diff --git a/Cargo.lock b/Cargo.lock
index b5c096c3c76618a0682b361a9785e5dff8246398..39dde40d5cb822acd8b1d4f75bfc24bdf5620915 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -451,6 +451,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+[[package]]
+name = "base64"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
+
[[package]]
name = "bit-set"
version = "0.10.0"
@@ -964,6 +970,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
name = "draw"
version = "0.1.0"
dependencies = [
+ "base64 0.22.1",
"bytemuck",
"image",
"linebender_resource_handle",
@@ -1237,6 +1244,17 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
[[package]]
name = "getrandom"
version = "0.3.4"
@@ -1400,6 +1418,22 @@ dependencies = [
"digest",
]
+[[package]]
+name = "http"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
[[package]]
name = "hybrid-array"
version = "0.4.14"
@@ -1885,6 +1919,8 @@ dependencies = [
"canvas",
"draw",
"notebook",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
"onestore",
"parley",
"pollster",
@@ -1990,6 +2026,7 @@ name = "notebook"
version = "0.1.0"
dependencies = [
"libc",
+ "nix",
"onestore",
"rusqlite",
"serde",
@@ -2361,7 +2398,7 @@ name = "onestore"
version = "0.1.0"
dependencies = [
"aes",
- "base64",
+ "base64 0.22.1",
"bumpalo",
"cbc",
"getrandom 0.4.3",
@@ -2849,6 +2886,20 @@ dependencies = [
"bytemuck",
]
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
[[package]]
name = "roxmltree"
version = "0.21.1"
@@ -2924,6 +2975,41 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "rustls"
+version = "0.23.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
+dependencies = [
+ "log",
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
+dependencies = [
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
[[package]]
name = "rustversion"
version = "1.0.23"
@@ -3232,8 +3318,10 @@ name = "snowbound"
version = "0.1.0"
dependencies = [
"accesskit",
+ "accesskit_consumer",
"accesskit_winit",
"arboard",
+ "block2 0.5.1",
"canvas",
"draw",
"fontique",
@@ -3247,11 +3335,16 @@ dependencies = [
"parley",
"png",
"pollster",
+ "ring",
"sctk-adwaita",
"serde",
"serde_json",
"smithay-clipboard",
"ui",
+ "ureq",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
"wgpu",
"winit",
"zbus",
@@ -3616,6 +3709,8 @@ dependencies = [
name = "ui"
version = "0.1.0"
dependencies = [
+ "accesskit",
+ "accesskit_consumer",
"draw",
"nucleo-matcher",
"parley",
@@ -3653,6 +3748,40 @@ dependencies = [
"ctutils",
]
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "ureq"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
+dependencies = [
+ "base64 0.23.1",
+ "log",
+ "percent-encoding",
+ "rustls",
+ "rustls-pki-types",
+ "ureq-proto",
+ "utf8-zero",
+ "webpki-roots",
+]
+
+[[package]]
+name = "ureq-proto"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be"
+dependencies = [
+ "base64 0.23.1",
+ "http",
+ "httparse",
+ "log",
+]
+
[[package]]
name = "usvg"
version = "0.48.1"
@@ -3678,6 +3807,12 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
+[[package]]
+name = "utf8-zero"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
+
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -3942,6 +4077,15 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "webpki-roots"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
+dependencies = [
+ "rustls-pki-types",
+]
+
[[package]]
name = "weezl"
version = "0.1.12"
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000000000000000000000000000000000000..46944bba7343fcbb526ae34e9953c4973a8d37bc
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,15 @@
+ISC License
+
+Copyright (c) 2026 clover caruso
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
diff --git a/apps/ios/Info.plist b/apps/ios/Info.plist
index ab475a47a5afa0062c2d7d809d6a6a1299034982..4c4d8523c9c02c23057ad0e2cd42d2e08da38af4 100644
--- a/apps/ios/Info.plist
+++ b/apps/ios/Info.plist
@@ -8,6 +8,18 @@
NSCameraUsageDescription
Take a photo to put on a page.
+ NSUbiquitousContainers
+
+ iCloud.net.paperclover.snowbound
+
+ NSUbiquitousContainerIsDocumentScopePublic
+
+ NSUbiquitousContainerName
+ Snowbound
+ NSUbiquitousContainerSupportedFolderLevels
+ Any
+
+
NSLocalNetworkUsageDescription
Snowbound opens notebooks on file servers on your network.
UIApplicationSceneManifest
diff --git a/apps/ios/Snowbound.entitlements b/apps/ios/Snowbound.entitlements
new file mode 100644
index 0000000000000000000000000000000000000000..201297119e45e3839763982c3fa8c3e7e85b0a7b
--- /dev/null
+++ b/apps/ios/Snowbound.entitlements
@@ -0,0 +1,18 @@
+
+
+
+
+ com.apple.developer.icloud-container-identifiers
+
+ iCloud.net.paperclover.snowbound
+
+ com.apple.developer.icloud-services
+
+ CloudDocuments
+
+ com.apple.developer.ubiquity-container-identifiers
+
+ iCloud.net.paperclover.snowbound
+
+
+
diff --git a/apps/ios/Snowbound.xcodeproj/project.pbxproj b/apps/ios/Snowbound.xcodeproj/project.pbxproj
index 7d83a972b8732bc7904b5361fa1deea4c1950680..21806b54565e4cbfbb00ff930e63953437c9e447 100644
--- a/apps/ios/Snowbound.xcodeproj/project.pbxproj
+++ b/apps/ios/Snowbound.xcodeproj/project.pbxproj
@@ -198,6 +198,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ARCHS = arm64;
+ CODE_SIGN_ENTITLEMENTS = Snowbound.entitlements;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
HEADER_SEARCH_PATHS = "$(SRCROOT)/../../crates/mobile/include";
@@ -219,7 +220,7 @@
"-framework",
CoreText,
);
- PRODUCT_BUNDLE_IDENTIFIER = dev.clover.snowbound;
+ PRODUCT_BUNDLE_IDENTIFIER = net.paperclover.snowbound;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_EMIT_LOC_STRINGS = NO;
@@ -233,6 +234,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ARCHS = arm64;
+ CODE_SIGN_ENTITLEMENTS = Snowbound.entitlements;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
HEADER_SEARCH_PATHS = "$(SRCROOT)/../../crates/mobile/include";
@@ -254,7 +256,7 @@
"-framework",
CoreText,
);
- PRODUCT_BUNDLE_IDENTIFIER = dev.clover.snowbound;
+ PRODUCT_BUNDLE_IDENTIFIER = net.paperclover.snowbound;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_EMIT_LOC_STRINGS = NO;
diff --git a/apps/ios/Snowbound/App.swift b/apps/ios/Snowbound/App.swift
index d536d95c80df1c3ac722916bb0dc81ff58a1c92e..35753ed3d7871e21d337fb6f9211b7543d9bf944 100644
--- a/apps/ios/Snowbound/App.swift
+++ b/apps/ios/Snowbound/App.swift
@@ -19,6 +19,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
_ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
sb_set_coordinator(coordinate)
+ ICloud.start()
sb_set_sync_wake { DispatchQueue.main.async { NotificationCenter.default.post(name: Sync.changed, object: nil) } }
#if DEBUG
Presenter.watch()
@@ -45,7 +46,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
/// Where the reader was, reopened on the next launch as Notes reopens its last note.
private struct Place: Codable {
- let notebook: UUID
+ let notebook: String
let section: String
let page: String?
@@ -90,6 +91,11 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro
self?.restore(notebook)
}
}
+ // iCloud Drive's notebooks list once its folder is found, and again when the account
+ // changes.
+ NotificationCenter.default.addObserver(forName: ICloud.changed, object: nil, queue: .main) { [weak self] _ in
+ self?.notebooks.rescan { self?.restore($0) }
+ }
}
/// Reopens the section and page shown last, or the scripted one.
@@ -171,6 +177,15 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro
Place.saved = Place(notebook: section.notebook.id, section: section.tab.path, page: page)
}
+ /// Returns to the list from any section of `notebook` shown, as before its folder moves.
+ func close(_ notebook: Notebook) {
+ guard pages.section?.notebook === notebook else { return }
+ pages.clear()
+ showingPage = false
+ split.setViewController(UIViewController(), for: .secondary)
+ split.show(.primary)
+ }
+
/// Adds a page to the open section and opens it, its title ready for typing.
func newPage(subpage: Bool = false) {
guard let section = pages.section else { return }
@@ -194,6 +209,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro
/// Composition ends and every edit is stored and published while the system allows.
func sceneDidEnterBackground(_ scene: UIScene) {
NotificationCenter.default.post(name: PageViewController.leaving, object: nil)
+ for notebook in Notebooks.all { notebook.pause() }
let sections = Section.all
guard !sections.isEmpty, background == .invalid else { return }
background = UIApplication.shared.beginBackgroundTask(withName: "Saving") { [weak self] in
@@ -211,8 +227,16 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro
background = .invalid
}
+ /// Someone moving to another device finds their edits there: they publish now rather than
+ /// after a pause in typing.
+ func sceneWillResignActive(_ scene: UIScene) {
+ for section in Section.all { section.wake() }
+ }
+
/// Changes made elsewhere while away show at once.
func sceneWillEnterForeground(_ scene: UIScene) {
+ for notebook in Notebooks.all { notebook.resume() }
+ notebooks.rescan()
for section in Section.all { section.wake() }
}
}
diff --git a/apps/ios/Snowbound/CanvasView.swift b/apps/ios/Snowbound/CanvasView.swift
index ac0f2420af601edb0eb0be8080611197038f7219..584ee4abb5346049c6b9e4c8bc5d7cfd0ad91f11 100644
--- a/apps/ios/Snowbound/CanvasView.swift
+++ b/apps/ios/Snowbound/CanvasView.swift
@@ -628,20 +628,25 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
}
}
- /// The Pencil, or a drawing finger, comes down at `point` in the scroll view's bounds.
- func inkPressed(_ point: CGPoint) {
+ /// The Pencil, or a drawing finger, comes down at `sample`'s point in the scroll view's bounds.
+ func inkPressed(_ sample: InkSample) {
guard let handle else { return }
// Drawing puts the keyboard away, as Notes does.
if isFirstResponder { _ = resignFirstResponder() }
use(inkTool)
editMenu.dismissMenu()
- let point = visible(point)
+ let point = visible(sample.point)
+ sb_view_pressure(handle, sample.pressure)
edit { sb_view_press(handle, Float(point.x), Float(point.y)) }
}
- func inkMoved(_ points: ArraySlice) {
+ func inkMoved(_ samples: ArraySlice) {
guard let handle else { return }
- for point in points.map(visible) { _ = sb_view_drag(handle, Float(point.x), Float(point.y)) }
+ for sample in samples {
+ let point = visible(sample.point)
+ sb_view_pressure(handle, sample.pressure)
+ _ = sb_view_drag(handle, Float(point.x), Float(point.y))
+ }
dirty = true
}
@@ -664,7 +669,10 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
_ interaction: UIEditMenuInteraction, menuFor configuration: UIEditMenuConfiguration,
suggestedActions: [UIMenuElement]
) -> UIMenu? {
- guard configuration.identifier as? NSString == Self.inkMenu else { return UIMenu(children: suggestedActions) }
+ guard configuration.identifier as? NSString == Self.inkMenu else {
+ return selectedTextRange.flatMap { editMenu(for: $0, suggestedActions: suggestedActions) }
+ ?? UIMenu(children: suggestedActions)
+ }
return UIMenu(children: [
UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in
self?.deleteInk()
@@ -828,7 +836,32 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
self?.apply(10)
}, at: 0)
}
- return UIMenu(children: suggestedActions + [UIMenu(options: .displayInline, children: extra)])
+ return UIMenu(
+ children: spellingActions() + suggestedActions + [UIMenu(options: .displayInline, children: extra)])
+ }
+
+ private struct Correction: Decodable {
+ let word: String
+ let suggestions: [String]
+ let repeated: Bool
+ }
+
+ /// OneNote's corrections for the marked word at the selection, first in the menu.
+ private func spellingActions() -> [UIMenuElement] {
+ guard let handle, let correction = decode(Correction.self, sb_view_correction(handle)) else { return [] }
+ let correct = { [weak self] (command: UInt8, text: String?) in
+ guard let self, let handle = self.handle else { return }
+ self.edit(external: true) { sb_view_correct(handle, command, text) }
+ }
+ let replacements =
+ correction.repeated
+ ? [UIAction(title: "Delete Repeated Word") { _ in correct(0, "") }]
+ : correction.suggestions.map { word in UIAction(title: word) { _ in correct(0, word) } }
+ let ignore = UIAction(title: "Ignore") { _ in correct(1, nil) }
+ let learn = UIAction(title: "Add to Dictionary") { _ in correct(2, nil) }
+ return [
+ UIMenu(options: .displayInline, children: replacements + (correction.repeated ? [ignore] : [ignore, learn]))
+ ]
}
// MARK: Page
diff --git a/apps/ios/Snowbound/ICloud.swift b/apps/ios/Snowbound/ICloud.swift
new file mode 100644
index 0000000000000000000000000000000000000000..233bfdd1fea102b542cb6dbcfa0727b038604993
--- /dev/null
+++ b/apps/ios/Snowbound/ICloud.swift
@@ -0,0 +1,80 @@
+import Foundation
+
+/// Lists the conflict versions iCloud Drive keeps beside a section file, as it keeps another
+/// device's commit that lost; the library merges each into the file.
+private let listVersions: sb_versions = { path, found, context in
+ guard let path, let found else { return }
+ let url = URL(fileURLWithPath: String(cString: path))
+ for version in NSFileVersion.unresolvedConflictVersionsOfItem(at: url) ?? [] {
+ version.url.path.withCString { id in
+ guard let device = version.localizedNameOfSavingComputer else { return found(context, id, nil) }
+ device.withCString { found(context, id, $0) }
+ }
+ }
+}
+
+/// Resolves and removes a version the library merged, first keeping it beside the file as
+/// “Name (Device).one” when it is another section. A version already gone, as another device
+/// resolved it, is done.
+private let retireVersion: sb_retire = { path, id, keep in
+ guard let path, let id else { return false }
+ let url = URL(fileURLWithPath: String(cString: path))
+ let named = String(cString: id)
+ guard let version = NSFileVersion.unresolvedConflictVersionsOfItem(at: url)?.first(where: { $0.url.path == named })
+ else { return true }
+ var retired = false
+ var error: NSError?
+ NSFileCoordinator().coordinate(writingItemAt: url, options: .forMerging, error: &error) { url in
+ if keep {
+ let device = version.localizedNameOfSavingComputer ?? "another device"
+ let stem = url.deletingPathExtension().lastPathComponent
+ let folder = url.deletingLastPathComponent()
+ let kept = (1...).lazy.map { n in
+ folder.appendingPathComponent(n == 1 ? "\(stem) (\(device)).one" : "\(stem) (\(device) \(n)).one")
+ }.first { !FileManager.default.fileExists(atPath: $0.path) }
+ guard let kept, (try? FileManager.default.copyItem(at: version.url, to: kept)) != nil else { return }
+ }
+ version.isResolved = true
+ retired = (try? version.remove()) != nil
+ }
+ return retired
+}
+
+/// Snowbound's folder in iCloud Drive: its container's Documents, which Files shows as iCloud
+/// Drive's Snowbound folder.
+enum ICloud {
+ /// Sent on the main thread when the folder was looked up again.
+ static let changed = Notification.Name("ICloudChanged")
+ private static let identifier = "iCloud.net.paperclover.snowbound"
+
+ /// The folder, once looked up; nil while iCloud Drive is off, signed out, or the app lacks
+ /// the container.
+ private(set) static var documents: URL?
+
+ /// Whether iCloud is signed in with iCloud Drive on, container or not.
+ static var signedIn: Bool { FileManager.default.ubiquityIdentityToken != nil }
+
+ /// Has the library merge conflict versions, and follows the account; call once at launch.
+ static func start() {
+ sb_set_versions(listVersions, retireVersion)
+ NotificationCenter.default.addObserver(
+ forName: .NSUbiquityIdentityDidChange, object: nil, queue: .main
+ ) { _ in lookUp() }
+ lookUp()
+ }
+
+ /// Looks the folder up off the main thread, as asking iCloud can take a while.
+ static func lookUp() {
+ background({ () -> URL? in
+ guard
+ let container = FileManager.default.url(forUbiquityContainerIdentifier: identifier)?
+ .appendingPathComponent("Documents", isDirectory: true)
+ else { return nil }
+ try? FileManager.default.createDirectory(at: container, withIntermediateDirectories: true)
+ return container
+ }) { found in
+ documents = found
+ NotificationCenter.default.post(name: changed, object: nil)
+ }
+ }
+}
diff --git a/apps/ios/Snowbound/Ink.swift b/apps/ios/Snowbound/Ink.swift
index 0643d2c14fdbf0590c7624848120d85e11562fb3..da9714374545ef9406937d2822af136a25d20952 100644
--- a/apps/ios/Snowbound/Ink.swift
+++ b/apps/ios/Snowbound/Ink.swift
@@ -43,19 +43,28 @@ struct Pen {
}
}
+/// A point a touch passed, in the view's bounds, and the Pencil's pressure there from 0 to its
+/// greatest, or -1 for a finger, whose strokes keep their pen's width.
+typealias InkSample = (point: CGPoint, pressure: Float)
+
/// Follows one touch from the moment it lands, gathering every point the hardware sampled,
/// for the canvas's pens, eraser and lasso. A second finger cancels a finger's stroke, so two
/// fingers scroll; a hand resting beside the Pencil is ignored.
final class InkGesture: UIGestureRecognizer {
- /// Points the touch passed since the action last took them, in the view's bounds.
- var points: [CGPoint] = []
+ /// Points the touch passed since the action last took them.
+ var points: [InkSample] = []
private var tracked: UITouch?
+ private func sample(_ touch: UITouch) -> InkSample {
+ let pressure = touch.type == .pencil ? Float(touch.force / touch.maximumPossibleForce) : -1
+ return (touch.preciseLocation(in: view), pressure)
+ }
+
override func touchesBegan(_ touches: Set, with event: UIEvent) {
for touch in touches {
if tracked == nil {
tracked = touch
- points = [touch.preciseLocation(in: view)]
+ points = [sample(touch)]
state = .began
} else if tracked?.type == .direct {
state = .cancelled
@@ -67,13 +76,13 @@ final class InkGesture: UIGestureRecognizer {
override func touchesMoved(_ touches: Set, with event: UIEvent) {
guard let tracked, touches.contains(tracked) else { return }
- points += (event.coalescedTouches(for: tracked) ?? [tracked]).map { $0.preciseLocation(in: view) }
+ points += (event.coalescedTouches(for: tracked) ?? [tracked]).map(sample)
state = .changed
}
override func touchesEnded(_ touches: Set, with event: UIEvent) {
guard let tracked, touches.contains(tracked) else { return }
- points.append(tracked.preciseLocation(in: view))
+ points.append(sample(tracked))
state = .ended
}
diff --git a/apps/ios/Snowbound/Lists.swift b/apps/ios/Snowbound/Lists.swift
index 11597dcb29136324d96d479f375634578adf3c5d..1cc494ef877c504d61a24d0ce15068e8697d76dc 100644
--- a/apps/ios/Snowbound/Lists.swift
+++ b/apps/ios/Snowbound/Lists.swift
@@ -1,21 +1,40 @@
import UIKit
import UniformTypeIdentifiers
-/// Every open notebook with its sections, as Notes lists each account's folders.
-final class NotebooksViewController: UITableViewController, UIDocumentPickerDelegate {
- private enum Item {
- case group(String, depth: Int)
- case section(Tab, depth: Int)
- case opening
- case problem(String)
+/// The places notebooks are kept, each listing its notebooks with their sections, as Files
+/// lists its locations and Notes each account's folders.
+final class NotebooksViewController: UICollectionViewController, UIDocumentPickerDelegate {
+ private enum Location: Hashable {
+ /// Snowbound's folder in iCloud Drive, where new notebooks go while iCloud Drive is on.
+ case icloud
+ /// Snowbound's Documents, which Files shows as Snowbound's folder.
+ case onDevice
+ /// Notebooks opened from Files or a server.
+ case elsewhere
}
- private var items: [[Item]] = []
- private var selected: (notebook: UUID, path: String)?
+ private enum Item: Hashable {
+ case location(Location)
+ case notebook(String)
+ /// A section group, by its `/`-separated path.
+ case group(notebook: String, path: String)
+ case section(notebook: String, path: String)
+ /// Opening, or why the notebook cannot be.
+ case status(notebook: String)
+ case newNotebook, openFolder, connect
+ /// New Notebook in iCloud Drive, and the way to it while iCloud Drive is off.
+ case newInCloud, turnOnICloud
+ }
+
+ private var dataSource: UICollectionViewDiffableDataSource!
+ private var collapsed: Set- = []
+ private var selected: Item?
var onOpen: ((Tab, Notebook) -> Void)?
+ private static let device = "On My \(UIDevice.current.model)"
+
init() {
- super.init(style: .insetGrouped)
+ super.init(collectionViewLayout: UICollectionViewFlowLayout())
title = "Notebooks"
navigationItem.largeTitleDisplayMode = .always
}
@@ -24,23 +43,79 @@ final class NotebooksViewController: UITableViewController, UIDocumentPickerDele
override func viewDidLoad() {
super.viewDidLoad()
- tableView.register(UITableViewCell.self, forCellReuseIdentifier: "row")
+ collectionView.collectionViewLayout = UICollectionViewCompositionalLayout { [weak self] _, environment in
+ let compact = environment.traitCollection.horizontalSizeClass == .compact
+ var list = UICollectionLayoutListConfiguration(appearance: compact ? .insetGrouped : .sidebar)
+ list.headerMode = .firstItemInSection
+ list.trailingSwipeActionsConfigurationProvider = { indexPath in
+ guard let self, let item = self.dataSource.itemIdentifier(for: indexPath) else { return nil }
+ if case .notebook(let id) = item, let notebook = self.notebook(id) {
+ return UISwipeActionsConfiguration(actions: self.swipeActions(for: notebook, at: indexPath))
+ }
+ guard item == .location(.onDevice) else { return nil }
+ let hide = UIContextualAction(style: .normal, title: "Hide") { [weak self] _, _, done in
+ self?.showOnDevice(false)
+ done(true)
+ }
+ hide.image = UIImage(systemName: "eye.slash")
+ return UISwipeActionsConfiguration(actions: [hide])
+ }
+ return NSCollectionLayoutSection.list(using: list, layoutEnvironment: environment)
+ }
+ let cell = UICollectionView.CellRegistration { [weak self] cell, _, item in
+ self?.configure(cell, item)
+ }
+ dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { view, indexPath, item in
+ view.dequeueConfiguredReusableCell(using: cell, for: indexPath, item: item)
+ }
+ dataSource.sectionSnapshotHandlers.willCollapseItem = { [weak self] in self?.collapsed.insert($0) }
+ dataSource.sectionSnapshotHandlers.willExpandItem = { [weak self] in self?.collapsed.remove($0) }
+
let add = UIMenu(children: [
- UIAction(title: "Open from Files…", image: UIImage(systemName: "folder")) { [weak self] _ in
- self?.openFromFiles()
- },
- UIAction(title: "Connect to Server…", image: UIImage(systemName: "server.rack")) { [weak self] _ in
- self?.connect()
- },
- UIAction(title: "Personalize…", image: UIImage(systemName: "person.crop.circle")) { [weak self] _ in
- guard let self else { return }
- Author.ask(from: self) {}
- },
+ UIDeferredMenuElement.uncached { [weak self] provide in
+ guard let self else { return provide([]) }
+ var actions: [UIMenuElement] = []
+ if ICloud.documents != nil || Notebooks.showsOnDevice {
+ actions.append(
+ UIAction(title: "New Notebook…", image: UIImage(systemName: "plus")) { [weak self] _ in
+ self?.newNotebook(inCloud: ICloud.documents != nil)
+ })
+ }
+ actions += [
+ UIAction(title: "Open Folder…", image: UIImage(systemName: "folder")) { [weak self] _ in
+ self?.openFolder()
+ },
+ UIAction(title: "Connect to Server…", image: UIImage(systemName: "server.rack")) { [weak self] _ in
+ self?.connect()
+ },
+ ]
+ var more: [UIMenuElement] = []
+ if !Notebooks.showsOnDevice {
+ more.append(
+ UIAction(title: "Show \(Self.device)", image: UIImage(systemName: "eye")) { [weak self] _ in
+ self?.showOnDevice(true)
+ })
+ }
+ more.append(
+ UIAction(title: "Personalize…", image: UIImage(systemName: "person.crop.circle")) { [weak self] _ in
+ guard let self else { return }
+ Author.ask(from: self) {}
+ })
+ provide(actions + [UIMenu(options: .displayInline, children: more)])
+ }
])
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "Add Notebook", image: UIImage(systemName: "plus"), menu: add)
navigationItem.searchController = SearchViewController.controller()
toolbarItems = [.flexibleSpace(), UIBarButtonItem(customView: SyncIndicator()), .flexibleSpace()]
+ NotificationCenter.default.addObserver(forName: Notebook.listed, object: nil, queue: .main) { [weak self] _ in
+ guard let self else { return }
+ reload()
+ var shown = dataSource.snapshot()
+ shown.reconfigureItems(shown.itemIdentifiers)
+ dataSource.apply(shown, animatingDifferences: false)
+ }
+ reload()
}
override func viewWillAppear(_ animated: Bool) {
@@ -49,47 +124,403 @@ final class NotebooksViewController: UITableViewController, UIDocumentPickerDele
navigationController?.setToolbarHidden(false, animated: false)
}
+ private func notebook(_ id: String) -> Notebook? { Notebooks.all.first { $0.id == id } }
+
func reload() {
- items = Notebooks.all.map { notebook in
- if notebook.handle == nil {
- return [notebook.problem.map(Item.problem) ?? .opening]
- }
- var rows: [Item] = []
- var group = ""
- for tab in notebook.tabs {
- if tab.group != group {
- // Each group level not yet shown gets its own row.
- let parts = tab.group.split(separator: "/").map(String.init)
- let shown = group.split(separator: "/").map(String.init)
- let common = zip(parts, shown).prefix { $0 == $1 }.count
- for depth in common..()
+ sections.appendSections(locations.map(\.0))
+ dataSource.apply(sections, animatingDifferences: false)
+ for (location, notebooks, actions) in locations {
+ var list = NSDiffableDataSourceSectionSnapshot
- ()
+ let header = Item.location(location)
+ list.append([header])
+ list.append(notebooks.map { .notebook($0.id) } + actions, to: header)
+ for notebook in notebooks {
+ let parent = Item.notebook(notebook.id)
+ guard notebook.handle != nil else {
+ list.append([.status(notebook: notebook.id)], to: parent)
+ continue
+ }
+ for tab in notebook.tabs {
+ var parent = parent
+ var path = ""
+ for part in tab.group.split(separator: "/") {
+ path += (path.isEmpty ? "" : "/") + part
+ let group = Item.group(notebook: notebook.id, path: path)
+ if !list.contains(group) { list.append([group], to: parent) }
+ parent = group
+ }
+ list.append([.section(notebook: notebook.id, path: tab.path)], to: parent)
}
- rows.append(.section(tab, depth: tab.group.split(separator: "/").count))
}
- return rows
+ list.expand(list.items.filter { !collapsed.contains($0) })
+ dataSource.apply(list, to: location, animatingDifferences: false)
}
- tableView.reloadData()
showSelection()
- var empty = UIContentUnavailableConfiguration.empty()
- empty.text = "No Notebooks"
- empty.secondaryText = "Open a OneNote notebook from Files or a server."
- contentUnavailableConfiguration = items.isEmpty ? empty : nil
+ }
+
+ /// Lists Documents and iCloud Drive again and opens the notebooks new there; `opened` runs
+ /// as each opens.
+ func rescan(opened: ((Notebook) -> Void)? = nil) {
+ for notebook in Notebooks.scanICloud() + Notebooks.scan() {
+ notebook.open { [weak self] in
+ self?.reload()
+ opened?(notebook)
+ }
+ }
+ reload()
+ }
+
+ private func showOnDevice(_ shown: Bool) {
+ Notebooks.showsOnDevice = shown
+ rescan()
}
func select(_ tab: Tab, of notebook: Notebook) {
- selected = (notebook.id, tab.path)
+ selected = .section(notebook: notebook.id, path: tab.path)
showSelection()
}
/// Marks the open section, as the sidebar of a wide window keeps it.
private func showSelection() {
- guard let selected, let section = Notebooks.all.firstIndex(where: { $0.id == selected.notebook }),
- let row = items[section].firstIndex(where: {
- if case .section(let tab, _) = $0 { tab.path == selected.path } else { false }
- })
+ guard let selected, let indexPath = dataSource.indexPath(for: selected) else { return }
+ collectionView.selectItem(at: indexPath, animated: false, scrollPosition: [])
+ }
+
+ private func configure(_ cell: UICollectionViewListCell, _ item: Item) {
+ var content = cell.defaultContentConfiguration()
+ var accessories: [UICellAccessory] = []
+ switch item {
+ case .location(let location):
+ content.text =
+ switch location {
+ case .icloud: "iCloud Drive"
+ case .onDevice: Self.device
+ case .elsewhere: "Other Locations"
+ }
+ accessories = [.outlineDisclosure(options: .init(style: .header))]
+ case .notebook(let id):
+ guard let notebook = notebook(id) else { break }
+ content.text = notebook.name
+ content.image = UIImage(systemName: "book.closed")
+ if case .server(let server) = notebook.source { content.secondaryText = server.host }
+ let more = UIButton(type: .system)
+ more.setImage(UIImage(systemName: "ellipsis.circle"), for: .normal)
+ more.accessibilityLabel = "More"
+ more.showsMenuAsPrimaryAction = true
+ more.menu = menu(for: notebook)
+ accessories = [
+ .customView(configuration: .init(customView: more, placement: .trailing(displayed: .always))),
+ .outlineDisclosure(options: .init(style: .cell)),
+ ]
+ case .group(_, let path):
+ content.text = path.split(separator: "/").last.map(String.init)
+ content.image = UIImage(systemName: "folder")
+ content.textProperties.color = .secondaryLabel
+ accessories = [.outlineDisclosure(options: .init(style: .cell))]
+ case .section(let id, let path):
+ guard let tab = notebook(id)?.tabs.first(where: { $0.path == path }) else { break }
+ content.text = tab.name
+ content.image = UIImage(
+ systemName: tab.readable ? "rectangle.portrait.fill" : tab.downloading ? "icloud.and.arrow.down" : "lock.fill")
+ content.imageProperties.tintColor = tab.uiColor
+ if !tab.readable {
+ content.secondaryText = tab.downloading ? "Downloading…" : tab.copy.map { "Copy of “\($0)”" } ?? tab.problem ?? "Can’t be opened here"
+ content.textProperties.color = .secondaryLabel
+ }
+ case .status(let id):
+ if let problem = notebook(id)?.problem {
+ content.text = "Can’t Open Notebook"
+ content.secondaryText = problem
+ content.image = UIImage(systemName: "exclamationmark.triangle")
+ content.imageProperties.tintColor = .systemOrange
+ let retry = UIImageView(image: UIImage(systemName: "arrow.clockwise"))
+ accessories = [.customView(configuration: .init(customView: retry, placement: .trailing()))]
+ } else {
+ content.text = "Opening…"
+ content.textProperties.color = .secondaryLabel
+ let spinner = UIActivityIndicatorView(style: .medium)
+ spinner.startAnimating()
+ accessories = [.customView(configuration: .init(customView: spinner, placement: .trailing()))]
+ }
+ case .turnOnICloud:
+ content.text = "Turn On iCloud Drive"
+ content.secondaryText = "Keep notebooks in iCloud to use them on all your devices."
+ content.image = UIImage(systemName: "icloud")
+ content.textProperties.color = .tintColor
+ case .newInCloud:
+ content.text = "New Notebook…"
+ content.image = UIImage(systemName: "plus")
+ content.textProperties.color = .tintColor
+ case .newNotebook, .openFolder, .connect:
+ content.text =
+ item == .newNotebook ? "New Notebook…" : item == .openFolder ? "Open Folder…" : "Connect to Server…"
+ content.image = UIImage(
+ systemName: item == .newNotebook ? "plus" : item == .openFolder ? "folder" : "server.rack")
+ content.textProperties.color = .tintColor
+ }
+ cell.contentConfiguration = content
+ cell.accessories = accessories
+ }
+
+ private func menu(for notebook: Notebook) -> UIMenu {
+ var actions = [
+ UIAction(title: "New Section…", image: UIImage(systemName: "plus.rectangle.portrait")) {
+ [weak self] _ in self?.newSection(in: notebook)
+ },
+ UIAction(title: "Tags Summary", image: UIImage(systemName: "tag")) { [weak self] _ in
+ guard let self else { return }
+ (view.window?.windowScene?.delegate as? SceneDelegate)?.showTags(of: notebook, from: self)
+ },
+ ]
+ // A notebook On My iPhone is its folder there, handled as Files handles a folder.
+ guard let folder = folder(of: notebook) else {
+ let image = UIImage(systemName: "xmark.circle")
+ let close = UIAction(title: "Close Notebook", image: image, attributes: .destructive) { [weak self] _ in
+ Notebooks.remove(notebook)
+ self?.reload()
+ }
+ return UIMenu(children: actions + [close])
+ }
+ let file = [
+ UIAction(title: "Rename…", image: UIImage(systemName: "pencil")) { [weak self] _ in
+ self?.rename(folder, of: notebook)
+ },
+ UIAction(title: "Duplicate", image: UIImage(systemName: "plus.square.on.square")) { [weak self] _ in
+ self?.duplicate(folder, of: notebook)
+ },
+ UIAction(title: "Share…", image: UIImage(systemName: "square.and.arrow.up")) { [weak self] _ in
+ self?.share(folder, from: notebook)
+ },
+ UIAction(title: "Show in Files", image: UIImage(systemName: "folder")) { _ in
+ // Files opens the folder a `shareddocuments` URL names.
+ if let url = URL(string: "shareddocuments://" + folder.path) { UIApplication.shared.open(url) }
+ },
+ ]
+ let delete = UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) {
+ [weak self] _ in self?.delete(folder, of: notebook)
+ }
+ return UIMenu(children: [
+ UIMenu(options: .displayInline, children: actions), UIMenu(options: .displayInline, children: file), delete,
+ ])
+ }
+
+ private func swipeActions(for notebook: Notebook, at indexPath: IndexPath) -> [UIContextualAction] {
+ guard let folder = folder(of: notebook) else {
+ let close = UIContextualAction(style: .destructive, title: "Close") { [weak self] _, _, done in
+ Notebooks.remove(notebook)
+ self?.reload()
+ done(true)
+ }
+ close.image = UIImage(systemName: "xmark.circle")
+ return [close]
+ }
+ let delete = UIContextualAction(style: .destructive, title: "Delete") { [weak self] _, _, done in
+ self?.delete(folder, of: notebook)
+ done(true)
+ }
+ delete.image = UIImage(systemName: "trash")
+ let share = UIContextualAction(style: .normal, title: "Share") { [weak self] _, _, done in
+ self?.share(folder, from: notebook)
+ done(true)
+ }
+ share.image = UIImage(systemName: "square.and.arrow.up")
+ share.backgroundColor = .systemBlue
+ return [delete, share]
+ }
+
+ /// The notebook's folder or section file On My iPhone; nil for one opened elsewhere.
+ private func folder(of notebook: Notebook) -> URL? {
+ guard case .documents(let path) = notebook.source else { return nil }
+ return documentsDirectory.appendingPathComponent(path)
+ }
+
+ /// Publishes the notebook's edits and lets go of it, closing any section of it shown,
+ /// then runs `act`; refuses with why while an edit can't be published, as moving the
+ /// folder then would strand it.
+ private func close(_ notebook: Notebook, refusing title: String, then act: @escaping () -> Void) {
+ for scene in UIApplication.shared.connectedScenes {
+ (scene.delegate as? SceneDelegate)?.close(notebook)
+ }
+ guard let handle = notebook.handle else { return act() }
+ let pointer = Int(bitPattern: handle)
+ background({ () -> String? in
+ var error: UnsafeMutablePointer?
+ let closed = sb_library_close(OpaquePointer(bitPattern: pointer), 10, &error)
+ let problem = take(error)
+ return closed ? nil : problem ?? ""
+ }) { [weak self] problem in
+ if let problem {
+ self?.refuse(title, problem)
+ return
+ }
+ notebook.pause()
+ act()
+ }
+ }
+
+ private func rename(_ folder: URL, of notebook: Notebook) {
+ let alert = UIAlertController(title: "Rename", message: nil, preferredStyle: .alert)
+ let isFile = folder.pathExtension.lowercased() == "one"
+ let rename = UIAlertAction(title: "Rename", style: .default) { [weak self, weak alert] _ in
+ let name = alert?.textFields?.first?.text?.trimmingCharacters(in: .whitespaces) ?? ""
+ let target = folder.deletingLastPathComponent().appendingPathComponent(
+ isFile ? name + "." + folder.pathExtension : name)
+ guard target != folder else { return }
+ guard !FileManager.default.fileExists(atPath: target.path) else {
+ self?.refuse("“\(name)” Already Exists", "Choose a different name.")
+ return
+ }
+ self?.close(notebook, refusing: "Can’t Rename") {
+ do {
+ try FileManager.default.moveItem(at: folder, to: target)
+ _ = sb_notebook_moved(cacheDirectory.path, folder.path, target.path, nil)
+ } catch {
+ self?.refuse("Can’t Rename", error.localizedDescription)
+ }
+ self?.rescan()
+ }
+ }
+ alert.addTextField { field in
+ field.text = folder.deletingPathExtension().lastPathComponent
+ field.autocapitalizationType = .words
+ field.clearButtonMode = .whileEditing
+ NotificationCenter.default.addObserver(
+ forName: UITextField.textDidChangeNotification, object: field, queue: .main
+ ) { _ in
+ let name = (field.text ?? "").trimmingCharacters(in: .whitespaces)
+ rename.isEnabled = !name.isEmpty && !name.hasPrefix(".") && !name.contains("/")
+ }
+ }
+ alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
+ alert.addAction(rename)
+ alert.preferredAction = rename
+ present(alert, animated: true)
+ }
+
+ /// Copies the notebook beside itself, named as Files names a duplicate.
+ private func duplicate(_ folder: URL, of notebook: Notebook) {
+ // The copy takes what the open sections have stored.
+ for section in Section.all where section.notebook === notebook && !section.flush(10) {
+ return refuse("Can’t Duplicate", "Some changes haven’t been saved to the notebook yet.")
+ }
+ let base = folder.deletingPathExtension().lastPathComponent
+ let ext = folder.pathExtension
+ var copy = folder
+ for n in 2... {
+ let name = base + " \(n)"
+ copy = folder.deletingLastPathComponent().appendingPathComponent(ext.isEmpty ? name : name + "." + ext)
+ if !FileManager.default.fileExists(atPath: copy.path) { break }
+ }
+ let target = copy
+ background({ () -> String? in
+ do { try FileManager.default.copyItem(at: folder, to: target) } catch { return error.localizedDescription }
+ return nil
+ }) { [weak self] problem in
+ if let problem { self?.refuse("Can’t Duplicate", problem) }
+ self?.rescan()
+ }
+ }
+
+ private func share(_ folder: URL, from notebook: Notebook) {
+ let sheet = UIActivityViewController(activityItems: [folder], applicationActivities: nil)
+ if let popover = sheet.popoverPresentationController {
+ if let indexPath = dataSource.indexPath(for: .notebook(notebook.id)),
+ let cell = collectionView.cellForItem(at: indexPath)
+ {
+ popover.sourceView = cell
+ popover.sourceRect = cell.bounds
+ } else {
+ popover.sourceView = view
+ }
+ }
+ present(sheet, animated: true)
+ }
+
+ /// Moves the notebook to Recently Deleted, or, where the system keeps none, removes it
+ /// once confirmed.
+ private func delete(_ folder: URL, of notebook: Notebook) {
+ close(notebook, refusing: "Can’t Delete") { [weak self] in self?.remove(folder, of: notebook) }
+ }
+
+ private func remove(_ folder: URL, of notebook: Notebook) {
+ if (try? FileManager.default.trashItem(at: folder, resultingItemURL: nil)) != nil {
+ return rescan()
+ }
+ let name = folder.deletingPathExtension().lastPathComponent
+ let alert = UIAlertController(
+ title: "Delete “\(name)”?", message: "This notebook will be deleted immediately. You can’t undo this action.",
+ preferredStyle: .alert)
+ alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { [weak self] _ in
+ notebook.open { self?.reload() }
+ })
+ alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in
+ do { try FileManager.default.removeItem(at: folder) } catch {
+ self?.refuse("Can’t Delete", error.localizedDescription)
+ }
+ self?.rescan()
+ })
+ present(alert, animated: true)
+ }
+
+ override func collectionView(
+ _ collectionView: UICollectionView, contextMenuConfigurationForItemsAt indexPaths: [IndexPath], point: CGPoint
+ ) -> UIContextMenuConfiguration? {
+ guard indexPaths.count == 1, case .notebook(let id) = dataSource.itemIdentifier(for: indexPaths[0]),
+ let notebook = notebook(id)
+ else { return nil }
+ return UIContextMenuConfiguration(actionProvider: { [weak self] _ in self?.menu(for: notebook) })
+ }
+
+ override func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
+ guard let item = dataSource.itemIdentifier(for: indexPath) else { return false }
+ switch item {
+ case .section(let id, let path):
+ return notebook(id)?.tabs.first(where: { $0.path == path })?.readable == true
+ case .status(let id):
+ guard let notebook = notebook(id), notebook.problem != nil else { return false }
+ let spinner = UIActivityIndicatorView(style: .medium)
+ spinner.startAnimating()
+ (collectionView.cellForItem(at: indexPath) as? UICollectionViewListCell)?.accessories = [
+ .customView(configuration: .init(customView: spinner, placement: .trailing()))
+ ]
+ notebook.open { [weak self] in self?.reload() }
+ case .notebook, .group:
+ // The row opens and closes, as a folder of Files' sidebar does.
+ guard let location = dataSource.sectionIdentifier(for: indexPath.section) else { return false }
+ var list = dataSource.snapshot(for: location)
+ if list.isExpanded(item) {
+ list.collapse([item])
+ collapsed.insert(item)
+ } else {
+ list.expand([item])
+ collapsed.remove(item)
+ }
+ dataSource.apply(list, to: location)
+ case .newNotebook: newNotebook(inCloud: false)
+ case .newInCloud: newNotebook(inCloud: true)
+ case .turnOnICloud:
+ if let settings = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(settings) }
+ case .openFolder: openFolder()
+ case .connect: connect()
+ case .location: break
+ }
+ return false
+ }
+
+ override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
+ guard case .section(let id, let path) = dataSource.itemIdentifier(for: indexPath), let notebook = notebook(id),
+ let tab = notebook.tabs.first(where: { $0.path == path })
else { return }
- tableView.selectRow(at: IndexPath(row: row, section: section), animated: false, scrollPosition: .none)
+ onOpen?(tab, notebook)
}
private func add(_ notebook: Notebook) {
@@ -102,7 +533,66 @@ final class NotebooksViewController: UITableViewController, UIDocumentPickerDele
}
}
- private func openFromFiles() {
+ /// Creates a notebook in iCloud Drive or On My iPhone, as OneNote's New Notebook does, and
+ /// opens it.
+ private func newNotebook(inCloud: Bool) {
+ guard let author = Author.name else {
+ return Author.ask(from: self) { [weak self] in self?.newNotebook(inCloud: inCloud) }
+ }
+ let alert = UIAlertController(title: "New Notebook", message: nil, preferredStyle: .alert)
+ let create = UIAlertAction(title: "Create", style: .default) { [weak self, weak alert] _ in
+ let name = alert?.textFields?.first?.text?.trimmingCharacters(in: .whitespaces) ?? ""
+ self?.create(name, by: author, inCloud: inCloud)
+ }
+ create.isEnabled = false
+ alert.addTextField { field in
+ field.placeholder = "Name"
+ field.autocapitalizationType = .words
+ NotificationCenter.default.addObserver(
+ forName: UITextField.textDidChangeNotification, object: field, queue: .main
+ ) { _ in
+ let name = (field.text ?? "").trimmingCharacters(in: .whitespaces)
+ create.isEnabled = !name.isEmpty && !name.hasPrefix(".") && !name.contains("/")
+ }
+ }
+ alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
+ alert.addAction(create)
+ alert.preferredAction = create
+ present(alert, animated: true)
+ }
+
+ private func create(_ name: String, by author: String, inCloud: Bool) {
+ let place = inCloud ? ICloud.documents : documentsDirectory
+ guard let folder = place?.appendingPathComponent(name) else { return }
+ guard !FileManager.default.fileExists(atPath: folder.path) else {
+ return refuse("“\(name)” Already Exists", "Choose a different name.")
+ }
+ let (date, time) = titleDate()
+ background({ () -> String? in
+ var error: UnsafeMutablePointer?
+ let made = sb_notebook_create(folder.path, cacheDirectory.path, author, date, time, &error)
+ let problem = take(error)
+ return made ? nil : problem ?? ""
+ }) { [weak self] problem in
+ guard let self else { return }
+ if let problem { return refuse("Can’t Create Notebook", problem) }
+ if !inCloud && !Notebooks.showsOnDevice { Notebooks.showsOnDevice = true }
+ rescan { [weak self] notebook in
+ guard notebook.source == (inCloud ? .icloud(path: name) : .documents(path: name)),
+ let tab = notebook.tabs.first(where: \.readable)
+ else { return }
+ self?.onOpen?(tab, notebook)
+ }
+ }
+ }
+
+ private func refuse(_ title: String, _ message: String) {
+ let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
+ alert.addAction(UIAlertAction(title: "OK", style: .default))
+ present(alert, animated: true)
+ }
+
+ private func openFolder() {
let picker = UIDocumentPickerViewController(
forOpeningContentTypes: [.folder, UTType(filenameExtension: "one") ?? .data])
picker.delegate = self
@@ -145,109 +635,6 @@ final class NotebooksViewController: UITableViewController, UIDocumentPickerDele
})
present(alert, animated: true)
}
-
- override func numberOfSections(in tableView: UITableView) -> Int { items.count }
-
- override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
- items[section].count
- }
-
- override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
- let notebook = Notebooks.all[section]
- var title = UIButton.Configuration.plain()
- title.title = notebook.name
- title.image = UIImage(systemName: "ellipsis.circle")
- title.imagePlacement = .trailing
- title.imagePadding = 6
- title.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 4, bottom: 4, trailing: 4)
- title.baseForegroundColor = .label
- title.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer {
- var attributes = $0
- attributes.font = UIFont.preferredFont(forTextStyle: .title3).withTraits(.traitBold)
- return attributes
- }
- let button = UIButton(configuration: title)
- button.contentHorizontalAlignment = .leading
- button.showsMenuAsPrimaryAction = true
- button.menu = UIMenu(children: [
- UIAction(title: "New Section…", image: UIImage(systemName: "plus.rectangle.portrait")) {
- [weak self] _ in self?.newSection(in: notebook)
- },
- UIAction(title: "Tags Summary", image: UIImage(systemName: "tag")) { [weak self] _ in
- guard let self else { return }
- (view.window?.windowScene?.delegate as? SceneDelegate)?.showTags(of: notebook, from: self)
- },
- UIAction(title: "Close Notebook", image: UIImage(systemName: "xmark.circle"), attributes: .destructive) {
- [weak self] _ in
- Notebooks.remove(notebook)
- self?.reload()
- },
- ])
- return button
- }
-
- override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
- let cell = tableView.dequeueReusableCell(withIdentifier: "row", for: indexPath)
- var content = cell.defaultContentConfiguration()
- cell.accessoryType = .none
- cell.selectionStyle = .none
- cell.accessoryView = nil
- switch items[indexPath.section][indexPath.row] {
- case .group(let name, let depth):
- content.text = name
- content.image = UIImage(systemName: "folder")
- content.textProperties.color = .secondaryLabel
- content.directionalLayoutMargins.leading += CGFloat(depth) * 20
- case .section(let tab, let depth):
- content.text = tab.name
- content.image = UIImage(systemName: tab.readable ? "rectangle.portrait.fill" : "lock.fill")
- content.imageProperties.tintColor = tab.uiColor
- content.directionalLayoutMargins.leading += CGFloat(depth) * 20
- if tab.readable {
- cell.accessoryType = .disclosureIndicator
- cell.selectionStyle = .default
- } else {
- content.secondaryText = "Can’t be opened here"
- content.textProperties.color = .secondaryLabel
- }
-
- case .opening:
- content.text = "Opening…"
- content.textProperties.color = .secondaryLabel
- let spinner = UIActivityIndicatorView(style: .medium)
- spinner.startAnimating()
- cell.accessoryView = spinner
- case .problem(let problem):
- content.text = "Can’t Open Notebook"
- content.secondaryText = problem
- content.image = UIImage(systemName: "exclamationmark.triangle")
- content.imageProperties.tintColor = .systemOrange
- cell.accessoryView = UIImageView(image: UIImage(systemName: "arrow.clockwise"))
- cell.selectionStyle = .default
- }
- cell.contentConfiguration = content
- return cell
- }
-
- override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
- switch items[indexPath.section][indexPath.row] {
- case .section(let tab, _): tab.readable ? indexPath : nil
- case .problem: indexPath
- default: nil
- }
- }
-
- override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
- let notebook = Notebooks.all[indexPath.section]
- switch items[indexPath.section][indexPath.row] {
- case .section(let tab, _): onOpen?(tab, notebook)
- case .problem:
- items[indexPath.section] = [.opening]
- tableView.reloadSections([indexPath.section], with: .none)
- notebook.open { [weak self] in self?.reload() }
- default: break
- }
- }
}
extension UIFont {
@@ -308,6 +695,16 @@ final class PagesViewController: UITableViewController {
navigationController?.setToolbarHidden(true, animated: false)
}
+ /// Lets go of the section shown, as when its notebook goes.
+ func clear() {
+ section = nil
+ items = []
+ title = nil
+ tableView.reloadData()
+ contentUnavailableConfiguration = nil
+ navigationController?.setToolbarHidden(true, animated: false)
+ }
+
func failed(_ tab: Tab, _ problem: String?) {
var empty = UIContentUnavailableConfiguration.empty()
empty.text = "Can’t Open Section"
diff --git a/apps/ios/Snowbound/Notebooks.swift b/apps/ios/Snowbound/Notebooks.swift
index b5d364c00d259efd0e411d0f5f82a5fce993db43..dd3ffcc6b8dd515facf23b2db79ade74ca685020 100644
--- a/apps/ios/Snowbound/Notebooks.swift
+++ b/apps/ios/Snowbound/Notebooks.swift
@@ -109,6 +109,8 @@ enum Source: Codable, Equatable {
case files(bookmark: Data)
/// A folder in the app's Documents, which Files shows as Snowbound's.
case documents(path: String)
+ /// A folder in Snowbound's folder in iCloud Drive (`ICloud.documents`).
+ case icloud(path: String)
/// A notebook folder on an SMB share, opened through Snowbound's own client.
case server(Server)
/// A folder or section at an absolute path, for scripted runs.
@@ -124,6 +126,12 @@ struct Tab: Decodable, Equatable {
let group: String
let color: [UInt8]
let readable: Bool
+ /// Not on this device yet; `Notebook` asks iCloud Drive for it.
+ let downloading: Bool
+ /// The section this file copies, which opens instead.
+ let copy: String?
+ /// Why the file could not be read, where retrying or repair may help.
+ let problem: String?
var uiColor: UIColor {
UIColor(red: CGFloat(color[0]) / 255, green: CGFloat(color[1]) / 255, blue: CGFloat(color[2]) / 255, alpha: 1)
@@ -150,7 +158,8 @@ let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .
/// An open notebook: its Rust library and the sections it lists.
final class Notebook {
- let id: UUID
+ /// A UUID, or `Documents/` and the folder's name for a notebook On My iPhone.
+ let id: String
let source: Source
private(set) var name: String
private(set) var handle: OpaquePointer?
@@ -161,8 +170,12 @@ final class Notebook {
private var accessed: URL?
/// Reports other apps' changes to a folder on this device to the library.
private var presenter: FolderPresenter?
+ /// Lists the sections again while iCloud Drive downloads some.
+ private var recheck: DispatchWorkItem?
+ /// Posted on the main thread when the sections list again on their own.
+ static let listed = Notification.Name("NotebookListed")
- init(id: UUID = UUID(), name: String, source: Source) {
+ init(id: String = UUID().uuidString, name: String, source: Source) {
self.id = id
self.name = name
self.source = source
@@ -205,6 +218,16 @@ final class Notebook {
let url = documentsDirectory.appendingPathComponent(path)
let library = sb_library_open(url.path, cacheDirectory.path, true, &error)
return (library, nil, take(error), url)
+ case .icloud(let path):
+ guard let url = ICloud.documents?.appendingPathComponent(path) else {
+ return (nil, nil, "Turn on iCloud Drive in Settings to open this notebook.", nil)
+ }
+ var library: OpaquePointer?
+ var coordination: NSError?
+ NSFileCoordinator().coordinate(readingItemAt: url, options: [], error: &coordination) { url in
+ library = sb_library_open(url.path, cacheDirectory.path, false, &error)
+ }
+ return (library, nil, take(error) ?? coordination?.localizedDescription, url)
case .path(let path):
let url = URL(fileURLWithPath: path)
let local = onThisDevice(url)
@@ -233,7 +256,8 @@ final class Notebook {
var location: String {
switch source {
case .files: "In Files"
- case .documents: "On this \(UIDevice.current.model), in Snowbound’s folder"
+ case .documents: "On My \(UIDevice.current.model), in Snowbound’s folder"
+ case .icloud: "In iCloud Drive"
case .server(let server):
"smb://\(server.host)/\([server.share, server.root].filter { !$0.isEmpty }.joined(separator: "/"))"
case .path(let path): path
@@ -249,9 +273,51 @@ final class Notebook {
let pointer = Int(bitPattern: handle)
background({ decode([Tab].self, sb_library_sections(OpaquePointer(bitPattern: pointer))) }) { [self] tabs in
if let tabs { self.tabs = tabs }
+ download()
done()
}
}
+
+ /// Asks iCloud Drive for the notebook files it keeps elsewhere, which iOS lists as
+ /// `.Name.icloud`, and lists the sections again until they arrive.
+ private func download() {
+ let folder: URL? =
+ switch source {
+ case .files: accessed
+ case .documents(let path): documentsDirectory.appendingPathComponent(path)
+ case .icloud(let path): ICloud.documents?.appendingPathComponent(path)
+ case .path(let path): URL(fileURLWithPath: path)
+ case .server: nil
+ }
+ guard let folder, recheck == nil,
+ let files = FileManager.default.enumerator(at: folder, includingPropertiesForKeys: nil)
+ else { return }
+ var waiting = false
+ for case let stub as URL in files where stub.lastPathComponent.hasPrefix(".") && stub.pathExtension == "icloud" {
+ let name = String(stub.deletingPathExtension().lastPathComponent.dropFirst())
+ let file = stub.deletingLastPathComponent().appendingPathComponent(name)
+ try? FileManager.default.startDownloadingUbiquitousItem(at: file)
+ waiting = true
+ }
+ guard waiting else { return }
+ let recheck = DispatchWorkItem { [weak self] in
+ self?.recheck = nil
+ self?.reload { NotificationCenter.default.post(name: Self.listed, object: self) }
+ }
+ self.recheck = recheck
+ DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: recheck)
+ }
+
+ /// Stops hearing other apps' writes while in the background, where a suspended presenter
+ /// can deadlock their coordinated writes.
+ func pause() { presenter?.stop() }
+
+ /// Hears other apps' writes again, and checks the sections and their list for those
+ /// missed meanwhile.
+ func resume() {
+ presenter?.resume()
+ reload { NotificationCenter.default.post(name: Self.listed, object: self) }
+ }
}
/// Whether `url` is kept on this device, rather than by a file provider that keeps it elsewhere
@@ -281,7 +347,12 @@ final class FolderPresenter: NSObject, NSFilePresenter {
NSFileCoordinator.addFilePresenter(self)
}
- /// Stops reporting, before the library goes.
+ func resume() {
+ NSFileCoordinator.addFilePresenter(self)
+ touched("")
+ }
+
+ /// Stops reporting, before the library goes or the app leaves the foreground.
func stop() {
NSFileCoordinator.removeFilePresenter(self)
presentedItemOperationQueue.waitUntilAllOperationsAreFinished()
@@ -290,6 +361,8 @@ final class FolderPresenter: NSObject, NSFilePresenter {
func presentedItemDidChange() { touched("") }
func presentedSubitemDidChange(at url: URL) { touched(url) }
func presentedSubitemDidAppear(at url: URL) { touched(url) }
+ /// Another device's commit that lost in iCloud Drive, which changes no file.
+ func presentedSubitem(at url: URL, didGain version: NSFileVersion) { touched(url) }
func presentedSubitem(at oldURL: URL, didMoveTo newURL: URL) {
touched(oldURL)
touched(newURL)
@@ -309,59 +382,132 @@ final class FolderPresenter: NSObject, NSFilePresenter {
}
}
-/// The notebooks the list shows, kept across launches.
+/// The notebooks the list shows: those in Documents, and those opened from elsewhere, kept
+/// across launches.
enum Notebooks {
private struct Entry: Codable {
- let id: UUID
+ let id: String
let name: String
let source: Source
}
private static let key = "notebooks"
- private(set) static var all: [Notebook] = []
+ private static let hidden = "hidesOnDevice"
+ private static let scripted = ProcessInfo.processInfo.environment["SNOWBOUND_NOTEBOOK"]
+ /// The folders and sections in Documents, as On My iPhone lists them.
+ private(set) static var onDevice: [Notebook] = []
+ /// The folders in Snowbound's folder in iCloud Drive.
+ private(set) static var inCloud: [Notebook] = []
+ private(set) static var elsewhere: [Notebook] = []
+ static var all: [Notebook] { inCloud + onDevice + elsewhere }
- /// The kept notebooks, or on first launch a copy of the bundled sample in Documents.
+ /// Whether the list shows On My iPhone, as Files lets a location be hidden.
+ static var showsOnDevice: Bool {
+ get { scripted == nil && !UserDefaults.standard.bool(forKey: hidden) }
+ set { UserDefaults.standard.set(!newValue, forKey: hidden) }
+ }
+
+ /// The kept notebooks and those in Documents, where the bundled sample goes on first launch.
static func load() {
- if let path = ProcessInfo.processInfo.environment["SNOWBOUND_NOTEBOOK"] {
- all = [Notebook(name: URL(fileURLWithPath: path).deletingPathExtension().lastPathComponent, source: .path(path))]
+ if let scripted {
+ elsewhere = [
+ Notebook(
+ name: URL(fileURLWithPath: scripted).deletingPathExtension().lastPathComponent,
+ source: .path(scripted))
+ ]
return
}
if let data = UserDefaults.standard.data(forKey: key),
let entries = try? JSONDecoder().decode([Entry].self, from: data)
{
- all = entries.map { Notebook(id: $0.id, name: $0.name, source: $0.source) }
- return
+ elsewhere = entries.compactMap { entry in
+ switch entry.source {
+ case .documents, .icloud: return nil
+ default: break
+ }
+ return Notebook(id: entry.id, name: entry.name, source: entry.source)
+ }
+ } else {
+ let sample = documentsDirectory.appendingPathComponent("Sample")
+ if !FileManager.default.fileExists(atPath: sample.path),
+ let bundled = Bundle.main.url(forResource: "notebook", withExtension: nil)
+ {
+ try? FileManager.default.copyItem(at: bundled, to: sample)
+ }
+ // The notebook the first version remembered from Files.
+ if let bookmark = UserDefaults.standard.data(forKey: "notebook") {
+ var stale = false
+ let name = (try? URL(resolvingBookmarkData: bookmark, bookmarkDataIsStale: &stale))?
+ .deletingPathExtension().lastPathComponent
+ elsewhere.append(Notebook(name: name ?? "Notebook", source: .files(bookmark: bookmark)))
+ }
+ save()
}
- let sample = documentsDirectory.appendingPathComponent("Sample")
- if !FileManager.default.fileExists(atPath: sample.path),
- let bundled = Bundle.main.url(forResource: "notebook", withExtension: nil)
- {
- try? FileManager.default.copyItem(at: bundled, to: sample)
+ scan()
+ }
+
+ /// Lists Documents again, keeping the notebooks still there; returns those new to the list.
+ @discardableResult
+ static func scan() -> [Notebook] {
+ guard showsOnDevice else {
+ onDevice = []
+ return []
}
- all = [Notebook(name: "Sample", source: .documents(path: "Sample"))]
- // The notebook the first version remembered from Files.
- if let bookmark = UserDefaults.standard.data(forKey: "notebook") {
- var stale = false
- let name = (try? URL(resolvingBookmarkData: bookmark, bookmarkDataIsStale: &stale))?
- .deletingPathExtension().lastPathComponent
- all.append(Notebook(name: name ?? "Notebook", source: .files(bookmark: bookmark)))
+ var added: [Notebook] = []
+ onDevice = notebooks(in: documentsDirectory).map { name in
+ if let kept = onDevice.first(where: { $0.id == "Documents/" + name }) { return kept }
+ let notebook = Notebook(
+ id: "Documents/" + name, name: (name as NSString).deletingPathExtension, source: .documents(path: name))
+ added.append(notebook)
+ return notebook
}
- save()
+ return added
+ }
+
+ /// Lists Snowbound's folder in iCloud Drive again, as `scan` lists Documents; none while
+ /// iCloud Drive is off.
+ @discardableResult
+ static func scanICloud() -> [Notebook] {
+ guard let folder = ICloud.documents, scripted == nil else {
+ inCloud = []
+ return []
+ }
+ var added: [Notebook] = []
+ inCloud = notebooks(in: folder).map { name in
+ if let kept = inCloud.first(where: { $0.id == "iCloud/" + name }) { return kept }
+ let notebook = Notebook(
+ id: "iCloud/" + name, name: (name as NSString).deletingPathExtension, source: .icloud(path: name))
+ added.append(notebook)
+ return notebook
+ }
+ return added
+ }
+
+ /// The notebook folders and section files in `folder`, by name, as Files orders them.
+ private static func notebooks(in folder: URL) -> [String] {
+ let listed = try? FileManager.default.contentsOfDirectory(
+ at: folder, includingPropertiesForKeys: [.isDirectoryKey], options: .skipsHiddenFiles)
+ return (listed ?? []).filter { url in
+ (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
+ || url.pathExtension.lowercased() == "one"
+ }
+ .map(\.lastPathComponent)
+ .sorted { $0.localizedStandardCompare($1) == .orderedAscending }
}
static func add(_ notebook: Notebook) {
- all.append(notebook)
+ elsewhere.append(notebook)
save()
}
static func remove(_ notebook: Notebook) {
- all.removeAll { $0 === notebook }
+ elsewhere.removeAll { $0 === notebook }
save()
}
private static func save() {
- guard ProcessInfo.processInfo.environment["SNOWBOUND_NOTEBOOK"] == nil else { return }
- let entries = all.map { Entry(id: $0.id, name: $0.name, source: $0.source) }
+ guard scripted == nil else { return }
+ let entries = elsewhere.map { Entry(id: $0.id, name: $0.name, source: $0.source) }
UserDefaults.standard.set(try? JSONEncoder().encode(entries), forKey: key)
}
}
diff --git a/apps/ios/Snowbound/Script.swift b/apps/ios/Snowbound/Script.swift
index 0a482a79748dbc4750e11cc6a71d7eaa0aa0eea4..e48f4e36679deea2b141ec39aed7a3daceab0ed8 100644
--- a/apps/ios/Snowbound/Script.swift
+++ b/apps/ios/Snowbound/Script.swift
@@ -38,9 +38,11 @@ extension CanvasView {
let values = argument.split(separator: ",").compactMap { Double($0) }
tap(at: CGPoint(x: values[0] + contentOffset.x, y: values[1] + contentOffset.y))
case "pencil":
+ // Points as `x,y`, or `x,y,pressure` for the Pencil's pressure from 0 to 1.
let points = argument.split(separator: ";").map {
let values = $0.split(separator: ",").compactMap { Double($0) }
- return CGPoint(x: values[0] + contentOffset.x, y: values[1] + contentOffset.y)
+ let point = CGPoint(x: values[0] + contentOffset.x, y: values[1] + contentOffset.y)
+ return (point, values.count > 2 ? Float(values[2]) : -1) as InkSample
}
inkPressed(points[0])
inkMoved(points.dropFirst())
@@ -80,6 +82,12 @@ extension CanvasView {
case "rotate":
let orientation: UIInterfaceOrientationMask = argument == "portrait" ? .portrait : .landscapeRight
window?.windowScene?.requestGeometryUpdate(.iOS(interfaceOrientations: orientation))
+ case "menu":
+ guard let range = selectedTextRange,
+ let menu = interactions.lazy.compactMap({ $0 as? UIEditMenuInteraction }).first(where: { $0.delegate === self })
+ else { break }
+ let caret = caretRect(for: range.start)
+ menu.presentEditMenu(with: UIEditMenuConfiguration(identifier: nil, sourcePoint: CGPoint(x: caret.midX, y: caret.minY)))
case "done": _ = resignFirstResponder()
case "selectall": selectAll(nil)
case "format": apply(UInt8(argument) ?? 0)
diff --git a/apps/ios/Snowbound/Sync.swift b/apps/ios/Snowbound/Sync.swift
index cfa00d2b66d77a6bce55ab999f586967638f25be..6aa4f2e6b783952d0964f578acdd170ae7b5f3ba 100644
--- a/apps/ios/Snowbound/Sync.swift
+++ b/apps/ios/Snowbound/Sync.swift
@@ -3,7 +3,7 @@ import UIKit
/// A section's sync status from `sb_library_sync_status`.
struct SectionSync: Decodable {
enum State: UInt8, Decodable, Comparable {
- case upToDate, syncing, inUse, notConnected, readOnly, failed
+ case upToDate, syncing, inUse, notConnected, readOnly, protected, unreadable, failed
static func < (a: State, b: State) -> Bool { a.rawValue < b.rawValue }
}
@@ -55,6 +55,8 @@ enum Sync {
case .syncing: "Syncing…"
case .inUse: "Section in use"
case .notConnected: "Not connected"
+ case .protected: "Password protected"
+ case .unreadable: "Can’t read this section"
case .readOnly, .failed: "Unable to sync"
}
}
@@ -65,7 +67,8 @@ enum Sync {
case .upToDate: "checkmark.circle"
case .syncing, .inUse: "arrow.triangle.2.circlepath"
case .notConnected: "wifi.slash"
- case .readOnly, .failed: "exclamationmark.triangle"
+ case .protected: "lock"
+ case .readOnly, .unreadable, .failed: "exclamationmark.triangle"
}
}
@@ -77,6 +80,7 @@ enum Sync {
case .readOnly: "You can’t change this notebook where it’s stored. Changes stay on this \(device)."
case .inUse: "Someone else is saving this section. Sync continues when they finish."
case .notConnected: "Changes stay on this \(device) and sync when the notebook is back."
+ case .protected: "Snowbound can’t open password-protected sections yet."
default: nil
}
}
diff --git a/arc/canvas.md b/arc/canvas.md
index d9649cde3a02bdc6d6abf5e91cd1f7d695164361..32c122e2ec6ef4a68d23f648437bec0d57f3eac1 100644
--- a/arc/canvas.md
+++ b/arc/canvas.md
@@ -106,15 +106,40 @@ on the page itself, as OneNote 2010 places it: the same column at the caret's
grid point, outside any outline. It selects, drags on the grid, deletes and
moves with Insert Space as a picture does, and opens and saves as above.
+An outline holding only pictures or files takes a paragraph after them where a
+click beside them lands, as OneNote 2010 adds one when typing there. It is
+stored with the first edit that reaches it, and undoing back to it empty takes it
+out again (`corpus/object-outline`).
+
+A file printout's pages are pictures whose stored data is the printout's XPS
+package. The page draws the PNG OneNote rendered of each
+(WebPictureContainer14), framed in grey as OneNote frames them, and moves them as
+pictures. Snowbound does not write XPS, so a printout page deleted and brought
+back by undo returns as that picture (`corpus/printout`).
+
## Recordings
-Record Audio works as OneNote 2010's does. The caret's paragraph splits around
-a line saying when recording started, in OneNote's grey `cite` style, and the
-caret goes on below it. Text written while recording links to the moment it
-was written. On Stop, the file goes in above that line, named after the page.
-Hovering a linked note or a recording shows OneNote's blue play button in the
-margin, and a click asks the host to play from that moment. The host records a
-16 kHz WAV and stores it as IMA ADPCM, which OneNote plays.
+Record Audio and Record Video work as OneNote 2010's do. The caret's paragraph
+splits around a line saying when recording started, in OneNote's grey `cite`
+style, and the caret goes on below it. Text written while recording links to
+the moment it was written; text written while paused links to nothing, and the
+pause is left out of later moments. On Stop, the file goes in above that line,
+named after the page. Hovering a linked note or a recording shows OneNote's blue
+play button in the margin, and a click asks the host to play from five seconds
+before that moment, OneNote's default rewind. While a recording plays, See
+Playback highlights the note linked last at or before the moment playing,
+scrolling it into view when it changes.
+
+The host records audio as 16 kHz WAV and stores it as IMA ADPCM, and video as
+AVI of Motion JPEG at 320 by 240 and 15 pictures a second with PCM sound, both
+of which OneNote plays (`corpus/recording/video`). GStreamer writes that AVI
+directly; on macOS a capture session records a movie that `AVAssetReader` reads
+back into it off the main thread after Stop, the transport saying so and the page
+editable meanwhile. Mac OS X 10.6, without AVFoundation, opens recordings in the
+system's player and does not record. Snowbound plays both itself: the sound through the platform's
+player, decoded from IMA ADPCM first, and a video's pictures in the transport
+over the page, which also carries OneNote's Pause, Stop, ten-second and
+ten-minute skips, Seek To and See Playback.
## Drawing
@@ -133,6 +158,23 @@ click on ink picks it. The default pen draws in the open section's accent at
the light theme's shade, so a stroke stores one real colour and shows it in both
themes.
+A pen that reports pressure (a tablet on macOS, the Apple Pencil) draws and stores it as
+OneNote 2010 does with pressure sensitivity on: each point's width is the pen's times
+0.25 plus 1.5 times the pressure, and the stroke keeps NormalPressure beside X and Y
+(`corpus/ink-pressure`). A mouse or finger draws at the pen's width.
+
+## Tables and selections across them
+
+Note tags on a table sit in the tag column centred on it, and a click checks its
+box (`corpus/table-tags`). A selection crossing a table's edge deletes as
+OneNote 2010 deletes one: nothing joins across the edge, cells inside are
+emptied, and where the selection runs on past the table its rows inside go, the
+table with them when all do. What replaces the selection goes in at its start
+(`corpus/cross-container`). Tab and Link leave such a selection alone, and
+Shift+Enter a caret inside a link, as OneNote's do; Alt+= across paragraphs
+makes each paragraph's part an equation, and a deletion between two equations
+joins them where neither seam lies inside an object (`corpus/equation-join`).
+
## Content the editor doesn't understand
Nothing is lost for being unfamiliar. A paragraph the canvas can't draw
@@ -158,8 +200,24 @@ editable text area, whose runs carry the canvas's real line boxes and
character positions, so a screen reader's caret and selection land where the
eye does. Native selection and replacement actions go through the editor's
history like any other edit. The tree updates only while an assistive client
-is listening, and never for a caret blink. The interface kit around the page
-doesn't have a tree yet.
+is listening, and never for a caret blink. The interface's own tree holds it at
+the page's box (see [the interface kit](ui.md#accessibility-and-the-keyboard)).
+
+## Spelling
+
+Spelling is checked as OneNote 2010 checks it with its default proofing
+options. Words in capitals, words with digits, and Internet and file addresses
+go unchecked, and a word repeating the one before it is marked as repeated.
+Each run is checked in the language it stores, so a French paragraph takes the
+French dictionary and an equation none. The host supplies the dictionary;
+`canvas::spelling` checks each paragraph on a thread of its own when it first
+comes into view, and keeps the result by the paragraph's text, so drawing never
+waits on it. Marked words carry OneNote's red zigzag, one pixel thick at any
+zoom and laid on the device's pixel grid so a 1× screen shows OneNote's exact
+pixels, except the word still being typed. The context menu (the edit menu on
+iOS) offers the dictionary's corrections, Ignore and Add to Dictionary, and the
+Spelling pane (F7) walks the page's marked words. A correction is an ordinary
+edit, one revision. Nothing about spelling is stored in the page.
## Search, dates, conflicts
diff --git a/arc/file-format.md b/arc/file-format.md
index 93c4b0a6ad1a24a6a439e9cd114793bf46c97ca3..2b85e489fb14427f56db24b84f9fc0dba0be1098 100644
--- a/arc/file-format.md
+++ b/arc/file-format.md
@@ -169,9 +169,12 @@ MS-ONESTORE and MS-ONE are good, but they are not the whole truth:
note to a moment in a recording. Ink and math were reverse-engineered, each
against an independent oracle. For ink, the stroke extents from OneNote's own
export, and for the Draw tab's pens, highlighters and shapes, the objects
- OneNote stored while drawing them in the lab (`corpus/ink-tools`). For math, the MathML it exports, matched byte for byte. Recordings
+ OneNote stored while drawing them in the lab (`corpus/ink-tools`), and for pressure,
+ its PDF export of strokes it took in from ISF (`corpus/ink-pressure`). For math, the MathML it exports, matched byte for byte. Recordings
and their links follow what OneNote stored while recording in the lab, read
- back through its XML export (`corpus/recording`). Embedded objects are read
+ back through its XML export (`corpus/recording`); OneNote lists attached
+ .avi, .mpg and .wmv files as video recordings but not .mp4 or .mov, so video
+ is stored as Motion JPEG AVI, which it plays. Embedded objects are read
and kept, but never authored.
Readers stay tolerant (files in the wild are older, odder, or written by other
diff --git a/arc/platforms.md b/arc/platforms.md
index 60ab0848199c82f66a7c3e2a7ba8e80145b410f2..f16b196bb09af1475dcc24ce19e86da7006bfb52 100644
--- a/arc/platforms.md
+++ b/arc/platforms.md
@@ -22,7 +22,8 @@ page inside it. `snowbound` picks a platform module at compile time
(`macos.rs` or `linux.rs`, both mounted as `platform`), and everything else
in the crate is shared: library and settings, the sidebar, menus, page and
section management, templates, and screenshot and replay support.
-Accessibility for the page goes through AccessKit's winit adapter on both.
+Accessibility goes through AccessKit's winit adapter on both: the interface's
+tree, with the page's grafted into it.
`commands.rs` is the one table of commands: each one's title, its chords on
macOS and elsewhere, when it is enabled or checked, and what it does. The
keyboard, the toolbar and the macOS menu bar all run commands from it.
@@ -41,13 +42,16 @@ keyboard, the toolbar and the macOS menu bar all run commands from it.
sidebar take the system's desktop tint, appearance and focus state exactly.
- AppKit supplies the open and save panels, alerts, the date picker, date
formatting for new page titles and conflict labels, the account's full name
- (used as the author, as OneNote uses Office's user name), and the caret and
- selection colours.
+ (used as the author, as OneNote uses Office's user name), the caret and
+ selection colours, and the spell checker, `NSSpellChecker`, which the
+ spelling thread calls on the main thread (10.6 has it too).
- Frames present inside Core Animation's transaction, so a resize pairs each
frame with the window's new size instead of stretching the last one.
- A notebook on a mounted SMB share is opened through the embedded SMB client,
signed in with the password the keychain keeps for that mount (see
- [sync](sync.md) for why the mount itself isn't enough).
+ [sync](sync.md) for why the mount itself isn't enough). Open Notebook from Server… reaches one
+ by address instead, and keeps a password it asks to remember as the Finder does, an
+ SMB internet password.
- The menu bar (`menubar.rs`) is laid out as OneNote for Mac's. Its items are
the command table's, validated from the statuses each frame publishes, and
their key equivalents are the table's chords, so AppKit takes a chord the
@@ -75,11 +79,20 @@ keyboard, the toolbar and the macOS menu bar all run commands from it.
The desktop is read once from `XDG_CURRENT_DESKTOP`; elsewhere the kit's own.
- zenity or kdialog provide the pickers and alerts. The XDG settings portal
provides the colour scheme. Text conventions come from the C library's
- locale. Fontconfig is loaded at run time, so builds need no headers for it.
+ locale. Fontconfig is loaded at run time, so builds need no headers for it,
+ and so is Enchant, which checks spelling with whatever dictionaries its
+ providers have; without it, words go unmarked.
- Wayland's clipboard goes through the window's own connection, since not every
compositor offers a clipboard to clients without a window.
-- `crates/snowbound/linux` packages a tarball, plus an installer that adds the
- launcher entry the Wayland desktop needs to find the icon.
+- The executable carries its desktop entry and icon (`desktop_linux.rs`). The
+ window is `net.paperclover.snowbound` to Wayland and X11 alike. Where a
+ Wayland compositor lacks xdg-toplevel-icon, as GNOME's does, it finds the
+ icon only through an entry of that name, so a run that isn't installed
+ writes a hidden one pointing at an icon in the runtime folder, marked with
+ its process ID, and removes both on exit or at the next start after a crash.
+ KWin takes the icon from the window. Install on the welcome copies the
+ executable to `~/.local/bin` and writes the visible entry under the same
+ name, so the menu never lists two; Uninstall in Options removes it.
### Windows and older macOS
@@ -114,7 +127,8 @@ owns everything around the page:
No `ui` crate ships on iOS. Everything below the view is the desktop's code:
`notebook::session` with its replica and background publishing, the embedded
SMB client (credentials kept in the Keychain, as Files keeps its own), conflict
-pages, search, and page management through the same ops. Notebooks from Files
+pages, search, spelling (through `UITextChecker`), and page management through
+the same ops. Notebooks from Files
are read and written under `NSFileCoordinator`, so file providers see every
change.
diff --git a/arc/sync.md b/arc/sync.md
index 2af29a078d9b78b3e5099a8606311ff3d5a7ace6..930a6a91e0de20580f30a54ce0b8e2c3e0dbe5de 100644
--- a/arc/sync.md
+++ b/arc/sync.md
@@ -159,6 +159,13 @@ A notebook on a share that macOS has mounted is opened through the embedded
client with the account the system keeps for that mount. It falls back to the
mount only when it can't sign in that way.
+A notebook can also be opened from its server's address, with no mount at all (File ▸
+Open Notebook from Server…), which is how Mac OS X 10.6 reaches a server that no longer
+speaks SMB1, the only version its Finder has. The notebook is kept by that address
+(`smb://[domain;]user@server/share/folder`), never with a password; the password lives in
+the keychain (the Secret Service on Linux) when the user asks to remember it, and
+otherwise the sign-in asks again at the next launch.
+
A file's identity is its root object space, not its server file ID, which
changes when maintenance replaces the file. A check opens by path each time
for the same reason.
@@ -228,6 +235,46 @@ copies and learns of other apps' writes through file coordination (`NSFilePresen
file provider keeps elsewhere, as iCloud Drive does, keeps copies and is checked every 15
seconds. Closing a notebook deletes its copies, except any with edits still waiting.
+## iCloud Drive
+
+A cloud drive syncs whole files with no lock and no compare-and-swap. The stamp check still runs
+before each append, but only against this device's copy; when two devices append to the same
+base, iCloud keeps one as the file and the other beside it as a **conflict version**
+(`NSFileVersion`), on every device. A conflict version is a stamp check that failed after the
+fact, and each sync step merges the versions it finds before anything else:
+
+```text
+for each version V the remote lists (Remote::versions)
+ C := the file, read coordinated
+ per space of V: a := its newest revision C holds, or holds merged
+ ancestor := V opened at those revisions (Section::open_at)
+ ops := lower_page(ancestor[S], V[S]) per page V changed, V's new pages imported under their
+ identities, its deletions, moves and conflict pages
+ replay ops on C through merge.rs (conflict pages where they clash), seal_as the named
+ revisions, publish on C's stamp; then retire V (resolved, removed)
+```
+
+Each revision the merge writes is named after the version's revision it merged (a hash of it),
+so "holds merged" is a lookup by revision identity. A version merged once, by this device or
+another, merges as nothing the next time; two devices merging one version at once write the
+same names, and the merge of their two results finds everything held. Any device merges any
+version at once; nothing waits for the device that wrote it. A version of another section, or
+one that cannot be read, is kept beside the file as `Name (Device).one` instead.
+
+On a cloud drive every read and write of a section file runs under `NSFileCoordinator`, so the
+iCloud daemon never swaps a file between the stamp check and the append. A file iCloud evicted is
+asked for and read later; the replica serves it meanwhile. Each publication is an upload and a
+chance to conflict, so edits wait for a 3 second pause in typing (at most 30 seconds,
+`Section::set_pause`), and publish at once when the app leaves the foreground, the screen locks,
+the Mac's window loses focus or the app quits. A folder presenter reports other devices'
+changes and conflict versions, which change no file, and every section is checked every 15
+seconds besides. Signing out of iCloud closes its notebooks; replicas holding edits stay.
+
+`crates/notebook/tests/cloud.rs` runs devices against a fake iCloud (uploads without
+compare-and-swap, either side of a race winning, late deliveries, offline spans) and checks that
+every device ends on the same file with each typed string once. OneNote 2010 cold-opens a
+merged section with its conflict page as its own (`corpus/icloud-merge`).
+
## Notebook structure
Sections, groups and the notebook's own colour live in the `.onetoc2` files,
@@ -237,6 +284,13 @@ parent TOC's identity and a CRC of the file's name). Without them OneNote
treats the file as a stranger and re-identifies it. Deleting sends sections and
pages to `OneNote_RecycleBin`, as OneNote does.
+What only Snowbound reads lives in the notebook's `.snowbound` folder, which carries the
+Windows hidden attribute so that OneNote never makes a section group of it. Its files are
+plain files, not revision stores: pictures named by their content, and small JSON
+mappings that every writer merges before replacing. The first is tag art: a tag keeps
+OneNote's definition and a fallback symbol on the page, and `tags.json` maps its name and
+symbol to a picture that Snowbound draws in the symbol's place.
+
## Recovery and migration
`export_recovery` snapshots a replica into a single read-only archive: base and
diff --git a/arc/ui.md b/arc/ui.md
index 8c9749f5a91599285930b57b5f6b42f8a3fa1215..0003b469f76087a3996f7a1d86046b92de3a68d8 100644
--- a/arc/ui.md
+++ b/arc/ui.md
@@ -25,8 +25,9 @@ frame-rate independent, a retargeted animation continues smoothly from where
it is, and it settles fast. Popups open and close on short timed curves,
slow enough to follow: a menu swings out of the pointer or its button as it
fades in, a combo's field widens into its list, and a dialog swings up into
-place over a dimmed window, as Windows opens a window; the command palette swings
-in the same way near the window's top. On GNOME and KDE, menus instead
+place near the window's top over a dimmed window, as Windows opens a window; the
+command palette swings in the same way, undimmed. A filtered list shows its new
+results at once. On GNOME and KDE, menus instead
look and move as the desktop's own (see [platforms](platforms.md)).
## Why not native widgets
@@ -85,7 +86,7 @@ frame N
are built each frame and the solver picks, so a group folds in the frame the
window narrows, with no widths remembered or worked out by the app. A folded
group is a dropdown of the same command-table rows, and a gallery it lists
- opens under it, since a folded-away box takes its group's rectangle. The
+ opens beside its row as a submenu, on hover, Right or a click. The
section tabs scroll sideways past the row's room, fading out where cut.
- **Nothing runs while nothing changes.** The kit reports whether it wants
another frame (queued input, an animation still settling) and when a timed
@@ -106,6 +107,51 @@ has the OneNote-specific controls: section tabs and compact toolbar buttons.
The kit doesn't know what a notebook is. `snowbound` assembles the window from
these parts.
+## Accessibility and the keyboard
+
+The interface presents itself to assistive technology as an AccessKit tree
+built from the same boxes as the frame. A box with a `Spec::role` is a node;
+the kit's widgets choose their own, and `Ui::access` adds states, values and
+names to any box. A box without a role lets its children through, and its
+text reads as a label.
+
+- **Names come from what a sighted user reads.** A control without one is
+ named by its text and its children's, or by its tooltip: the title names
+ it, the chord becomes its shortcut, and the description its description.
+ An icon button's command-table title is its name, and a split button's arrow
+ is that name with "Options", so no two toolbar controls share one.
+- **Popups are menus and dialogs.** A menu's highlighted row is the focus
+ within it, so arrows read as they move; a palette is a dialog whose field
+ keeps the focus while its list's selection moves. Opening a popup moves the
+ focus into it, and closing it gives the focus back. A command menu opens at
+ its top with nothing highlighted; a value picker (fonts, sizes, a combo's
+ list) opens on its current value.
+- **Actions are input.** A press or a new value from assistive technology
+ arrives as an event and is answered next frame, as a click would be.
+- **Only changes are sent**, and only while something listens.
+
+The page keeps its own tree (see [canvas](canvas.md#accessibility)). The
+host grafts it at the page's box, sending the interface's tree first, since
+it holds the graft, then the page's.
+
+```text
+Window
+├─ Toolbar buttons named by their tooltips, combos with their values
+├─ TabList section tabs
+├─ Group "Page" the page's own tree, grafted
+├─ TabList page tabs
+└─ Menu | Dialog open popups
+```
+
+The keyboard reaches the same controls. Tab steps through them, within an
+open popup or across the window; a toolbar, tab list or tree is one step,
+entered at its selected control, and arrows move within it. F6 steps between
+those groups and the page, as Windows and GTK step between panes, and on
+macOS Control-F5 goes to the toolbar. Space or Enter presses, Escape closes a
+popup or returns the focus to where the keyboard took it from, and the
+focused control wears a ring until the pointer is used. The page keeps Tab
+for itself, so F6 leaves it.
+
## `draw`: the renderer
`draw` is a small wgpu renderer. A frame is a list of layers, each with its
@@ -142,6 +188,7 @@ repaint, cache eviction and a new renderer must all produce identical pixels.
A covered window gets no redraws, so interaction is scripted.
`SNOWBOUND_REPLAY` feeds the app a file of pointer, key, wait and snapshot
-steps. `--screenshot` draws the whole window offscreen in each appearance
+steps, and `accessibility` steps that write the whole window's tree as text, so a
+hidden window's tree can be checked without a screen reader. `--screenshot` draws the whole window offscreen in each appearance
(the README's screenshots are made this way). `ui`'s own tests build frames
headlessly and assert on layout, routing and signals.
diff --git a/corpus/cross-container/README.md b/corpus/cross-container/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..855e37f653acbf01ddae973d57a410e1a8458f6e
--- /dev/null
+++ b/corpus/cross-container/README.md
@@ -0,0 +1,20 @@
+# Deleting across a table's edge
+
+`native/` is OneNote 2010 authoring `tools/native/pages.ps1` input: four pages in
+`Cross.one`, each "Before text", a two-by-two table and "After text".
+
+`keys/` is `tools/native_cross_container.py` pressing the same keys in OneNote 2010 on
+that notebook: a selection from "Before" into the first cell deleted, one from the
+second cell out to "After" deleted and "X" typed, one from "Before" over the first row
+into "Beta one" typed over with "Y", and one from "Before" over the whole table deleted.
+OneNote joins nothing across the table's edge, empties cells inside the selection, and
+where the selection runs on past the table removes its rows inside, and the table when
+all of them are.
+
+`candidate/` is the output of `deleting_across_a_tables_edge_keeps_each_side` in
+`crates/canvas/tests/cross_container.rs`, the same edits through the editor, each
+published as its own revision; `cold/` is a fresh OneNote 2010 read of it, whose pages
+read as `keys/` does. `tools/test_cross_container.py` checks this without a VM.
+Regenerate with `SNOWBOUND_CROSS_CONTAINER_EXPORT` set to a new absolute directory while
+running the test, then cold-open it with
+`tools/native_runner.py OUTPUT COLD --expected-pages 4 --collect-notebook --screenshots`.
diff --git a/corpus/cross-container/candidate/Cross.one b/corpus/cross-container/candidate/Cross.one
new file mode 100644
index 0000000000000000000000000000000000000000..0a08c2c77bbeffb5deda86f156e796bd66197f4d
Binary files /dev/null and b/corpus/cross-container/candidate/Cross.one differ
diff --git a/corpus/cross-container/candidate/Open Notebook.onetoc2 b/corpus/cross-container/candidate/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..65283aaf76a0f783f269c7ca5523837d5bc4521c
Binary files /dev/null and b/corpus/cross-container/candidate/Open Notebook.onetoc2 differ
diff --git a/corpus/cross-container/cold/commands.jsonl b/corpus/cross-container/cold/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..458a6c58e6be3b7984f242c326decb22d0d0f58e
--- /dev/null
+++ b/corpus/cross-container/cold/commands.jsonl
@@ -0,0 +1,3 @@
+{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-CE2F338C -ExpectedPages 4 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 4 pages.\r\n", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
diff --git a/corpus/cross-container/cold/machine.json b/corpus/cross-container/cold/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..c2cb152b1c1c46d2f07504b3b7e53c97ebd85fac
--- /dev/null
+++ b/corpus/cross-container/cold/machine.json
@@ -0,0 +1 @@
+{"name": "m6-ce2f338c", "hostname": "ONE-M6-CE2F338C"}
diff --git a/corpus/cross-container/cold/notebook/Cross.one b/corpus/cross-container/cold/notebook/Cross.one
new file mode 100644
index 0000000000000000000000000000000000000000..b2a370707f31115b9c571c4fbf249dc06ba8f7f2
Binary files /dev/null and b/corpus/cross-container/cold/notebook/Cross.one differ
diff --git a/corpus/cross-container/cold/notebook/Open Notebook.onetoc2 b/corpus/cross-container/cold/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..ef5ab08cf26a74cfaa67557174fa169a44eddf61
Binary files /dev/null and b/corpus/cross-container/cold/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/cross-container/cold/read/environment.json b/corpus/cross-container/cold/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..ed7670ea8665885ec1ef8487b2e1979cbc1e9df4
--- /dev/null
+++ b/corpus/cross-container/cold/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-CE2F338C",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/cross-container/cold/read/hierarchy.xml b/corpus/cross-container/cold/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..ce6f50600cc71313d8c82a903654a62ce2d2656f
--- /dev/null
+++ b/corpus/cross-container/cold/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/cross-container/cold/read/page-000.navigation.json b/corpus/cross-container/cold/read/page-000.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..eb1f9c3957987bccacb1d9028cbeeb66f5ba7c8c
--- /dev/null
+++ b/corpus/cross-container/cold/read/page-000.navigation.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Delete a whole table - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/cross-container/cold/read/page-000.png b/corpus/cross-container/cold/read/page-000.png
new file mode 100644
index 0000000000000000000000000000000000000000..8c9c5581d9a9fbef9d62d2fb0ecbe0069eeec0fb
Binary files /dev/null and b/corpus/cross-container/cold/read/page-000.png differ
diff --git a/corpus/cross-container/cold/read/page-000.xml b/corpus/cross-container/cold/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..12cca3a770268dbab5cf836affac50dbe4a1d099
--- /dev/null
+++ b/corpus/cross-container/cold/read/page-000.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/cross-container/cold/read/page-001.navigation.json b/corpus/cross-container/cold/read/page-001.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..cd1c5e98266d1d9e59b5eac98957dac7a2a7b8dc
--- /dev/null
+++ b/corpus/cross-container/cold/read/page-001.navigation.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Type over a row - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/cross-container/cold/read/page-001.png b/corpus/cross-container/cold/read/page-001.png
new file mode 100644
index 0000000000000000000000000000000000000000..f1200e24688f87bdd6f8ab0661dd241101e720ba
Binary files /dev/null and b/corpus/cross-container/cold/read/page-001.png differ
diff --git a/corpus/cross-container/cold/read/page-001.xml b/corpus/cross-container/cold/read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..c357be3f300f68d16b99a814b055abb397ecc727
--- /dev/null
+++ b/corpus/cross-container/cold/read/page-001.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/cross-container/cold/read/page-002.navigation.json b/corpus/cross-container/cold/read/page-002.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..c290d163970aa32a6e9af2b27353a0d97f37e9e0
--- /dev/null
+++ b/corpus/cross-container/cold/read/page-002.navigation.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Delete into a table - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/cross-container/cold/read/page-002.png b/corpus/cross-container/cold/read/page-002.png
new file mode 100644
index 0000000000000000000000000000000000000000..b1677f1a1609064a96bc636cc86e8495e10696f6
Binary files /dev/null and b/corpus/cross-container/cold/read/page-002.png differ
diff --git a/corpus/cross-container/cold/read/page-002.xml b/corpus/cross-container/cold/read/page-002.xml
new file mode 100644
index 0000000000000000000000000000000000000000..6e715d96a728021587b0341a19d18350e4f4245e
--- /dev/null
+++ b/corpus/cross-container/cold/read/page-002.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/cross-container/cold/read/page-003.navigation.json b/corpus/cross-container/cold/read/page-003.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..e3f5eaba4943cb1eb03f5e8198e76b8b08215a6f
--- /dev/null
+++ b/corpus/cross-container/cold/read/page-003.navigation.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Delete out of a table - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/cross-container/cold/read/page-003.png b/corpus/cross-container/cold/read/page-003.png
new file mode 100644
index 0000000000000000000000000000000000000000..934c2ff1ed177176d836044773cb21e807fb8dfe
Binary files /dev/null and b/corpus/cross-container/cold/read/page-003.png differ
diff --git a/corpus/cross-container/cold/read/page-003.xml b/corpus/cross-container/cold/read/page-003.xml
new file mode 100644
index 0000000000000000000000000000000000000000..d6fcc3e6952a198edd56a3469536f43cfd21dbac
--- /dev/null
+++ b/corpus/cross-container/cold/read/page-003.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/cross-container/cold/read/payloads.json b/corpus/cross-container/cold/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843
--- /dev/null
+++ b/corpus/cross-container/cold/read/payloads.json
@@ -0,0 +1,3 @@
+[
+
+]
\ No newline at end of file
diff --git a/corpus/cross-container/cold/run.json b/corpus/cross-container/cold/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..c8e882026e2e92f351855b3d8db58c074d1336a0
--- /dev/null
+++ b/corpus/cross-container/cold/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/private/tmp/unsup-lab/cross-candidate",
+ "expected_pages": 4,
+ "author": null,
+ "author_timeout_seconds": 600,
+ "inspect": false,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
+ }
+}
diff --git a/corpus/cross-container/cold/scripts/cold.ps1 b/corpus/cross-container/cold/scripts/cold.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f
--- /dev/null
+++ b/corpus/cross-container/cold/scripts/cold.ps1
@@ -0,0 +1,27 @@
+param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
+$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
+if ($CloneHost) {
+ if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
+ throw 'The disposable clone hostname does not match this machine.'
+ }
+ New-Item "$key\Options\Paths" -Force | Out-Null
+ New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
+} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
+ -not (Test-Path 'C:\one-tests\profile-original-cache')) {
+ throw 'Park the personal OneNote profile before resetting the test cache.'
+}
+$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
+$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
+if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
+New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
+if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
+if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
+New-Item "$key\OpenNotebooks" | Out-Null
+New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
diff --git a/corpus/cross-container/cold/scripts/read.ps1 b/corpus/cross-container/cold/scripts/read.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a42366b32e8baaf13dae92e9472cae7a071220cc
--- /dev/null
+++ b/corpus/cross-container/cold/scripts/read.ps1
@@ -0,0 +1,144 @@
+param(
+ [Parameter(Mandatory=$true)][string]$Root,
+ [int]$ExpectedPages = -1,
+ [switch]$UseCurrentCache,
+ [switch]$Pdf,
+ [switch]$KeepOpen,
+ [string]$CloneHost = ''
+)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+$notebook = Join-Path $root 'notebook'
+$output = Join-Path $root 'read'
+if (Test-Path $output) { throw 'Choose a new read destination.' }
+if ($UseCurrentCache) {
+ if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
+ throw 'Park the personal OneNote profile before reading test notebooks.'
+ }
+} else {
+ & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
+}
+New-Item -ItemType Directory -Path $output | Out-Null
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+$failure = $null
+try {
+ $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
+ $process = Get-Process ONENOTE
+ @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
+ powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
+ ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
+ $sections = @()
+ foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
+ $id = ''
+ $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
+ $sections += $id
+ }
+ $deadline = [DateTime]::UtcNow.AddSeconds(300)
+ $previous = ''
+ $lastChange = ''
+ $stableSince = [DateTime]::UtcNow
+ $settled = $false
+ do {
+ $pages = @{}
+ foreach ($section in $sections) {
+ $hierarchy = ''
+ $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
+ [xml]$xml = $hierarchy
+ foreach ($node in $xml.SelectNodes('//*[@path]')) {
+ if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'OneNote opened a section outside the copied notebook.'
+ }
+ }
+ foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
+ $id = $node.GetAttribute('ID')
+ $content = ''
+ $app.GetPageContent($id, [ref]$content, 1, 1)
+ $pages[$id] = $content
+ }
+ }
+ $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
+ if ($signature -ne $previous) {
+ $lastChange = $previous
+ $previous = $signature
+ $stableSince = [DateTime]::UtcNow
+ }
+ if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
+ ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
+ ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
+ Start-Sleep -Milliseconds 250
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
+ [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
+ $index = 0
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ $index++
+ }
+ $hierarchy = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
+ [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
+ throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
+ }
+ $index = 0
+ $payloads = @()
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ if ($Pdf) {
+ $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
+ $app.NavigateTo($id, '', $false)
+ $app.Publish($id, $pdfPath, 3, '')
+ if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
+ }
+ [xml]$page = $pages[$id]
+ foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
+ $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
+ $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
+ [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
+ # A paragraph's file is named by its paragraph, one on the page by itself.
+ $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
+ $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
+ kind = $file.LocalName; name = $file.GetAttribute('preferredName');
+ sha256 = $hash; bytes = $bytes.Length }
+ }
+ $index++
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
+ $all = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
+ [xml]$finalTree = $all
+ $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
+ if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
+ throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
+ Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
+} catch {
+ $failure = $_
+ [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ throw
+} finally {
+ try {
+ try {
+ if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
+ if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
+ $app.CloseNotebook($notebookId, $false)
+ }
+ } catch {
+ if ($null -eq $failure) { throw }
+ [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ }
+ } finally {
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ }
+ $app = $null
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+ if (-not $UseCurrentCache -and -not $CloneHost) {
+ Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
+ }
+}
diff --git a/corpus/cross-container/cold/source.json b/corpus/cross-container/cold/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..614bd2f45ebe0258917a1cd43d257ca81648409b
--- /dev/null
+++ b/corpus/cross-container/cold/source.json
@@ -0,0 +1,14 @@
+[
+ {
+ "path": "Cross.one",
+ "bytes": 36880,
+ "sha256": "c799433c13b9d28d6b302e70c50a5cfaad91ed1ad6324ee288ac4394a4f4b749",
+ "mtime_ns": 1790741247924484176
+ },
+ {
+ "path": "Open Notebook.onetoc2",
+ "bytes": 1604,
+ "sha256": "335f7cd71e022c2f7447d6369ff1cad37aaba9294b3942850f8e1ca739cb9dd7",
+ "mtime_ns": 1790741247924820969
+ }
+]
diff --git a/corpus/cross-container/cold/teardown.json b/corpus/cross-container/cold/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/cross-container/cold/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/corpus/cross-container/keys/before-read/environment.json b/corpus/cross-container/keys/before-read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..cd3d47ff72d297c4ca81b2af23d8fe284d8488f9
--- /dev/null
+++ b/corpus/cross-container/keys/before-read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-407884AD",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/cross-container/keys/before-read/hierarchy.xml b/corpus/cross-container/keys/before-read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..c5a064dbae9213a6e15351f2befbf320720e7cb0
--- /dev/null
+++ b/corpus/cross-container/keys/before-read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/cross-container/keys/before-read/page-000.xml b/corpus/cross-container/keys/before-read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..390ce5d979769a6830add2d171a119707560901b
--- /dev/null
+++ b/corpus/cross-container/keys/before-read/page-000.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/cross-container/keys/before-read/page-001.xml b/corpus/cross-container/keys/before-read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..9532172dccd5829e59dae269741a2630097ab759
--- /dev/null
+++ b/corpus/cross-container/keys/before-read/page-001.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/cross-container/keys/before-read/page-002.xml b/corpus/cross-container/keys/before-read/page-002.xml
new file mode 100644
index 0000000000000000000000000000000000000000..847da2a48bc87e50c875a40fa22dc37ef5fb0656
--- /dev/null
+++ b/corpus/cross-container/keys/before-read/page-002.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/cross-container/keys/before-read/page-003.xml b/corpus/cross-container/keys/before-read/page-003.xml
new file mode 100644
index 0000000000000000000000000000000000000000..42f4aa1371be5f24fb43af2e5f8588e5dd72e57b
--- /dev/null
+++ b/corpus/cross-container/keys/before-read/page-003.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/cross-container/keys/before-read/page-004.xml b/corpus/cross-container/keys/before-read/page-004.xml
new file mode 100644
index 0000000000000000000000000000000000000000..817e8c0a7a85e4ce82c267b7a48d295b3e79f9b2
--- /dev/null
+++ b/corpus/cross-container/keys/before-read/page-004.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/cross-container/keys/before-read/payloads.json b/corpus/cross-container/keys/before-read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843
--- /dev/null
+++ b/corpus/cross-container/keys/before-read/payloads.json
@@ -0,0 +1,3 @@
+[
+
+]
\ No newline at end of file
diff --git a/corpus/cross-container/keys/commands.jsonl b/corpus/cross-container/keys/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..59bf83bd407f6f6ca633ddd8f2a20a409c349a11
--- /dev/null
+++ b/corpus/cross-container/keys/commands.jsonl
@@ -0,0 +1,6 @@
+{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-407884AD -ExpectedPages -1 -KeepOpen", "exit": 0, "stdout": "Read 2 sections and 5 pages.\r\n", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -Command \"Rename-Item C:\\one-tests\\runs\\capture\\read before-read\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-407884AD -UseCurrentCache -ExpectedPages -1", "exit": 0, "stdout": "Read 2 sections and 5 pages.\r\n", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
diff --git a/corpus/cross-container/keys/delete-a-whole-table.ahk b/corpus/cross-container/keys/delete-a-whole-table.ahk
new file mode 100644
index 0000000000000000000000000000000000000000..7ccdb11d0020c9cfbc576f669f0dbd4038a3a7ce
--- /dev/null
+++ b/corpus/cross-container/keys/delete-a-whole-table.ahk
@@ -0,0 +1,15 @@
+ComObject("OneNote.Application").NavigateTo("{244ECF19-31A6-09AC-0E75-6B09C5A98D32}{1}{B0}", "", false)
+WinWait("ahk_exe ONENOTE.EXE", , 30)
+WinActivate("ahk_exe ONENOTE.EXE")
+WinWaitActive("ahk_exe ONENOTE.EXE", , 30)
+WinMaximize("ahk_exe ONENOTE.EXE")
+Sleep(3000)
+CoordMode("Mouse", "Screen")
+Click(160, 236)
+Sleep(500)
+Send("{Home}{Right 6}")
+Sleep(300)
+Send("+{Right 49}")
+Sleep(500)
+Send("{Delete}")
+Sleep(2000)
diff --git a/corpus/cross-container/keys/delete-a-whole-table.json b/corpus/cross-container/keys/delete-a-whole-table.json
new file mode 100644
index 0000000000000000000000000000000000000000..eb1f9c3957987bccacb1d9028cbeeb66f5ba7c8c
--- /dev/null
+++ b/corpus/cross-container/keys/delete-a-whole-table.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Delete a whole table - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/cross-container/keys/delete-a-whole-table.png b/corpus/cross-container/keys/delete-a-whole-table.png
new file mode 100644
index 0000000000000000000000000000000000000000..29f5b5b2b15de3e82386a4fa89069731dd0851e5
Binary files /dev/null and b/corpus/cross-container/keys/delete-a-whole-table.png differ
diff --git a/corpus/cross-container/keys/delete-into-a-table.ahk b/corpus/cross-container/keys/delete-into-a-table.ahk
new file mode 100644
index 0000000000000000000000000000000000000000..8bf951fc03b970bf8309993566318fffe66dc2ec
--- /dev/null
+++ b/corpus/cross-container/keys/delete-into-a-table.ahk
@@ -0,0 +1,15 @@
+ComObject("OneNote.Application").NavigateTo("{559E950C-23E1-0225-2FE4-0B4CE99F3B33}{1}{B0}", "", false)
+WinWait("ahk_exe ONENOTE.EXE", , 30)
+WinActivate("ahk_exe ONENOTE.EXE")
+WinWaitActive("ahk_exe ONENOTE.EXE", , 30)
+WinMaximize("ahk_exe ONENOTE.EXE")
+Sleep(3000)
+CoordMode("Mouse", "Screen")
+Click(160, 236)
+Sleep(500)
+Send("{Home}{Right 6}")
+Sleep(300)
+Send("+{Right 11}")
+Sleep(500)
+Send("{Delete}")
+Sleep(2000)
diff --git a/corpus/cross-container/keys/delete-into-a-table.json b/corpus/cross-container/keys/delete-into-a-table.json
new file mode 100644
index 0000000000000000000000000000000000000000..c290d163970aa32a6e9af2b27353a0d97f37e9e0
--- /dev/null
+++ b/corpus/cross-container/keys/delete-into-a-table.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Delete into a table - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/cross-container/keys/delete-into-a-table.png b/corpus/cross-container/keys/delete-into-a-table.png
new file mode 100644
index 0000000000000000000000000000000000000000..2f5771b10a366e8a06d0428db19657b140f7349a
Binary files /dev/null and b/corpus/cross-container/keys/delete-into-a-table.png differ
diff --git a/corpus/cross-container/keys/delete-out-of-a-table.ahk b/corpus/cross-container/keys/delete-out-of-a-table.ahk
new file mode 100644
index 0000000000000000000000000000000000000000..887f90db611d36f6efdd8d2c1eaf5e93bc48132b
--- /dev/null
+++ b/corpus/cross-container/keys/delete-out-of-a-table.ahk
@@ -0,0 +1,17 @@
+ComObject("OneNote.Application").NavigateTo("{ACACC717-6A3B-0805-30CA-3CFD22E4E788}{1}{B0}", "", false)
+WinWait("ahk_exe ONENOTE.EXE", , 30)
+WinActivate("ahk_exe ONENOTE.EXE")
+WinWaitActive("ahk_exe ONENOTE.EXE", , 30)
+WinMaximize("ahk_exe ONENOTE.EXE")
+Sleep(3000)
+CoordMode("Mouse", "Screen")
+Click(250, 260)
+Sleep(500)
+Send("{Home}{Right 5}")
+Sleep(300)
+Send("+{Right 28}")
+Sleep(500)
+Send("{Delete}")
+Sleep(500)
+SendText("X")
+Sleep(2000)
diff --git a/corpus/cross-container/keys/delete-out-of-a-table.json b/corpus/cross-container/keys/delete-out-of-a-table.json
new file mode 100644
index 0000000000000000000000000000000000000000..e3f5eaba4943cb1eb03f5e8198e76b8b08215a6f
--- /dev/null
+++ b/corpus/cross-container/keys/delete-out-of-a-table.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Delete out of a table - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/cross-container/keys/delete-out-of-a-table.png b/corpus/cross-container/keys/delete-out-of-a-table.png
new file mode 100644
index 0000000000000000000000000000000000000000..fa9b150d8d9bcd4e20b20a967f044736eb1b0890
Binary files /dev/null and b/corpus/cross-container/keys/delete-out-of-a-table.png differ
diff --git a/corpus/cross-container/keys/machine.json b/corpus/cross-container/keys/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..93000cb2885f392e6f9d78e13001bbf2322c9875
--- /dev/null
+++ b/corpus/cross-container/keys/machine.json
@@ -0,0 +1 @@
+{"name": "m6-407884ad", "hostname": "ONE-M6-407884AD"}
diff --git a/corpus/cross-container/keys/notebook/Cross.one b/corpus/cross-container/keys/notebook/Cross.one
new file mode 100644
index 0000000000000000000000000000000000000000..791d0cf408951e3c5b3df0b34253cdd377f40719
Binary files /dev/null and b/corpus/cross-container/keys/notebook/Cross.one differ
diff --git a/corpus/cross-container/keys/notebook/Open Notebook.onetoc2 b/corpus/cross-container/keys/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..ca240337153bb01012080f129258a6f736046f19
Binary files /dev/null and b/corpus/cross-container/keys/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/cross-container/keys/notebook/fixture.json b/corpus/cross-container/keys/notebook/fixture.json
new file mode 100644
index 0000000000000000000000000000000000000000..03dc309779f1612431bba1c1c210294778a3b743
--- /dev/null
+++ b/corpus/cross-container/keys/notebook/fixture.json
@@ -0,0 +1,23 @@
+{
+ "sections": [
+ "Cross.one"
+ ],
+ "pages": [
+ {
+ "section": "Cross.one",
+ "xml": ""
+ },
+ {
+ "section": "Cross.one",
+ "xml": ""
+ },
+ {
+ "section": "Cross.one",
+ "xml": ""
+ },
+ {
+ "section": "Cross.one",
+ "xml": ""
+ }
+ ]
+}
\ No newline at end of file
diff --git a/corpus/cross-container/keys/notebook/synthetic.one b/corpus/cross-container/keys/notebook/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..0828a11acf7ea1c65579afe0c3111335fc0b7c7a
Binary files /dev/null and b/corpus/cross-container/keys/notebook/synthetic.one differ
diff --git a/corpus/cross-container/keys/read/environment.json b/corpus/cross-container/keys/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..b436dbda4826141b17f62aaecf542a99daebd996
--- /dev/null
+++ b/corpus/cross-container/keys/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-407884AD",
+ "cold": false,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/cross-container/keys/read/hierarchy.xml b/corpus/cross-container/keys/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..3daacbc2f3bba6852632c69ca3ccc36085a59451
--- /dev/null
+++ b/corpus/cross-container/keys/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/cross-container/keys/read/page-000.xml b/corpus/cross-container/keys/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..c87d5c84d8f043cc03034d81d2944dbc61bb1596
--- /dev/null
+++ b/corpus/cross-container/keys/read/page-000.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/cross-container/keys/read/page-001.xml b/corpus/cross-container/keys/read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..69883ecd85374c7feb8773a7ed1fc55b48159601
--- /dev/null
+++ b/corpus/cross-container/keys/read/page-001.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/cross-container/keys/read/page-002.xml b/corpus/cross-container/keys/read/page-002.xml
new file mode 100644
index 0000000000000000000000000000000000000000..c0c238639db56bce97a3398ac0e0e0376795d579
--- /dev/null
+++ b/corpus/cross-container/keys/read/page-002.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/cross-container/keys/read/page-003.xml b/corpus/cross-container/keys/read/page-003.xml
new file mode 100644
index 0000000000000000000000000000000000000000..c69c3ac7f7a77f656dd52321950ed9bed7428e59
--- /dev/null
+++ b/corpus/cross-container/keys/read/page-003.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/cross-container/keys/read/page-004.xml b/corpus/cross-container/keys/read/page-004.xml
new file mode 100644
index 0000000000000000000000000000000000000000..659839ce1be09ff5badfc4b1e99fbbe9fa1ff5a8
--- /dev/null
+++ b/corpus/cross-container/keys/read/page-004.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/cross-container/keys/read/payloads.json b/corpus/cross-container/keys/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843
--- /dev/null
+++ b/corpus/cross-container/keys/read/payloads.json
@@ -0,0 +1,3 @@
+[
+
+]
\ No newline at end of file
diff --git a/corpus/cross-container/keys/run.json b/corpus/cross-container/keys/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..42fcc169ff4172c4c2448d70f84a4ebdac416e17
--- /dev/null
+++ b/corpus/cross-container/keys/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/Users/clo/dev/one/corpus/cross-container/native/notebook",
+ "expected_pages": -1,
+ "author": null,
+ "author_timeout_seconds": 600,
+ "inspect": false,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
+ }
+}
diff --git a/corpus/cross-container/keys/scripts/cold.ps1 b/corpus/cross-container/keys/scripts/cold.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f
--- /dev/null
+++ b/corpus/cross-container/keys/scripts/cold.ps1
@@ -0,0 +1,27 @@
+param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
+$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
+if ($CloneHost) {
+ if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
+ throw 'The disposable clone hostname does not match this machine.'
+ }
+ New-Item "$key\Options\Paths" -Force | Out-Null
+ New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
+} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
+ -not (Test-Path 'C:\one-tests\profile-original-cache')) {
+ throw 'Park the personal OneNote profile before resetting the test cache.'
+}
+$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
+$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
+if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
+New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
+if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
+if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
+New-Item "$key\OpenNotebooks" | Out-Null
+New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
diff --git a/corpus/cross-container/keys/scripts/read.ps1 b/corpus/cross-container/keys/scripts/read.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a42366b32e8baaf13dae92e9472cae7a071220cc
--- /dev/null
+++ b/corpus/cross-container/keys/scripts/read.ps1
@@ -0,0 +1,144 @@
+param(
+ [Parameter(Mandatory=$true)][string]$Root,
+ [int]$ExpectedPages = -1,
+ [switch]$UseCurrentCache,
+ [switch]$Pdf,
+ [switch]$KeepOpen,
+ [string]$CloneHost = ''
+)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+$notebook = Join-Path $root 'notebook'
+$output = Join-Path $root 'read'
+if (Test-Path $output) { throw 'Choose a new read destination.' }
+if ($UseCurrentCache) {
+ if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
+ throw 'Park the personal OneNote profile before reading test notebooks.'
+ }
+} else {
+ & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
+}
+New-Item -ItemType Directory -Path $output | Out-Null
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+$failure = $null
+try {
+ $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
+ $process = Get-Process ONENOTE
+ @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
+ powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
+ ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
+ $sections = @()
+ foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
+ $id = ''
+ $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
+ $sections += $id
+ }
+ $deadline = [DateTime]::UtcNow.AddSeconds(300)
+ $previous = ''
+ $lastChange = ''
+ $stableSince = [DateTime]::UtcNow
+ $settled = $false
+ do {
+ $pages = @{}
+ foreach ($section in $sections) {
+ $hierarchy = ''
+ $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
+ [xml]$xml = $hierarchy
+ foreach ($node in $xml.SelectNodes('//*[@path]')) {
+ if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'OneNote opened a section outside the copied notebook.'
+ }
+ }
+ foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
+ $id = $node.GetAttribute('ID')
+ $content = ''
+ $app.GetPageContent($id, [ref]$content, 1, 1)
+ $pages[$id] = $content
+ }
+ }
+ $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
+ if ($signature -ne $previous) {
+ $lastChange = $previous
+ $previous = $signature
+ $stableSince = [DateTime]::UtcNow
+ }
+ if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
+ ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
+ ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
+ Start-Sleep -Milliseconds 250
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
+ [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
+ $index = 0
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ $index++
+ }
+ $hierarchy = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
+ [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
+ throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
+ }
+ $index = 0
+ $payloads = @()
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ if ($Pdf) {
+ $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
+ $app.NavigateTo($id, '', $false)
+ $app.Publish($id, $pdfPath, 3, '')
+ if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
+ }
+ [xml]$page = $pages[$id]
+ foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
+ $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
+ $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
+ [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
+ # A paragraph's file is named by its paragraph, one on the page by itself.
+ $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
+ $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
+ kind = $file.LocalName; name = $file.GetAttribute('preferredName');
+ sha256 = $hash; bytes = $bytes.Length }
+ }
+ $index++
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
+ $all = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
+ [xml]$finalTree = $all
+ $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
+ if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
+ throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
+ Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
+} catch {
+ $failure = $_
+ [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ throw
+} finally {
+ try {
+ try {
+ if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
+ if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
+ $app.CloseNotebook($notebookId, $false)
+ }
+ } catch {
+ if ($null -eq $failure) { throw }
+ [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ }
+ } finally {
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ }
+ $app = $null
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+ if (-not $UseCurrentCache -and -not $CloneHost) {
+ Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
+ }
+}
diff --git a/corpus/cross-container/keys/source.json b/corpus/cross-container/keys/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..dab624e1e554442483b8ae6acf56e6ccbc6ed101
--- /dev/null
+++ b/corpus/cross-container/keys/source.json
@@ -0,0 +1,26 @@
+[
+ {
+ "path": "Cross.one",
+ "bytes": 29944,
+ "sha256": "4610b5a0fe24c60532f34b804f7c8f4e8117d408683bfc5a34bec3ef8e011dc9",
+ "mtime_ns": 1790740956459211068
+ },
+ {
+ "path": "Open Notebook.onetoc2",
+ "bytes": 3900,
+ "sha256": "a47b71ad4f6ab62f9b36a503047304ef787ba51344a52f4ca6b0f9de69912c4a",
+ "mtime_ns": 1790740956459627153
+ },
+ {
+ "path": "fixture.json",
+ "bytes": 4461,
+ "sha256": "f5b2f03775b73d8359d05549134e795cb66f8930990922495003add1a786821c",
+ "mtime_ns": 1790740956454514170
+ },
+ {
+ "path": "synthetic.one",
+ "bytes": 3348,
+ "sha256": "2500c5736a2d146b6355e15247705cf3605c465f95d28eb2d74477f4d7b57806",
+ "mtime_ns": 1790740956459996238
+ }
+]
diff --git a/corpus/cross-container/keys/teardown.json b/corpus/cross-container/keys/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/cross-container/keys/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/corpus/cross-container/keys/type-over-a-row.ahk b/corpus/cross-container/keys/type-over-a-row.ahk
new file mode 100644
index 0000000000000000000000000000000000000000..2d22692ef17af48665957d2d4be4d743df061e6d
--- /dev/null
+++ b/corpus/cross-container/keys/type-over-a-row.ahk
@@ -0,0 +1,15 @@
+ComObject("OneNote.Application").NavigateTo("{4377D8AC-9263-01FA-0E6A-D7E0B436542E}{1}{B0}", "", false)
+WinWait("ahk_exe ONENOTE.EXE", , 30)
+WinActivate("ahk_exe ONENOTE.EXE")
+WinWaitActive("ahk_exe ONENOTE.EXE", , 30)
+WinMaximize("ahk_exe ONENOTE.EXE")
+Sleep(3000)
+CoordMode("Mouse", "Screen")
+Click(160, 236)
+Sleep(500)
+Send("{Home}{Right 6}")
+Sleep(300)
+Send("+{Right 30}")
+Sleep(500)
+SendText("Y")
+Sleep(2000)
diff --git a/corpus/cross-container/keys/type-over-a-row.json b/corpus/cross-container/keys/type-over-a-row.json
new file mode 100644
index 0000000000000000000000000000000000000000..cd1c5e98266d1d9e59b5eac98957dac7a2a7b8dc
--- /dev/null
+++ b/corpus/cross-container/keys/type-over-a-row.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Type over a row - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/cross-container/keys/type-over-a-row.png b/corpus/cross-container/keys/type-over-a-row.png
new file mode 100644
index 0000000000000000000000000000000000000000..f393cff21695c2f8d0ff773c1863728901540c18
Binary files /dev/null and b/corpus/cross-container/keys/type-over-a-row.png differ
diff --git a/corpus/cross-container/native/commands.jsonl b/corpus/cross-container/native/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..71d5dbdb2ccf807fb096786d630a2dcd1b7913e4
--- /dev/null
+++ b/corpus/cross-container/native/commands.jsonl
@@ -0,0 +1,4 @@
+{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\author.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-13E867D1", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-13E867D1 -ExpectedPages 5 -UseCurrentCache", "exit": 0, "stdout": "Read 2 sections and 5 pages.\r\n", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
diff --git a/corpus/cross-container/native/machine.json b/corpus/cross-container/native/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..81e7e878ebe052306797a156c874d6655d84a85f
--- /dev/null
+++ b/corpus/cross-container/native/machine.json
@@ -0,0 +1 @@
+{"name": "m6-13e867d1", "hostname": "ONE-M6-13E867D1"}
diff --git a/corpus/cross-container/native/notebook/Cross.one b/corpus/cross-container/native/notebook/Cross.one
new file mode 100644
index 0000000000000000000000000000000000000000..e1ad05043ac89949ef40d592743012837bc33dda
Binary files /dev/null and b/corpus/cross-container/native/notebook/Cross.one differ
diff --git a/corpus/cross-container/native/notebook/Open Notebook.onetoc2 b/corpus/cross-container/native/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..ca240337153bb01012080f129258a6f736046f19
Binary files /dev/null and b/corpus/cross-container/native/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/cross-container/native/notebook/fixture.json b/corpus/cross-container/native/notebook/fixture.json
new file mode 100644
index 0000000000000000000000000000000000000000..03dc309779f1612431bba1c1c210294778a3b743
--- /dev/null
+++ b/corpus/cross-container/native/notebook/fixture.json
@@ -0,0 +1,23 @@
+{
+ "sections": [
+ "Cross.one"
+ ],
+ "pages": [
+ {
+ "section": "Cross.one",
+ "xml": ""
+ },
+ {
+ "section": "Cross.one",
+ "xml": ""
+ },
+ {
+ "section": "Cross.one",
+ "xml": ""
+ },
+ {
+ "section": "Cross.one",
+ "xml": ""
+ }
+ ]
+}
\ No newline at end of file
diff --git a/corpus/cross-container/native/notebook/synthetic.one b/corpus/cross-container/native/notebook/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..c88a78fc3ceea793ec2aeea30370f50b305e0753
Binary files /dev/null and b/corpus/cross-container/native/notebook/synthetic.one differ
diff --git a/corpus/cross-container/native/read/environment.json b/corpus/cross-container/native/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..7fbc9c1443dc7a926187c67ef95f50136f06cef9
--- /dev/null
+++ b/corpus/cross-container/native/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-13E867D1",
+ "cold": false,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/cross-container/native/read/hierarchy.xml b/corpus/cross-container/native/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..2107ebfd6681e9d7bac918e2ad1a2768011fe99e
--- /dev/null
+++ b/corpus/cross-container/native/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/cross-container/native/read/page-000.xml b/corpus/cross-container/native/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..5c6802b779e70fdb5ecf2c50b70870b2469f66e2
--- /dev/null
+++ b/corpus/cross-container/native/read/page-000.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/cross-container/native/read/page-001.xml b/corpus/cross-container/native/read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..e690342ffb60c036d172ddcabd34e26c95ec8234
--- /dev/null
+++ b/corpus/cross-container/native/read/page-001.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/cross-container/native/read/page-002.xml b/corpus/cross-container/native/read/page-002.xml
new file mode 100644
index 0000000000000000000000000000000000000000..d3fc32b5ec84d211f5d0e5a56800bde72ab75fa9
--- /dev/null
+++ b/corpus/cross-container/native/read/page-002.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/cross-container/native/read/page-003.xml b/corpus/cross-container/native/read/page-003.xml
new file mode 100644
index 0000000000000000000000000000000000000000..4078b0c61129dc553d476470d31021a9e826d688
--- /dev/null
+++ b/corpus/cross-container/native/read/page-003.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/cross-container/native/read/page-004.xml b/corpus/cross-container/native/read/page-004.xml
new file mode 100644
index 0000000000000000000000000000000000000000..776ac8c089d651fccee7bb66f737654110c910c8
--- /dev/null
+++ b/corpus/cross-container/native/read/page-004.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/cross-container/native/read/payloads.json b/corpus/cross-container/native/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843
--- /dev/null
+++ b/corpus/cross-container/native/read/payloads.json
@@ -0,0 +1,3 @@
+[
+
+]
\ No newline at end of file
diff --git a/corpus/cross-container/native/run.json b/corpus/cross-container/native/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..2f0a24205c01498bfafb8bd2cedda1a2f1d33eef
--- /dev/null
+++ b/corpus/cross-container/native/run.json
@@ -0,0 +1,19 @@
+{
+ "notebook": "/private/tmp/unsup-lab/cross-input/notebook",
+ "expected_pages": 5,
+ "author": "tools/native/pages.ps1",
+ "author_timeout_seconds": 600,
+ "inspect": false,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "author.ps1": "dfbf0e016aa4c8486ecea9f39768c633de05ac0db8214139052ca2e9c43bbd02",
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
+ }
+}
diff --git a/corpus/cross-container/native/scripts/author.ps1 b/corpus/cross-container/native/scripts/author.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..8e428618e3452caa35da88687293b520c273b6c6
--- /dev/null
+++ b/corpus/cross-container/native/scripts/author.ps1
@@ -0,0 +1,42 @@
+param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost
+$fixture = Get-Content (Join-Path $Root 'notebook\fixture.json') -Raw -Encoding UTF8 | ConvertFrom-Json
+$inputs = Join-Path $Root 'inputs'
+New-Item -ItemType Directory -Path $inputs | Out-Null
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+try {
+ $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0)
+ $sections = @{}
+ foreach ($path in $fixture.sections) {
+ $parts = $path.Split('/')
+ if (@($parts | Where-Object { $_ -in @('', '.', '..') -or $_.Contains('\') -or $_.Contains(':') }).Count) { throw 'Invalid fixture section path.' }
+ $parent = $notebookId
+ for ($index = 0; $index -lt $parts.Count; $index++) {
+ $id = ''
+ $app.OpenHierarchy($parts[$index], $parent, [ref]$id, $(if ($index -eq $parts.Count - 1) {3} else {2}))
+ $parent = $id
+ }
+ $sections[$path] = $id
+ }
+ $index = 0
+ foreach ($item in $fixture.pages) {
+ $pageId = ''
+ $app.CreateNewPage($sections[$item.section], [ref]$pageId, 0)
+ [xml]$xml = $item.xml.Replace('__ASSETS__', [Security.SecurityElement]::Escape((Join-Path $Root 'notebook\fixture-data')))
+ $xml.DocumentElement.SetAttribute('ID', $pageId)
+ [IO.File]::WriteAllText((Join-Path $inputs ('page-{0:d3}.xml' -f $index)), $xml.OuterXml, [Text.Encoding]::UTF8)
+ $app.UpdatePageContent($xml.OuterXml, [DateTime]::MinValue, 1, $true)
+ $index++
+ }
+ $app.SyncHierarchy($notebookId)
+ $app.CloseNotebook($notebookId, $false)
+ $notebookId = ''
+} finally {
+ if ($notebookId) { $app.CloseNotebook($notebookId, $false) }
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+}
diff --git a/corpus/cross-container/native/scripts/cold.ps1 b/corpus/cross-container/native/scripts/cold.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f
--- /dev/null
+++ b/corpus/cross-container/native/scripts/cold.ps1
@@ -0,0 +1,27 @@
+param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
+$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
+if ($CloneHost) {
+ if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
+ throw 'The disposable clone hostname does not match this machine.'
+ }
+ New-Item "$key\Options\Paths" -Force | Out-Null
+ New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
+} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
+ -not (Test-Path 'C:\one-tests\profile-original-cache')) {
+ throw 'Park the personal OneNote profile before resetting the test cache.'
+}
+$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
+$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
+if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
+New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
+if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
+if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
+New-Item "$key\OpenNotebooks" | Out-Null
+New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
diff --git a/corpus/cross-container/native/scripts/read.ps1 b/corpus/cross-container/native/scripts/read.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a42366b32e8baaf13dae92e9472cae7a071220cc
--- /dev/null
+++ b/corpus/cross-container/native/scripts/read.ps1
@@ -0,0 +1,144 @@
+param(
+ [Parameter(Mandatory=$true)][string]$Root,
+ [int]$ExpectedPages = -1,
+ [switch]$UseCurrentCache,
+ [switch]$Pdf,
+ [switch]$KeepOpen,
+ [string]$CloneHost = ''
+)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+$notebook = Join-Path $root 'notebook'
+$output = Join-Path $root 'read'
+if (Test-Path $output) { throw 'Choose a new read destination.' }
+if ($UseCurrentCache) {
+ if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
+ throw 'Park the personal OneNote profile before reading test notebooks.'
+ }
+} else {
+ & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
+}
+New-Item -ItemType Directory -Path $output | Out-Null
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+$failure = $null
+try {
+ $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
+ $process = Get-Process ONENOTE
+ @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
+ powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
+ ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
+ $sections = @()
+ foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
+ $id = ''
+ $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
+ $sections += $id
+ }
+ $deadline = [DateTime]::UtcNow.AddSeconds(300)
+ $previous = ''
+ $lastChange = ''
+ $stableSince = [DateTime]::UtcNow
+ $settled = $false
+ do {
+ $pages = @{}
+ foreach ($section in $sections) {
+ $hierarchy = ''
+ $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
+ [xml]$xml = $hierarchy
+ foreach ($node in $xml.SelectNodes('//*[@path]')) {
+ if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'OneNote opened a section outside the copied notebook.'
+ }
+ }
+ foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
+ $id = $node.GetAttribute('ID')
+ $content = ''
+ $app.GetPageContent($id, [ref]$content, 1, 1)
+ $pages[$id] = $content
+ }
+ }
+ $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
+ if ($signature -ne $previous) {
+ $lastChange = $previous
+ $previous = $signature
+ $stableSince = [DateTime]::UtcNow
+ }
+ if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
+ ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
+ ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
+ Start-Sleep -Milliseconds 250
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
+ [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
+ $index = 0
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ $index++
+ }
+ $hierarchy = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
+ [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
+ throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
+ }
+ $index = 0
+ $payloads = @()
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ if ($Pdf) {
+ $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
+ $app.NavigateTo($id, '', $false)
+ $app.Publish($id, $pdfPath, 3, '')
+ if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
+ }
+ [xml]$page = $pages[$id]
+ foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
+ $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
+ $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
+ [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
+ # A paragraph's file is named by its paragraph, one on the page by itself.
+ $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
+ $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
+ kind = $file.LocalName; name = $file.GetAttribute('preferredName');
+ sha256 = $hash; bytes = $bytes.Length }
+ }
+ $index++
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
+ $all = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
+ [xml]$finalTree = $all
+ $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
+ if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
+ throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
+ Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
+} catch {
+ $failure = $_
+ [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ throw
+} finally {
+ try {
+ try {
+ if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
+ if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
+ $app.CloseNotebook($notebookId, $false)
+ }
+ } catch {
+ if ($null -eq $failure) { throw }
+ [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ }
+ } finally {
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ }
+ $app = $null
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+ if (-not $UseCurrentCache -and -not $CloneHost) {
+ Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
+ }
+}
diff --git a/corpus/cross-container/native/source.json b/corpus/cross-container/native/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..e3bf5d0fcff1a9c80feecedcbadf682adabdf8d1
--- /dev/null
+++ b/corpus/cross-container/native/source.json
@@ -0,0 +1,20 @@
+[
+ {
+ "path": "Open Notebook.onetoc2",
+ "bytes": 1612,
+ "sha256": "fafa5dcb3cd827c7edd30c8ea2fbc957a59fb4d2a8b28b667a3c551c8261ab86",
+ "mtime_ns": 1790740542526906926
+ },
+ {
+ "path": "fixture.json",
+ "bytes": 4461,
+ "sha256": "f5b2f03775b73d8359d05549134e795cb66f8930990922495003add1a786821c",
+ "mtime_ns": 1790740700488051772
+ },
+ {
+ "path": "synthetic.one",
+ "bytes": 3348,
+ "sha256": "b0662387c3fdc256d8dbe809e3b8f73b3586559696467fb1fff8f589095fa797",
+ "mtime_ns": 1790740542527105843
+ }
+]
diff --git a/corpus/cross-container/native/teardown.json b/corpus/cross-container/native/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/cross-container/native/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/corpus/custom-art/README.md b/corpus/custom-art/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..005ff859a0e06cd178683bcd778e68b965bcd0f8
--- /dev/null
+++ b/corpus/custom-art/README.md
@@ -0,0 +1,19 @@
+# Tags with Snowbound's art
+
+`candidate/` is the notebook `tags_with_art_keep_it_beside_the_page` in
+`crates/snowbound/src/tags.rs` writes: one page whose paragraphs carry "Launch" (a PNG
+chosen for it, OneNote's Yellow Star, 13), "Ship it" (Snowbound's Rocket, OneNote's Plane,
+127), "Done" (the Rocket over a Blue Check Box, 3, checked) and OneNote's own "Important".
+The page stores OneNote definitions only; `.snowbound/tags.json` maps each tag's name and
+symbol to its picture in `.snowbound/tags/`, named by SHA-256.
+
+`cold/` is `tools/native_custom_art.py` in a fresh OneNote 2010 clone: its author script
+gives `.snowbound` the hidden attribute Snowbound sets (the transfer archive drops it),
+`before-read/` is the cold read, which shows each fallback symbol under the tag's name and
+no `.snowbound` section group (`before-read/page-000.png`). OneNote then edits the page
+through COM (`scripts/custom-art-edit.ps1`: new text on "Launch day", "Done" unchecked)
+and syncs; `read/` is the page after, `edited.png` its render, and `sidecar.json` what
+OneNote left of the folder: still hidden, every file as written, no table of contents.
+`tools/test_custom_art.py` and `art_outlasts_onenote_editing_the_page` check this without
+a VM. Regenerate with `SNOWBOUND_CUSTOM_ART_EXPORT` set to a new absolute directory while
+running the test, then `tools/native_custom_art.py OUTPUT COLD`.
diff --git a/corpus/custom-art/candidate/.snowbound/tags.json b/corpus/custom-art/candidate/.snowbound/tags.json
new file mode 100644
index 0000000000000000000000000000000000000000..7d668cee6609727e0b84371b93ef9de7b65857ff
--- /dev/null
+++ b/corpus/custom-art/candidate/.snowbound/tags.json
@@ -0,0 +1,20 @@
+[
+ {
+ "name": "Launch",
+ "shape": 13,
+ "art": "d5cf52aa0db673980ffdd0481ecaa2cae00216c8ee9c288510cf68dc3d803e92.png",
+ "mapped": 134352249571937330
+ },
+ {
+ "name": "Ship it",
+ "shape": 127,
+ "art": "6dd10fabf6733354f911effb092fda9bfbbc9c1f3739576a18937093288767a9.svg",
+ "mapped": 134352249571944730
+ },
+ {
+ "name": "Done",
+ "shape": 3,
+ "art": "6dd10fabf6733354f911effb092fda9bfbbc9c1f3739576a18937093288767a9.svg",
+ "mapped": 134352249571951950
+ }
+]
\ No newline at end of file
diff --git a/corpus/custom-art/candidate/.snowbound/tags/6dd10fabf6733354f911effb092fda9bfbbc9c1f3739576a18937093288767a9.svg b/corpus/custom-art/candidate/.snowbound/tags/6dd10fabf6733354f911effb092fda9bfbbc9c1f3739576a18937093288767a9.svg
new file mode 100644
index 0000000000000000000000000000000000000000..cae23e60726a3a53e36ce9b2c05dfb4bd0e1cc5c
--- /dev/null
+++ b/corpus/custom-art/candidate/.snowbound/tags/6dd10fabf6733354f911effb092fda9bfbbc9c1f3739576a18937093288767a9.svg
@@ -0,0 +1,13 @@
+
diff --git a/corpus/custom-art/candidate/.snowbound/tags/d5cf52aa0db673980ffdd0481ecaa2cae00216c8ee9c288510cf68dc3d803e92.png b/corpus/custom-art/candidate/.snowbound/tags/d5cf52aa0db673980ffdd0481ecaa2cae00216c8ee9c288510cf68dc3d803e92.png
new file mode 100644
index 0000000000000000000000000000000000000000..42ce05fee7e020e67a47c9ac44e22d82be1d4494
Binary files /dev/null and b/corpus/custom-art/candidate/.snowbound/tags/d5cf52aa0db673980ffdd0481ecaa2cae00216c8ee9c288510cf68dc3d803e92.png differ
diff --git a/corpus/custom-art/candidate/Custom art.one b/corpus/custom-art/candidate/Custom art.one
new file mode 100644
index 0000000000000000000000000000000000000000..76726f5f4d21c2eeaa0779d75bd196b333d0c239
Binary files /dev/null and b/corpus/custom-art/candidate/Custom art.one differ
diff --git a/corpus/custom-art/candidate/Open Notebook.onetoc2 b/corpus/custom-art/candidate/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..740faf943872391d89c0df030062802b2130838c
Binary files /dev/null and b/corpus/custom-art/candidate/Open Notebook.onetoc2 differ
diff --git a/corpus/custom-art/cold/before-read/environment.json b/corpus/custom-art/cold/before-read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..2b6077fb5a3f84eb1955a660c1b4cc5fef5130ee
--- /dev/null
+++ b/corpus/custom-art/cold/before-read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-FFFFC1C2",
+ "cold": false,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/custom-art/cold/before-read/hierarchy.xml b/corpus/custom-art/cold/before-read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..84d200ac2f78fe70776431b28976730d9ccb0625
--- /dev/null
+++ b/corpus/custom-art/cold/before-read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/custom-art/cold/before-read/page-000.navigation.json b/corpus/custom-art/cold/before-read/page-000.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..f30211c1d306bac53ce0fe70f6bb5a8255cd724f
--- /dev/null
+++ b/corpus/custom-art/cold/before-read/page-000.navigation.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Launch day - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/custom-art/cold/before-read/page-000.png b/corpus/custom-art/cold/before-read/page-000.png
new file mode 100644
index 0000000000000000000000000000000000000000..bd9b99f2c3cfff66aaa475bd50474cd1616bab7b
Binary files /dev/null and b/corpus/custom-art/cold/before-read/page-000.png differ
diff --git a/corpus/custom-art/cold/before-read/page-000.xml b/corpus/custom-art/cold/before-read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..13cdaad67e5b1c85f9820daf064f601e410d37ac
--- /dev/null
+++ b/corpus/custom-art/cold/before-read/page-000.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/custom-art/cold/before-read/payloads.json b/corpus/custom-art/cold/before-read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843
--- /dev/null
+++ b/corpus/custom-art/cold/before-read/payloads.json
@@ -0,0 +1,3 @@
+[
+
+]
\ No newline at end of file
diff --git a/corpus/custom-art/cold/commands.jsonl b/corpus/custom-art/cold/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..4c65bbc88da14d63ecf2a1de94788f84319b0e47
--- /dev/null
+++ b/corpus/custom-art/cold/commands.jsonl
@@ -0,0 +1,8 @@
+{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\author.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-FFFFC1C2", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-FFFFC1C2 -ExpectedPages 1 -UseCurrentCache -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\custom-art-edit.ps1 -Root C:\\one-tests\\runs\\capture", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -Command \"Rename-Item C:\\one-tests\\runs\\capture\\read before-read\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-FFFFC1C2 -UseCurrentCache -ExpectedPages -1", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
diff --git a/corpus/custom-art/cold/edited.png b/corpus/custom-art/cold/edited.png
new file mode 100644
index 0000000000000000000000000000000000000000..973ac7b9d1c29f52b3a9320068f565c9e96481e9
Binary files /dev/null and b/corpus/custom-art/cold/edited.png differ
diff --git a/corpus/custom-art/cold/machine.json b/corpus/custom-art/cold/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..83bb1ffa1eae37ab6988c447ec249924e184f9f7
--- /dev/null
+++ b/corpus/custom-art/cold/machine.json
@@ -0,0 +1 @@
+{"name": "m6-ffffc1c2", "hostname": "ONE-M6-FFFFC1C2"}
diff --git a/corpus/custom-art/cold/notebook/Custom art.one b/corpus/custom-art/cold/notebook/Custom art.one
new file mode 100644
index 0000000000000000000000000000000000000000..a397b0b93c285f1b467293e0f10b048e991fa1c7
Binary files /dev/null and b/corpus/custom-art/cold/notebook/Custom art.one differ
diff --git a/corpus/custom-art/cold/notebook/Open Notebook.onetoc2 b/corpus/custom-art/cold/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..6b1f45400d76ffd8b2389e3fb315d6a827e14276
Binary files /dev/null and b/corpus/custom-art/cold/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/custom-art/cold/read/environment.json b/corpus/custom-art/cold/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..2b6077fb5a3f84eb1955a660c1b4cc5fef5130ee
--- /dev/null
+++ b/corpus/custom-art/cold/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-FFFFC1C2",
+ "cold": false,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/custom-art/cold/read/hierarchy.xml b/corpus/custom-art/cold/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..6bc468fc197c482e647fe5e05ce6546b89f43acd
--- /dev/null
+++ b/corpus/custom-art/cold/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/custom-art/cold/read/page-000.xml b/corpus/custom-art/cold/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..9d8d5b09a1761a18fa5c063eaa8b2462c6cd7cb5
--- /dev/null
+++ b/corpus/custom-art/cold/read/page-000.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/custom-art/cold/read/payloads.json b/corpus/custom-art/cold/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843
--- /dev/null
+++ b/corpus/custom-art/cold/read/payloads.json
@@ -0,0 +1,3 @@
+[
+
+]
\ No newline at end of file
diff --git a/corpus/custom-art/cold/run.json b/corpus/custom-art/cold/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..fbc7a11ede7cfc87589ba7d89202ef737fa5163f
--- /dev/null
+++ b/corpus/custom-art/cold/run.json
@@ -0,0 +1,19 @@
+{
+ "notebook": "/private/tmp/snowbound-custom-art/candidate",
+ "expected_pages": 1,
+ "author": "/Users/clo/dev/one/tools/native/custom-art.ps1",
+ "author_timeout_seconds": 600,
+ "inspect": false,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "author.ps1": "fee6de8f8db4c294d9b39f5cf6428fa6031b52b9158470a3f0aeaf82ef64bfa8",
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
+ }
+}
diff --git a/corpus/custom-art/cold/scripts/author.ps1 b/corpus/custom-art/cold/scripts/author.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..efba77f99a1621c369f3cc3637b8845fd4f225ea
--- /dev/null
+++ b/corpus/custom-art/cold/scripts/author.ps1
@@ -0,0 +1,7 @@
+param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost
+# Snowbound makes its folder hidden, which the transfer's archive does not keep.
+$sidecar = Get-Item -LiteralPath (Join-Path $Root 'notebook\.snowbound') -Force
+$sidecar.Attributes = $sidecar.Attributes -bor [IO.FileAttributes]::Hidden
diff --git a/corpus/custom-art/cold/scripts/cold.ps1 b/corpus/custom-art/cold/scripts/cold.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f
--- /dev/null
+++ b/corpus/custom-art/cold/scripts/cold.ps1
@@ -0,0 +1,27 @@
+param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
+$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
+if ($CloneHost) {
+ if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
+ throw 'The disposable clone hostname does not match this machine.'
+ }
+ New-Item "$key\Options\Paths" -Force | Out-Null
+ New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
+} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
+ -not (Test-Path 'C:\one-tests\profile-original-cache')) {
+ throw 'Park the personal OneNote profile before resetting the test cache.'
+}
+$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
+$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
+if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
+New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
+if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
+if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
+New-Item "$key\OpenNotebooks" | Out-Null
+New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
diff --git a/corpus/custom-art/cold/scripts/custom-art-edit.ps1 b/corpus/custom-art/cold/scripts/custom-art-edit.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..e51bb0b91045ebde096e18f56aec15392d6b5538
--- /dev/null
+++ b/corpus/custom-art/cold/scripts/custom-art-edit.ps1
@@ -0,0 +1,45 @@
+param([Parameter(Mandatory=$true)][string]$Root)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$notebook = Join-Path $Root 'notebook'
+$app = New-Object -ComObject OneNote.Application
+try {
+ $hierarchy = ''
+ $app.GetHierarchy('', 4, [ref]$hierarchy, 1)
+ [xml]$tree = $hierarchy
+ $page = $tree.SelectSingleNode('//*[local-name()="Notebook"][@path="' + $notebook + '"]//*[local-name()="Page"]')
+ if (-not $page) { throw 'The notebook has no page open.' }
+ $content = ''
+ $app.GetPageContent($page.GetAttribute('ID'), [ref]$content, 0, 1)
+ [IO.File]::WriteAllText((Join-Path $Root 'before.xml'), $content, [Text.Encoding]::UTF8)
+ [xml]$xml = $content
+ # OneNote edits the tagged paragraphs: new text on one, the check box of another cleared.
+ foreach ($oe in $xml.SelectNodes('//*[local-name()="OE"]')) {
+ $text = $oe.SelectSingleNode('*[local-name()="T"]')
+ if (-not $text) { continue }
+ if ($text.InnerText -eq 'Launch day') { $text.InnerXml = '' }
+ if ($text.InnerText -eq 'Checked off') {
+ foreach ($tag in $oe.SelectNodes('*[local-name()="Tag"]')) { $tag.SetAttribute('completed', 'false') }
+ }
+ }
+ [IO.File]::WriteAllText((Join-Path $Root 'update.xml'), $xml.OuterXml, [Text.Encoding]::UTF8)
+ $app.UpdatePageContent($xml.OuterXml, [DateTime]::MinValue, 1, $false)
+ $id = $tree.SelectSingleNode('//*[local-name()="Notebook"][@path="' + $notebook + '"]').GetAttribute('ID')
+ $app.SyncHierarchy($id)
+ Start-Sleep -Seconds 5
+} finally {
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+}
+# What OneNote left of Snowbound's folder: its attribute, and each file with its hash.
+$folder = Get-Item -LiteralPath (Join-Path $notebook '.snowbound') -Force
+$files = @(Get-ChildItem -LiteralPath $folder.FullName -Recurse -Force | ForEach-Object {
+ $hash = $null
+ if (-not $_.PSIsContainer) { $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName).Hash.ToLower() }
+ @{ path = $_.FullName.Substring($folder.FullName.Length + 1).Replace('\', '/');
+ attributes = $_.Attributes.ToString(); sha256 = $hash }
+})
+@{ attributes = $folder.Attributes.ToString(); files = $files;
+ notebook = @(Get-ChildItem -LiteralPath $notebook -Force | ForEach-Object { $_.Name }) } |
+ ConvertTo-Json -Depth 4 | Set-Content (Join-Path $Root 'sidecar.json') -Encoding UTF8
diff --git a/corpus/custom-art/cold/scripts/read.ps1 b/corpus/custom-art/cold/scripts/read.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a42366b32e8baaf13dae92e9472cae7a071220cc
--- /dev/null
+++ b/corpus/custom-art/cold/scripts/read.ps1
@@ -0,0 +1,144 @@
+param(
+ [Parameter(Mandatory=$true)][string]$Root,
+ [int]$ExpectedPages = -1,
+ [switch]$UseCurrentCache,
+ [switch]$Pdf,
+ [switch]$KeepOpen,
+ [string]$CloneHost = ''
+)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+$notebook = Join-Path $root 'notebook'
+$output = Join-Path $root 'read'
+if (Test-Path $output) { throw 'Choose a new read destination.' }
+if ($UseCurrentCache) {
+ if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
+ throw 'Park the personal OneNote profile before reading test notebooks.'
+ }
+} else {
+ & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
+}
+New-Item -ItemType Directory -Path $output | Out-Null
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+$failure = $null
+try {
+ $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
+ $process = Get-Process ONENOTE
+ @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
+ powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
+ ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
+ $sections = @()
+ foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
+ $id = ''
+ $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
+ $sections += $id
+ }
+ $deadline = [DateTime]::UtcNow.AddSeconds(300)
+ $previous = ''
+ $lastChange = ''
+ $stableSince = [DateTime]::UtcNow
+ $settled = $false
+ do {
+ $pages = @{}
+ foreach ($section in $sections) {
+ $hierarchy = ''
+ $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
+ [xml]$xml = $hierarchy
+ foreach ($node in $xml.SelectNodes('//*[@path]')) {
+ if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'OneNote opened a section outside the copied notebook.'
+ }
+ }
+ foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
+ $id = $node.GetAttribute('ID')
+ $content = ''
+ $app.GetPageContent($id, [ref]$content, 1, 1)
+ $pages[$id] = $content
+ }
+ }
+ $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
+ if ($signature -ne $previous) {
+ $lastChange = $previous
+ $previous = $signature
+ $stableSince = [DateTime]::UtcNow
+ }
+ if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
+ ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
+ ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
+ Start-Sleep -Milliseconds 250
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
+ [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
+ $index = 0
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ $index++
+ }
+ $hierarchy = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
+ [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
+ throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
+ }
+ $index = 0
+ $payloads = @()
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ if ($Pdf) {
+ $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
+ $app.NavigateTo($id, '', $false)
+ $app.Publish($id, $pdfPath, 3, '')
+ if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
+ }
+ [xml]$page = $pages[$id]
+ foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
+ $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
+ $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
+ [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
+ # A paragraph's file is named by its paragraph, one on the page by itself.
+ $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
+ $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
+ kind = $file.LocalName; name = $file.GetAttribute('preferredName');
+ sha256 = $hash; bytes = $bytes.Length }
+ }
+ $index++
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
+ $all = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
+ [xml]$finalTree = $all
+ $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
+ if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
+ throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
+ Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
+} catch {
+ $failure = $_
+ [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ throw
+} finally {
+ try {
+ try {
+ if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
+ if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
+ $app.CloseNotebook($notebookId, $false)
+ }
+ } catch {
+ if ($null -eq $failure) { throw }
+ [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ }
+ } finally {
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ }
+ $app = $null
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+ if (-not $UseCurrentCache -and -not $CloneHost) {
+ Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
+ }
+}
diff --git a/corpus/custom-art/cold/sidecar.json b/corpus/custom-art/cold/sidecar.json
new file mode 100644
index 0000000000000000000000000000000000000000..6687b065ead02c70cdfc81776b28244bc094f032
--- /dev/null
+++ b/corpus/custom-art/cold/sidecar.json
@@ -0,0 +1,30 @@
+{
+ "attributes": "Hidden, Directory",
+ "files": [
+ {
+ "path": "tags",
+ "attributes": "Directory",
+ "sha256": null
+ },
+ {
+ "path": "tags.json",
+ "attributes": "Archive",
+ "sha256": "297cb1a778848dca82e207fedb0d57ca60baa19add18e5e613eeb9252020b45f"
+ },
+ {
+ "path": "tags/6dd10fabf6733354f911effb092fda9bfbbc9c1f3739576a18937093288767a9.svg",
+ "attributes": "Archive",
+ "sha256": "6dd10fabf6733354f911effb092fda9bfbbc9c1f3739576a18937093288767a9"
+ },
+ {
+ "path": "tags/d5cf52aa0db673980ffdd0481ecaa2cae00216c8ee9c288510cf68dc3d803e92.png",
+ "attributes": "Archive",
+ "sha256": "d5cf52aa0db673980ffdd0481ecaa2cae00216c8ee9c288510cf68dc3d803e92"
+ }
+ ],
+ "notebook": [
+ ".snowbound",
+ "Custom art.one",
+ "Open Notebook.onetoc2"
+ ]
+}
diff --git a/corpus/custom-art/cold/source.json b/corpus/custom-art/cold/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..b9fca3f819eaf0679ff8c9bc99c2ffc299f39c07
--- /dev/null
+++ b/corpus/custom-art/cold/source.json
@@ -0,0 +1,32 @@
+[
+ {
+ "path": ".snowbound/tags/6dd10fabf6733354f911effb092fda9bfbbc9c1f3739576a18937093288767a9.svg",
+ "bytes": 1207,
+ "sha256": "6dd10fabf6733354f911effb092fda9bfbbc9c1f3739576a18937093288767a9",
+ "mtime_ns": 1790751357201384901
+ },
+ {
+ "path": ".snowbound/tags/d5cf52aa0db673980ffdd0481ecaa2cae00216c8ee9c288510cf68dc3d803e92.png",
+ "bytes": 377,
+ "sha256": "d5cf52aa0db673980ffdd0481ecaa2cae00216c8ee9c288510cf68dc3d803e92",
+ "mtime_ns": 1790751357201807401
+ },
+ {
+ "path": ".snowbound/tags.json",
+ "bytes": 493,
+ "sha256": "297cb1a778848dca82e207fedb0d57ca60baa19add18e5e613eeb9252020b45f",
+ "mtime_ns": 1790751357202100985
+ },
+ {
+ "path": "Custom art.one",
+ "bytes": 8024,
+ "sha256": "db7099a6b0663436d14e11cbff66b2b1555cdd90d29191f41bcbc606f55783d3",
+ "mtime_ns": 1790751357200828150
+ },
+ {
+ "path": "Open Notebook.onetoc2",
+ "bytes": 1612,
+ "sha256": "7ab2e99e22102099e12bec081dc1e99ada669fb7b00a596fd766ce4030060109",
+ "mtime_ns": 1790751357200618358
+ }
+]
diff --git a/corpus/custom-art/cold/teardown.json b/corpus/custom-art/cold/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/custom-art/cold/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/corpus/equation-join/README.md b/corpus/equation-join/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..04504ff197eb45265c7748e3dfa2e7d3451f8782
--- /dev/null
+++ b/corpus/equation-join/README.md
@@ -0,0 +1,17 @@
+# Deleting between equations
+
+`lab/` holds two COM reads of a disposable OneNote 2010 lab notebook.
+`equation-seam.xml` is two equations typed with Alt+=, "aa+bb" and "cc+dd" in
+paragraphs of their own, after a selection from "aa+|" into "cc|" was deleted:
+OneNote joins them into one equation, `aa++dd`. `equation-per-paragraph.xml` is
+Alt+= over a selection across two paragraphs: each paragraph's part becomes an
+equation of its own.
+
+`candidate/` is the output of `deleting_between_equations_joins_them` in
+`crates/canvas/tests/links_equations.rs` on the page of `corpus/link-edit/candidate`:
+the same equations typed and the same selection deleted through the editor, whose
+joined equation reads back as one. `cold/` is a fresh OneNote 2010 read of it, whose
+MathML is OneNote's own. `tools/test_equation_join.py` checks this without a VM.
+Regenerate with `CANVAS_EQUATION_JOIN_EXPORT` set to a new absolute directory while
+running the test, then cold-open it with
+`tools/native_runner.py OUTPUT COLD --expected-pages 1 --collect-notebook --screenshots`.
diff --git a/corpus/equation-join/candidate/Open Notebook.onetoc2 b/corpus/equation-join/candidate/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..8dd9580bf95258edc9028c15db610191bfc7406b
Binary files /dev/null and b/corpus/equation-join/candidate/Open Notebook.onetoc2 differ
diff --git a/corpus/equation-join/candidate/links.one b/corpus/equation-join/candidate/links.one
new file mode 100644
index 0000000000000000000000000000000000000000..08b52dd23c3e1480467256c5e6c7219bc1f6cd9b
Binary files /dev/null and b/corpus/equation-join/candidate/links.one differ
diff --git a/corpus/equation-join/cold/commands.jsonl b/corpus/equation-join/cold/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..cbd8e5b1ff522f6982fa74469a5bac49ac405985
--- /dev/null
+++ b/corpus/equation-join/cold/commands.jsonl
@@ -0,0 +1,3 @@
+{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-5366E305 -ExpectedPages 1 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
diff --git a/corpus/equation-join/cold/machine.json b/corpus/equation-join/cold/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..c128850694780ba381f550fad26a4ecda0056d79
--- /dev/null
+++ b/corpus/equation-join/cold/machine.json
@@ -0,0 +1 @@
+{"name": "m6-5366e305", "hostname": "ONE-M6-5366E305"}
diff --git a/corpus/equation-join/cold/notebook/Open Notebook.onetoc2 b/corpus/equation-join/cold/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..9ef20188bb494f684371b947443d5ca831a72172
Binary files /dev/null and b/corpus/equation-join/cold/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/equation-join/cold/notebook/links.one b/corpus/equation-join/cold/notebook/links.one
new file mode 100644
index 0000000000000000000000000000000000000000..227454ee8da92593f8cf22afe10551ee5445ed87
Binary files /dev/null and b/corpus/equation-join/cold/notebook/links.one differ
diff --git a/corpus/equation-join/cold/read/environment.json b/corpus/equation-join/cold/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..8cd3c9a6e4488f8755247d5bf6cf9188139553d3
--- /dev/null
+++ b/corpus/equation-join/cold/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-5366E305",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/equation-join/cold/read/hierarchy.xml b/corpus/equation-join/cold/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..134170399a68dc20d3c14d9b73516dc2a2245eb9
--- /dev/null
+++ b/corpus/equation-join/cold/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/equation-join/cold/read/page-000.navigation.json b/corpus/equation-join/cold/read/page-000.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..83fb4df3362bb2ac7e9fa657fe6db76614b54e31
--- /dev/null
+++ b/corpus/equation-join/cold/read/page-000.navigation.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Read about Rust the Rust site - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/equation-join/cold/read/page-000.png b/corpus/equation-join/cold/read/page-000.png
new file mode 100644
index 0000000000000000000000000000000000000000..bdcfca0d84290a55b4c9100e7a4ebec712db4828
Binary files /dev/null and b/corpus/equation-join/cold/read/page-000.png differ
diff --git a/corpus/equation-join/cold/read/page-000.xml b/corpus/equation-join/cold/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..433fc21858503cc03c0e20ff8e056746fd624b8e
--- /dev/null
+++ b/corpus/equation-join/cold/read/page-000.xml
@@ -0,0 +1,3 @@
+
+the Rust site]]>aa++dd ]]>
diff --git a/corpus/equation-join/cold/read/payloads.json b/corpus/equation-join/cold/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843
--- /dev/null
+++ b/corpus/equation-join/cold/read/payloads.json
@@ -0,0 +1,3 @@
+[
+
+]
\ No newline at end of file
diff --git a/corpus/equation-join/cold/run.json b/corpus/equation-join/cold/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..8aaabba0e9b30ba4dc7955496706f31484b3e997
--- /dev/null
+++ b/corpus/equation-join/cold/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/private/tmp/unsup-lab/join-candidate",
+ "expected_pages": 1,
+ "author": null,
+ "author_timeout_seconds": 600,
+ "inspect": false,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
+ }
+}
diff --git a/corpus/equation-join/cold/scripts/cold.ps1 b/corpus/equation-join/cold/scripts/cold.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f
--- /dev/null
+++ b/corpus/equation-join/cold/scripts/cold.ps1
@@ -0,0 +1,27 @@
+param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
+$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
+if ($CloneHost) {
+ if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
+ throw 'The disposable clone hostname does not match this machine.'
+ }
+ New-Item "$key\Options\Paths" -Force | Out-Null
+ New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
+} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
+ -not (Test-Path 'C:\one-tests\profile-original-cache')) {
+ throw 'Park the personal OneNote profile before resetting the test cache.'
+}
+$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
+$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
+if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
+New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
+if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
+if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
+New-Item "$key\OpenNotebooks" | Out-Null
+New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
diff --git a/corpus/equation-join/cold/scripts/read.ps1 b/corpus/equation-join/cold/scripts/read.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a42366b32e8baaf13dae92e9472cae7a071220cc
--- /dev/null
+++ b/corpus/equation-join/cold/scripts/read.ps1
@@ -0,0 +1,144 @@
+param(
+ [Parameter(Mandatory=$true)][string]$Root,
+ [int]$ExpectedPages = -1,
+ [switch]$UseCurrentCache,
+ [switch]$Pdf,
+ [switch]$KeepOpen,
+ [string]$CloneHost = ''
+)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+$notebook = Join-Path $root 'notebook'
+$output = Join-Path $root 'read'
+if (Test-Path $output) { throw 'Choose a new read destination.' }
+if ($UseCurrentCache) {
+ if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
+ throw 'Park the personal OneNote profile before reading test notebooks.'
+ }
+} else {
+ & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
+}
+New-Item -ItemType Directory -Path $output | Out-Null
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+$failure = $null
+try {
+ $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
+ $process = Get-Process ONENOTE
+ @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
+ powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
+ ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
+ $sections = @()
+ foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
+ $id = ''
+ $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
+ $sections += $id
+ }
+ $deadline = [DateTime]::UtcNow.AddSeconds(300)
+ $previous = ''
+ $lastChange = ''
+ $stableSince = [DateTime]::UtcNow
+ $settled = $false
+ do {
+ $pages = @{}
+ foreach ($section in $sections) {
+ $hierarchy = ''
+ $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
+ [xml]$xml = $hierarchy
+ foreach ($node in $xml.SelectNodes('//*[@path]')) {
+ if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'OneNote opened a section outside the copied notebook.'
+ }
+ }
+ foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
+ $id = $node.GetAttribute('ID')
+ $content = ''
+ $app.GetPageContent($id, [ref]$content, 1, 1)
+ $pages[$id] = $content
+ }
+ }
+ $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
+ if ($signature -ne $previous) {
+ $lastChange = $previous
+ $previous = $signature
+ $stableSince = [DateTime]::UtcNow
+ }
+ if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
+ ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
+ ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
+ Start-Sleep -Milliseconds 250
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
+ [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
+ $index = 0
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ $index++
+ }
+ $hierarchy = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
+ [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
+ throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
+ }
+ $index = 0
+ $payloads = @()
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ if ($Pdf) {
+ $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
+ $app.NavigateTo($id, '', $false)
+ $app.Publish($id, $pdfPath, 3, '')
+ if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
+ }
+ [xml]$page = $pages[$id]
+ foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
+ $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
+ $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
+ [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
+ # A paragraph's file is named by its paragraph, one on the page by itself.
+ $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
+ $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
+ kind = $file.LocalName; name = $file.GetAttribute('preferredName');
+ sha256 = $hash; bytes = $bytes.Length }
+ }
+ $index++
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
+ $all = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
+ [xml]$finalTree = $all
+ $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
+ if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
+ throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
+ Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
+} catch {
+ $failure = $_
+ [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ throw
+} finally {
+ try {
+ try {
+ if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
+ if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
+ $app.CloseNotebook($notebookId, $false)
+ }
+ } catch {
+ if ($null -eq $failure) { throw }
+ [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ }
+ } finally {
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ }
+ $app = $null
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+ if (-not $UseCurrentCache -and -not $CloneHost) {
+ Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
+ }
+}
diff --git a/corpus/equation-join/cold/source.json b/corpus/equation-join/cold/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..5ae40622df828870d2635029cef87b2d9270c5a4
--- /dev/null
+++ b/corpus/equation-join/cold/source.json
@@ -0,0 +1,14 @@
+[
+ {
+ "path": "Open Notebook.onetoc2",
+ "bytes": 1604,
+ "sha256": "df162b4a61fdc7d40bcd8c86a57cd7b98021eb75f8d1251037aa1df03cf28f1f",
+ "mtime_ns": 1790747690894245805
+ },
+ {
+ "path": "links.one",
+ "bytes": 8176,
+ "sha256": "be93291735237f7ba9bdbeb88fee8b4a46b7269b9cca3c1ce96d69b0c5ee6096",
+ "mtime_ns": 1790747690894084845
+ }
+]
diff --git a/corpus/equation-join/cold/teardown.json b/corpus/equation-join/cold/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/equation-join/cold/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/corpus/equation-join/lab/equation-per-paragraph.xml b/corpus/equation-join/lab/equation-per-paragraph.xml
new file mode 100644
index 0000000000000000000000000000000000000000..f1fe49966d84cd5f5599a029ca75d1461ffb55bb
--- /dev/null
+++ b/corpus/equation-join/lab/equation-per-paragraph.xml
@@ -0,0 +1,5 @@
+
+One ]]>Three four]]>
+
diff --git a/corpus/equation-join/lab/equation-seam.xml b/corpus/equation-join/lab/equation-seam.xml
new file mode 100644
index 0000000000000000000000000000000000000000..7a4d0a180a4d57cd8f9d19d4a721330670fdc38c
--- /dev/null
+++ b/corpus/equation-join/lab/equation-seam.xml
@@ -0,0 +1,3 @@
+
+aa++dd ]]>
+
diff --git a/corpus/icloud-merge/README.md b/corpus/icloud-merge/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..c64d4f35c88d514e47ba0c042a6d5a65ac3f257a
--- /dev/null
+++ b/corpus/icloud-merge/README.md
@@ -0,0 +1,13 @@
+# A conflict version merged
+
+`candidate` is `a_version_of_a_onenote_file_merges_into_it` in `crates/notebook/tests/cloud.rs`
+(`ONESTORE_ICLOUD_EXPORT`): two devices edit `conflict-page/native/initial` through a fake
+iCloud Drive. The Mac types "Mac: " at the start of the first paragraph; the iPhone, offline,
+types "iPhone: " at the same place, appends to the positioned outline and adds a page "From the
+iPhone" with a body. The iPhone's upload becomes a conflict version, which the Mac merges: the
+appended text and the new page replay, and the clashing edit becomes a conflict page.
+
+`cold` is OneNote 2010's fresh read in a disposable clone (`tools/native_runner.py`,
+`--screenshots`): both pages list, the first with its conflict mark and the yellow bar "This page
+has changes that could not be merged during synchronization", its positioned outline ending
+"(typed on the iPhone)", and "From the iPhone" holding "Written offline on the iPhone."
diff --git a/corpus/icloud-merge/candidate/Open Notebook.onetoc2 b/corpus/icloud-merge/candidate/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..c7beb44bf509f935090fe55e7549e4b50e0ae0e9
Binary files /dev/null and b/corpus/icloud-merge/candidate/Open Notebook.onetoc2 differ
diff --git a/corpus/icloud-merge/candidate/synthetic.one b/corpus/icloud-merge/candidate/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..74af495a916f5f9d6f611eb9ebbbd50721cac846
Binary files /dev/null and b/corpus/icloud-merge/candidate/synthetic.one differ
diff --git a/corpus/icloud-merge/cold/commands.jsonl b/corpus/icloud-merge/cold/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..2dc314e6972496392ce250e617d0199ce758cf55
--- /dev/null
+++ b/corpus/icloud-merge/cold/commands.jsonl
@@ -0,0 +1,3 @@
+{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-7D90CFD6 -ExpectedPages -1 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 2 pages.\r\n", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
diff --git a/corpus/icloud-merge/cold/machine.json b/corpus/icloud-merge/cold/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..9e422b8b1199a1d16b0b2563c12c84d4e537405e
--- /dev/null
+++ b/corpus/icloud-merge/cold/machine.json
@@ -0,0 +1 @@
+{"name": "m6-7d90cfd6", "hostname": "ONE-M6-7D90CFD6"}
diff --git a/corpus/icloud-merge/cold/notebook/Open Notebook.onetoc2 b/corpus/icloud-merge/cold/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..c7beb44bf509f935090fe55e7549e4b50e0ae0e9
Binary files /dev/null and b/corpus/icloud-merge/cold/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/icloud-merge/cold/notebook/synthetic.one b/corpus/icloud-merge/cold/notebook/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..341046bb4a798dc5d625522d891e8a2dc733583f
Binary files /dev/null and b/corpus/icloud-merge/cold/notebook/synthetic.one differ
diff --git a/corpus/icloud-merge/cold/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment b/corpus/icloud-merge/cold/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment
new file mode 100644
index 0000000000000000000000000000000000000000..66b6151e91f90df6a61a19ef2f4447da278e07b2
--- /dev/null
+++ b/corpus/icloud-merge/cold/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment
@@ -0,0 +1 @@
+Fictitious attachment for native corpus.
\ No newline at end of file
diff --git a/corpus/icloud-merge/cold/read/environment.json b/corpus/icloud-merge/cold/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..7474e12b368b2c93a9c3b343b11bc37677440d81
--- /dev/null
+++ b/corpus/icloud-merge/cold/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-7D90CFD6",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/icloud-merge/cold/read/hierarchy.xml b/corpus/icloud-merge/cold/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..04144036640aa932bacc1f5d88b2be0cd074c4ad
--- /dev/null
+++ b/corpus/icloud-merge/cold/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/icloud-merge/cold/read/page-000.navigation.json b/corpus/icloud-merge/cold/read/page-000.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..dec1111275d1a2b667ceeb8061a2a410672f8624
--- /dev/null
+++ b/corpus/icloud-merge/cold/read/page-000.navigation.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "From the iPhone - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/icloud-merge/cold/read/page-000.png b/corpus/icloud-merge/cold/read/page-000.png
new file mode 100644
index 0000000000000000000000000000000000000000..d3a1d39ecbb6deff67928098262217fc6b9f797f
Binary files /dev/null and b/corpus/icloud-merge/cold/read/page-000.png differ
diff --git a/corpus/icloud-merge/cold/read/page-000.xml b/corpus/icloud-merge/cold/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..90cae615da2d31bc687a09792df84a92d2da3e63
--- /dev/null
+++ b/corpus/icloud-merge/cold/read/page-000.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/icloud-merge/cold/read/page-001.navigation.json b/corpus/icloud-merge/cold/read/page-001.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..6cd4bb2182af13147155e35878ada4b490086d4c
--- /dev/null
+++ b/corpus/icloud-merge/cold/read/page-001.navigation.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Mac: Fictitious: caf\u00e9, \u6771\u4eac, \u0645\u0631\u062d\u0628\u0627 - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/icloud-merge/cold/read/page-001.png b/corpus/icloud-merge/cold/read/page-001.png
new file mode 100644
index 0000000000000000000000000000000000000000..e17d47dabf535960ffd87fb626b9f44d4691919e
Binary files /dev/null and b/corpus/icloud-merge/cold/read/page-001.png differ
diff --git a/corpus/icloud-merge/cold/read/page-001.xml b/corpus/icloud-merge/cold/read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..2abb629517e219f906dd42fbbd0cd1e3f71fc45c
--- /dev/null
+++ b/corpus/icloud-merge/cold/read/page-001.xml
@@ -0,0 +1,26 @@
+
+Mac: Fictitious: café, 東京, مرحبا]]>AK4FHQSiAaIBAWAqABofi0JwRYR1gL7QRdkE+oeVMvLsF0G4wBa/8yv0et67E4vlXrNInJuhJVq3
+URK/7HHMzCKxQYkOuxMs7nqhWM9UiuaXxU+PBvi60uGbIsRmbacNslhJmh1OQDQGbBADBkgQRSNG
+IwUIC2VPZmdoZGkZFDIIAIAeAiHi4kEzCADwEAKLxeJBZAMVRgBACpUE/QGH81d3mq7mkzgM0mcB
+mk1gEBgEBgEBgE1mUAgMzmUAmsygE1mUzmkBmc0gMzmk1mUAmsyTOaTWZQCazKZgALSmiazKATWZ
+QCAwCAwBAZpM4DAJlNYBMprAJlNZpM4DNJmmU1mkzgM0mcBmkzmU1gEyAAFpTSAzOaQGZzRAYBAY
+BAYBMprAIDNJnAZpM0yms0mcBmkzgM0mcymsAmU1gEyms0maZAACZTSawCZTWATKawCAoBAYBAZn
+NIDAJrMoBNZlAJrMpnMoBNZlAJrMpnNIDM5pAZnNJrMkzmk1AACH8C7XgWnmkzgM0msAmU1gEBgE
+BgEBTOaQGATWZQCazKATWZTOaTWZTNNJrMpmmUzTSaplM00mqZTOaTVMpmmk1TKATKazSawCAzSZ
+wGAQFAIDAJrMoBAZnNIDM5pAZnMpnNJrMpmmk1mUzTSaplM00mqZTNNJrMpmmk1TKZplhuAzKATW
+ZQCazKAIDAIDAIDNJnAYBMprAJlM4DNJnMprNJnMk1mkzmSazRM5kms0TOZJnMprNEzmSazQtKaQ
+GZzRNZlAIDAIDAIDAJlNYAmU1gEymsAmU1mkzmU1miZzKazRM5kmcyTWaJnMk1mkzmSazRM5kAUU
+RgBAAAAJBAUURgAAAAASABEgXhT+65lz/EiPi+8SFvjqCAk+gEcDAa/89Qd0AxVGAEAKABEg2R87
+QQrSFUA=AOECHQJSegFgKgAaH4tCcEWEdYC+0EXZBPqHlTLy7BdBuMAWv/Mr9HreuxOL5V6zSJyboSVat1ES
+v+xxzMwisUGJDrsTLO56oVjPVIrml8VPjwb4utLhmyLEZm2nDbJYSZodTkA0BmwQAwZIEEUjRiMF
+CAtlT2ZnaGRpGRQyCACAHgIh4uJBMwgA8BACi8XiQWQDFUYAQArKAUuH88s3nkzmkzgM0mcBmkzg
+KAQGAQGATWZQCAzOaQGZzKATWZTOaQGZzSAzOaTWZQCazJM5pNZlAJrMpmAAAACH8D9XgeBJlM0z
+mRNZpNZpM4DAJlNYBAUAgMAgMzmkBgE1mUAmsyTOaTWZTOaTVMpnNJqmUzTSaplM00mqZTOZTNNJ
+qmUzTSagBRRGAIAAAAkEBRRGAAAAABIAESDPTQ4xwIZyTqQ8FVDbHxeKCj9AI5rARvPPUDugAxVG
+AEAKABEgLMlfQdkfI0A=iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9J1uoA
+AAAASUVORK5CYII=
diff --git a/corpus/icloud-merge/cold/read/payloads.json b/corpus/icloud-merge/cold/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1a7cf679ab4e54b68e86e146fae5cdba530db78d
--- /dev/null
+++ b/corpus/icloud-merge/cold/read/payloads.json
@@ -0,0 +1,10 @@
+[
+ {
+ "sha256": "af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7",
+ "name": "fictitious-attachment.txt",
+ "object": "{9FDAFD7E-E3BC-49B7-93E7-A8052BA1831B}{11}{B0}",
+ "kind": "InsertedFile",
+ "page": "{1695F938-3B7E-4A34-957E-6A5CA88D058E}{1}{B0}",
+ "bytes": 43
+ }
+]
\ No newline at end of file
diff --git a/corpus/icloud-merge/cold/run.json b/corpus/icloud-merge/cold/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..36afe9e5f36c3917a9b94b9100de9004b859bea1
--- /dev/null
+++ b/corpus/icloud-merge/cold/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/private/tmp/icloud-lab/candidate",
+ "expected_pages": -1,
+ "author": null,
+ "author_timeout_seconds": 600,
+ "inspect": false,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
+ }
+}
diff --git a/corpus/icloud-merge/cold/scripts/cold.ps1 b/corpus/icloud-merge/cold/scripts/cold.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f
--- /dev/null
+++ b/corpus/icloud-merge/cold/scripts/cold.ps1
@@ -0,0 +1,27 @@
+param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
+$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
+if ($CloneHost) {
+ if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
+ throw 'The disposable clone hostname does not match this machine.'
+ }
+ New-Item "$key\Options\Paths" -Force | Out-Null
+ New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
+} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
+ -not (Test-Path 'C:\one-tests\profile-original-cache')) {
+ throw 'Park the personal OneNote profile before resetting the test cache.'
+}
+$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
+$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
+if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
+New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
+if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
+if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
+New-Item "$key\OpenNotebooks" | Out-Null
+New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
diff --git a/corpus/icloud-merge/cold/scripts/read.ps1 b/corpus/icloud-merge/cold/scripts/read.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a42366b32e8baaf13dae92e9472cae7a071220cc
--- /dev/null
+++ b/corpus/icloud-merge/cold/scripts/read.ps1
@@ -0,0 +1,144 @@
+param(
+ [Parameter(Mandatory=$true)][string]$Root,
+ [int]$ExpectedPages = -1,
+ [switch]$UseCurrentCache,
+ [switch]$Pdf,
+ [switch]$KeepOpen,
+ [string]$CloneHost = ''
+)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+$notebook = Join-Path $root 'notebook'
+$output = Join-Path $root 'read'
+if (Test-Path $output) { throw 'Choose a new read destination.' }
+if ($UseCurrentCache) {
+ if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
+ throw 'Park the personal OneNote profile before reading test notebooks.'
+ }
+} else {
+ & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
+}
+New-Item -ItemType Directory -Path $output | Out-Null
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+$failure = $null
+try {
+ $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
+ $process = Get-Process ONENOTE
+ @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
+ powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
+ ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
+ $sections = @()
+ foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
+ $id = ''
+ $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
+ $sections += $id
+ }
+ $deadline = [DateTime]::UtcNow.AddSeconds(300)
+ $previous = ''
+ $lastChange = ''
+ $stableSince = [DateTime]::UtcNow
+ $settled = $false
+ do {
+ $pages = @{}
+ foreach ($section in $sections) {
+ $hierarchy = ''
+ $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
+ [xml]$xml = $hierarchy
+ foreach ($node in $xml.SelectNodes('//*[@path]')) {
+ if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'OneNote opened a section outside the copied notebook.'
+ }
+ }
+ foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
+ $id = $node.GetAttribute('ID')
+ $content = ''
+ $app.GetPageContent($id, [ref]$content, 1, 1)
+ $pages[$id] = $content
+ }
+ }
+ $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
+ if ($signature -ne $previous) {
+ $lastChange = $previous
+ $previous = $signature
+ $stableSince = [DateTime]::UtcNow
+ }
+ if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
+ ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
+ ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
+ Start-Sleep -Milliseconds 250
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
+ [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
+ $index = 0
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ $index++
+ }
+ $hierarchy = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
+ [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
+ throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
+ }
+ $index = 0
+ $payloads = @()
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ if ($Pdf) {
+ $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
+ $app.NavigateTo($id, '', $false)
+ $app.Publish($id, $pdfPath, 3, '')
+ if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
+ }
+ [xml]$page = $pages[$id]
+ foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
+ $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
+ $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
+ [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
+ # A paragraph's file is named by its paragraph, one on the page by itself.
+ $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
+ $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
+ kind = $file.LocalName; name = $file.GetAttribute('preferredName');
+ sha256 = $hash; bytes = $bytes.Length }
+ }
+ $index++
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
+ $all = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
+ [xml]$finalTree = $all
+ $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
+ if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
+ throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
+ Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
+} catch {
+ $failure = $_
+ [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ throw
+} finally {
+ try {
+ try {
+ if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
+ if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
+ $app.CloseNotebook($notebookId, $false)
+ }
+ } catch {
+ if ($null -eq $failure) { throw }
+ [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ }
+ } finally {
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ }
+ $app = $null
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+ if (-not $UseCurrentCache -and -not $CloneHost) {
+ Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
+ }
+}
diff --git a/corpus/icloud-merge/cold/source.json b/corpus/icloud-merge/cold/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..8a3ed2edbb42ffeca8c341d8a705e8a7598623b2
--- /dev/null
+++ b/corpus/icloud-merge/cold/source.json
@@ -0,0 +1,14 @@
+[
+ {
+ "path": "Open Notebook.onetoc2",
+ "bytes": 4944,
+ "sha256": "36274ad5cc077c6b1ad51ee4c4b90d3b6798a45ce446cbfd7c9d7ac504776b28",
+ "mtime_ns": 1790473161215529077
+ },
+ {
+ "path": "synthetic.one",
+ "bytes": 48544,
+ "sha256": "8b733a304e4e3a2e3f137c188227b1fc80cb5527b04c76a941f9925262801120",
+ "mtime_ns": 1790750199560524829
+ }
+]
diff --git a/corpus/icloud-merge/cold/teardown.json b/corpus/icloud-merge/cold/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/icloud-merge/cold/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/corpus/ink-pressure/README.md b/corpus/ink-pressure/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..0dbc16ab3ea5484daea1e68cf05281c20612de16
--- /dev/null
+++ b/corpus/ink-pressure/README.md
@@ -0,0 +1,45 @@
+# Pressure ink
+
+OneNote 2010 draws a pen's pressure but stores only what the pen reports. With a mouse, or
+with File > Options > Advanced > Pen > "Use pen pressure sensitivity" off (the default,
+`HKCU\Software\Microsoft\Office\14.0\OneNote\Options\Pen\InkPressure`), a pen's drawing
+attributes carry IgnorePressure (`0x08003411`) and its dimension table holds X and Y. With the
+option on, the attributes leave IgnorePressure out, and a pen that reports pressure adds it.
+The lab's Windows 7 has no digitizer, so the pressure strokes here come from ISF: WPF builds
+strokes with NormalPressure (and one with X and Y tilt) and OneNote takes each through
+`UpdatePageContent` (`tools/native/ink-pressure.ps1`).
+
+- `native` is that run: page "Levels" holds two drawings of nine 24 pt strokes at pressures 0
+ to 1 in eighths, one pen reporting 0..1023 and the other 0..255; "Strokes" a ramp from no to
+ full pressure, a wave, a pen ignoring the pressure it recorded, a stroke with tilt and a
+ 4 by 24 rectangular tip.
+
+What it shows, and `crates/onestore/tests/page_ink.rs` pins:
+
+- The dimension table (`0x1c00340a`) keeps every packet property the pen reported, each a
+ GUID, lower and upper limit, unit and resolution: X and Y, NormalPressure
+ (`7307502d-f9f4-4e18-b3f2-2ce1b1a3610c`), tilt and the rest. The stroke's path holds one
+ block of multi-byte first differences per dimension, in the table's order.
+- OneNote draws each point as a stamp of the pen's tip `0.25 + 1.5 * p` times its width and
+ height, where `p` is the pressure over the pen's range, `(value - lower) / (upper -
+ lower)`: a quarter of the pen at none, all of it at half, 1.75 times at full. Consecutive
+ stamps are joined by the quadrilateral across their ends. Tilt changes nothing drawn, and a
+ pen with IgnorePressure draws at its width whatever it recorded.
+
+Snowbound reads pressure the same way and draws it as OneNote does. A stroke drawn with a pen
+that reports pressure (a tablet on macOS, the Apple Pencil) stores X, Y and NormalPressure
+from 0 to 1023 without IgnorePressure, as OneNote does with the option on; a mouse's or a
+finger's stroke stays as OneNote's mouse ink.
+
+`candidate` is `pressure_ink_is_written_as_onenote_keeps_it_and_survives_edits` in
+`page_ink.rs` (`ONESTORE_INK_PRESSURE_EXPORT`): the native file with the first drawing's
+no-pressure stroke erased, four Snowbound drawings of a 500 HIMETRIC pen below it (none, half
+and full pressure, and a ramp), and on "Strokes" the ramp moved to (72, 108) and the wave
+deleted. `cold` is a fresh OneNote 2010 read: every width as drawn above, in its PDF export
+and `read/page-*.png`.
+
+`tools/test_ink_pressure.py` checks both from the PDF exports without a VM. Regenerate with
+`tools/native_runner.py EMPTY_DIRECTORY OUTPUT --author tools/native/ink-pressure.ps1
+--collect-notebook --screenshots --pdf`, and cold-open a candidate with
+`tools/native_runner.py CANDIDATE COLD --expected-pages 2 --collect-notebook --screenshots
+--pdf`.
diff --git a/corpus/ink-pressure/candidate/Open Notebook.onetoc2 b/corpus/ink-pressure/candidate/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..d30b00ee1224ca1eddf9c9844048f9f5985074b5
Binary files /dev/null and b/corpus/ink-pressure/candidate/Open Notebook.onetoc2 differ
diff --git a/corpus/ink-pressure/candidate/Pressure.one b/corpus/ink-pressure/candidate/Pressure.one
new file mode 100644
index 0000000000000000000000000000000000000000..5adfe5bfc4cd45504f7ce2ef84a491c9147a1bc2
Binary files /dev/null and b/corpus/ink-pressure/candidate/Pressure.one differ
diff --git a/corpus/ink-pressure/cold/commands.jsonl b/corpus/ink-pressure/cold/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..8b38b105d360b4d996904d85826f2da9fb116c55
--- /dev/null
+++ b/corpus/ink-pressure/cold/commands.jsonl
@@ -0,0 +1,3 @@
+{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-F2A75FD2 -ExpectedPages 2 -Pdf -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 2 pages.\r\n", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
diff --git a/corpus/ink-pressure/cold/machine.json b/corpus/ink-pressure/cold/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..57adbce9813220779e01bcf838358e85f26d31f4
--- /dev/null
+++ b/corpus/ink-pressure/cold/machine.json
@@ -0,0 +1 @@
+{"name": "m6-f2a75fd2", "hostname": "ONE-M6-F2A75FD2"}
diff --git a/corpus/ink-pressure/cold/notebook/Open Notebook.onetoc2 b/corpus/ink-pressure/cold/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..0721630f2b32eff92ba9ee3178899e95f0a6ae2b
Binary files /dev/null and b/corpus/ink-pressure/cold/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/ink-pressure/cold/notebook/Pressure.one b/corpus/ink-pressure/cold/notebook/Pressure.one
new file mode 100644
index 0000000000000000000000000000000000000000..2502bb49b0e6db6677a4d8e520421bfd5fbd4c95
Binary files /dev/null and b/corpus/ink-pressure/cold/notebook/Pressure.one differ
diff --git a/corpus/ink-pressure/cold/read/environment.json b/corpus/ink-pressure/cold/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..731ed90d1469e3f70f06e151ad62d34774601c3c
--- /dev/null
+++ b/corpus/ink-pressure/cold/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-F2A75FD2",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/ink-pressure/cold/read/hierarchy.xml b/corpus/ink-pressure/cold/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..a888616c9f4ee7c63396af5ab18616bc6265c276
--- /dev/null
+++ b/corpus/ink-pressure/cold/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/ink-pressure/cold/read/page-000.navigation.json b/corpus/ink-pressure/cold/read/page-000.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..d4959cb4814b6a5c8a0373716bf39f45322e67e2
--- /dev/null
+++ b/corpus/ink-pressure/cold/read/page-000.navigation.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Levels - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/ink-pressure/cold/read/page-000.pdf b/corpus/ink-pressure/cold/read/page-000.pdf
new file mode 100644
index 0000000000000000000000000000000000000000..f53b1ad2c40e35fc13477035c8f3a183d0111c69
Binary files /dev/null and b/corpus/ink-pressure/cold/read/page-000.pdf differ
diff --git a/corpus/ink-pressure/cold/read/page-000.png b/corpus/ink-pressure/cold/read/page-000.png
new file mode 100644
index 0000000000000000000000000000000000000000..c22a989ddc95d0bd88c6afc75ca1b8be72014781
Binary files /dev/null and b/corpus/ink-pressure/cold/read/page-000.png differ
diff --git a/corpus/ink-pressure/cold/read/page-000.xml b/corpus/ink-pressure/cold/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..5476faa312e6fec1ce2aaf3406f2d681972b350c
--- /dev/null
+++ b/corpus/ink-pressure/cold/read/page-000.xml
@@ -0,0 +1,30 @@
+
+AKkCHQSQAsAGARAqABofi0JwRYR1gL7QRdkEAxJIEEXPBhsCALP+Rs8GGwIAs/4FAzgLZBkFOAMA
+/gNkAxVGAEAKDwWCfwYMlIIAggADFUYAQAoYBYJ/BgyUgv4jQ/iNEIL8SfiUAAMVRgBAChgFgn8G
+DJSC/krz+SvQgvzJ+ZQAAxVGAEAKGAWCfwYMlIL+cqP5ypCC/Un6lAADFUYAQAoYBYJ/BgyUgv6a
+U/ppUIL9yfuUAAMVRgBAChkFgn8GDJSC/sID+wgQgv4Ai/gCMAMVRgBAChkFgn8GDJSC/umz+6bQ
+gv4Bi/gGMAMVRgBAChwFgn8GDJSC/wAIsf4AEWQAgv4Ci/gKMAMVRgBAChwFgn8GDJSC/wAcif4A
+ORQAgv4Di/gOMAMVRgBA
+AJQCHQSQAuYFARAqABofi0JwRYR1gL7QRdkEAxJIEEXPBhsCALP+Rs8GGwIAs/4FAzgLZGQDFUYA
+QAoYBYJ/BgyUgv4jQ/iNEIL9yfuUAAMVRgBAChkFgn8GDJSC/krz+SvQgv4Dk/gOUAMVRgBAChkF
+gn8GDJSC/nKj+cqQgv4Hk/geUAMVRgBAChkFgn8GDJSC/ppT+mlQgv4Lk/guUAMVRgBAChkFgn8G
+DJSC/sID+wgQgv4Pi/g+MAMVRgBAChkFgn8GDJSC/umz+6bQgv4Ti/hOMAMVRgBAChwFgn8GDJSC
+/wAIsf4AEWQAgv4Xi/heMAMVRgBAChwFgn8GDJSC/wAcif4AORQAgv4bi/huMAMVRgBA
+APMBHQOKAwoBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
+Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMNSBBEn7Tq
+A0X0A0b0AwUJOAtlT2ZnaGRpGRQyCACAHgIh4uJBMwgA8BACi8XiQWQDFUYAQApUA4L+I0P4jQCC
+/wCcaf4BONSCAAUURgBAAAAJBAUURgAAAAASABEguqhoBLnfiUiQN9dTlXSFBAoAESAAPBmUtFDd
+AQMVRgBACgARIDabTT+39GNB
+APgBHQOmAyYBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
+Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMNSBBEn7Tq
+A0X0A0b0AwUJOAtlT2ZnaGRpGRQyCACAHgIh4uJBMwgA8BACi8XiQWQDFUYAQApZA4L+I0P4jQCC
+/wCwQf4BYISC/guT+C5QBRRGAIAAAAkEBRRGAAAAABIAESC+dPnOv2otSZfb454We3N4CgARIAA8
+GZS0UN0BAxVGAEAKABEgNptNP7f0c0E=APgBHQPCA0IBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
+Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMNSBBEn7Tq
+A0X0A0b0AwUJOAtlT2ZnaGRpGRQyCACAHgIh4uJBMwgA8BACi8XiQWQDFUYAQApZA4L+I0P4jQCC
+/wDEGf4BiDSC/huL+G4wBRRGAMAAAAkEBRRGAAAAABIAESBAFhfWgEA7TLxtxXvme3CCCgARIAA8
+GZS0UN0BAxVGAEAKABEgNptNP1z6gUE=AJICHQOmBUIBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
+Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMNSBBEn7Tq
+A0X0A0b0AwUJOAtlT2ZnaGRpGRQyCACAHgIh4uJBMwgA8BACi8XiQWQDFUYAQApzKYL+I0P4cpsW
+LFixYsWLFixYsIL/AN6R/gG9JAAAAAAAgn4YsssWWWLLFlliyyxZZYAFFEYBAAAACQQFFEYAAAAA
+EgARIDlXT31MN1lIqRuN46Oag5AKABEgADwZlLRQ3QEDFUYAQAoAESA2m00/GaaMQQ==
diff --git a/corpus/ink-pressure/cold/read/page-001.navigation.json b/corpus/ink-pressure/cold/read/page-001.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..f362538fe065f12f0f1a0a2ef6890d867f25c32c
--- /dev/null
+++ b/corpus/ink-pressure/cold/read/page-001.navigation.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Strokes - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/ink-pressure/cold/read/page-001.pdf b/corpus/ink-pressure/cold/read/page-001.pdf
new file mode 100644
index 0000000000000000000000000000000000000000..9a9b70a218b4e593b2047b96dcfaaf50832de374
Binary files /dev/null and b/corpus/ink-pressure/cold/read/page-001.pdf differ
diff --git a/corpus/ink-pressure/cold/read/page-001.png b/corpus/ink-pressure/cold/read/page-001.png
new file mode 100644
index 0000000000000000000000000000000000000000..596669114ad23621d1d9bcc5273fa682a5442104
Binary files /dev/null and b/corpus/ink-pressure/cold/read/page-001.png differ
diff --git a/corpus/ink-pressure/cold/read/page-001.xml b/corpus/ink-pressure/cold/read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..47b4261561f46522c1cc9204be5629773079b349
--- /dev/null
+++ b/corpus/ink-pressure/cold/read/page-001.xml
@@ -0,0 +1,8 @@
+
+AGYdA5AFHAEQKgAaH4tCcEWEdYC+0EXZBAMSSBBF1AEbAgCz/kbUARsCALP+BQM4C2RkAxVGAEAK
+LimCfwEZYsWLFixYsWLFixYAggAAAAAAAIJ+GLLLFlliyxZZYsssWWWAAxVGAEA=AGYdA5AFEAEQKgAaH4tCcEWEdYC+0EXZBAMSSBRF1AEbAgCz/kbUARsCALP+BQM4C2RkAxVGAEAK
+LimCfwEZYsWLFixYsWLFixYAggAAAAAAAIJ+GLLLFlliyxZZYsssWWWAAxVGAEA=AJgBHQOQBRgBECoAGh+LQnBFhHWAvtBF2QQDEkgQRdQBGwIAs/5G1AEbAgCz/gUFODs8C2QZGjsL
+0YwB0IwBAwAAyEI8C9GMAdCMAQMAAMhCZAMVRgBACkIpgn8BGWLFixYsWLFixYsWAIIAAAAAAACC
+/gH7+AXQAAlAAASggv6IN/o5wAAAAAAAgv5ZU/llUAAAAAAAAxVGAEA=AEkdA5gDVAEQKgAaH4tCcEWEdYC+0EXZBAMOSBBFahsCAFn/RwFG+wQFAzgLZGQDFUYAQAoVC4J/
+BgyUEIIAAIJ+sJUpKAMVRgBA
+
diff --git a/corpus/ink-pressure/cold/read/payloads.json b/corpus/ink-pressure/cold/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843
--- /dev/null
+++ b/corpus/ink-pressure/cold/read/payloads.json
@@ -0,0 +1,3 @@
+[
+
+]
\ No newline at end of file
diff --git a/corpus/ink-pressure/cold/run.json b/corpus/ink-pressure/cold/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..672d158d9cc5fad3772e75718367961c5effb09f
--- /dev/null
+++ b/corpus/ink-pressure/cold/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/private/tmp/snowbound-pressure/candidate",
+ "expected_pages": 2,
+ "author": null,
+ "author_timeout_seconds": 600,
+ "inspect": false,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
+ }
+}
diff --git a/corpus/ink-pressure/cold/scripts/cold.ps1 b/corpus/ink-pressure/cold/scripts/cold.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f
--- /dev/null
+++ b/corpus/ink-pressure/cold/scripts/cold.ps1
@@ -0,0 +1,27 @@
+param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
+$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
+if ($CloneHost) {
+ if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
+ throw 'The disposable clone hostname does not match this machine.'
+ }
+ New-Item "$key\Options\Paths" -Force | Out-Null
+ New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
+} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
+ -not (Test-Path 'C:\one-tests\profile-original-cache')) {
+ throw 'Park the personal OneNote profile before resetting the test cache.'
+}
+$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
+$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
+if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
+New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
+if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
+if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
+New-Item "$key\OpenNotebooks" | Out-Null
+New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
diff --git a/corpus/ink-pressure/cold/scripts/read.ps1 b/corpus/ink-pressure/cold/scripts/read.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a42366b32e8baaf13dae92e9472cae7a071220cc
--- /dev/null
+++ b/corpus/ink-pressure/cold/scripts/read.ps1
@@ -0,0 +1,144 @@
+param(
+ [Parameter(Mandatory=$true)][string]$Root,
+ [int]$ExpectedPages = -1,
+ [switch]$UseCurrentCache,
+ [switch]$Pdf,
+ [switch]$KeepOpen,
+ [string]$CloneHost = ''
+)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+$notebook = Join-Path $root 'notebook'
+$output = Join-Path $root 'read'
+if (Test-Path $output) { throw 'Choose a new read destination.' }
+if ($UseCurrentCache) {
+ if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
+ throw 'Park the personal OneNote profile before reading test notebooks.'
+ }
+} else {
+ & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
+}
+New-Item -ItemType Directory -Path $output | Out-Null
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+$failure = $null
+try {
+ $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
+ $process = Get-Process ONENOTE
+ @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
+ powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
+ ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
+ $sections = @()
+ foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
+ $id = ''
+ $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
+ $sections += $id
+ }
+ $deadline = [DateTime]::UtcNow.AddSeconds(300)
+ $previous = ''
+ $lastChange = ''
+ $stableSince = [DateTime]::UtcNow
+ $settled = $false
+ do {
+ $pages = @{}
+ foreach ($section in $sections) {
+ $hierarchy = ''
+ $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
+ [xml]$xml = $hierarchy
+ foreach ($node in $xml.SelectNodes('//*[@path]')) {
+ if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'OneNote opened a section outside the copied notebook.'
+ }
+ }
+ foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
+ $id = $node.GetAttribute('ID')
+ $content = ''
+ $app.GetPageContent($id, [ref]$content, 1, 1)
+ $pages[$id] = $content
+ }
+ }
+ $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
+ if ($signature -ne $previous) {
+ $lastChange = $previous
+ $previous = $signature
+ $stableSince = [DateTime]::UtcNow
+ }
+ if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
+ ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
+ ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
+ Start-Sleep -Milliseconds 250
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
+ [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
+ $index = 0
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ $index++
+ }
+ $hierarchy = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
+ [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
+ throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
+ }
+ $index = 0
+ $payloads = @()
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ if ($Pdf) {
+ $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
+ $app.NavigateTo($id, '', $false)
+ $app.Publish($id, $pdfPath, 3, '')
+ if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
+ }
+ [xml]$page = $pages[$id]
+ foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
+ $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
+ $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
+ [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
+ # A paragraph's file is named by its paragraph, one on the page by itself.
+ $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
+ $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
+ kind = $file.LocalName; name = $file.GetAttribute('preferredName');
+ sha256 = $hash; bytes = $bytes.Length }
+ }
+ $index++
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
+ $all = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
+ [xml]$finalTree = $all
+ $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
+ if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
+ throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
+ Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
+} catch {
+ $failure = $_
+ [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ throw
+} finally {
+ try {
+ try {
+ if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
+ if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
+ $app.CloseNotebook($notebookId, $false)
+ }
+ } catch {
+ if ($null -eq $failure) { throw }
+ [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ }
+ } finally {
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ }
+ $app = $null
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+ if (-not $UseCurrentCache -and -not $CloneHost) {
+ Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
+ }
+}
diff --git a/corpus/ink-pressure/cold/source.json b/corpus/ink-pressure/cold/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..4bdc7de5458ed2cc5b15ae501c5051f4007c1b06
--- /dev/null
+++ b/corpus/ink-pressure/cold/source.json
@@ -0,0 +1,14 @@
+[
+ {
+ "path": "Open Notebook.onetoc2",
+ "bytes": 1604,
+ "sha256": "c2ac6e825447b9a3857eb9f2715f1ffbb12e1b0e091882f8b1d22b2edd28c83f",
+ "mtime_ns": 1790756440470950723
+ },
+ {
+ "path": "Pressure.one",
+ "bytes": 27360,
+ "sha256": "84c7e6af5c491b39c26e1f070056464af8cec8123e666f938a9aa066a17a60c7",
+ "mtime_ns": 1790756440470653265
+ }
+]
diff --git a/corpus/ink-pressure/cold/teardown.json b/corpus/ink-pressure/cold/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/ink-pressure/cold/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/corpus/ink-pressure/native/commands.jsonl b/corpus/ink-pressure/native/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..4ddf164813c9044d989b8344f03705bea84a57b5
--- /dev/null
+++ b/corpus/ink-pressure/native/commands.jsonl
@@ -0,0 +1,4 @@
+{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\author.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-29CD6F15", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-29CD6F15 -ExpectedPages -1 -UseCurrentCache -Pdf -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 2 pages.\r\n", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
diff --git a/corpus/ink-pressure/native/machine.json b/corpus/ink-pressure/native/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..12aebf034da03dde568fdbe6f0a75d9b9b8d58d9
--- /dev/null
+++ b/corpus/ink-pressure/native/machine.json
@@ -0,0 +1 @@
+{"name": "m6-29cd6f15", "hostname": "ONE-M6-29CD6F15"}
diff --git a/corpus/ink-pressure/native/notebook/Open Notebook.onetoc2 b/corpus/ink-pressure/native/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..8c9c8c1849e451c5a8a182165747c06346df6ad9
Binary files /dev/null and b/corpus/ink-pressure/native/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/ink-pressure/native/notebook/Pressure.one b/corpus/ink-pressure/native/notebook/Pressure.one
new file mode 100644
index 0000000000000000000000000000000000000000..61f35be5003a948612bc9ce568b319286782cc43
Binary files /dev/null and b/corpus/ink-pressure/native/notebook/Pressure.one differ
diff --git a/corpus/ink-pressure/native/read/environment.json b/corpus/ink-pressure/native/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..839bf5ee389d74892a840275ede8f224162eb527
--- /dev/null
+++ b/corpus/ink-pressure/native/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-29CD6F15",
+ "cold": false,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/ink-pressure/native/read/hierarchy.xml b/corpus/ink-pressure/native/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..d961a41a4433ee8f8e68f8cb99f21b9a102869ea
--- /dev/null
+++ b/corpus/ink-pressure/native/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/ink-pressure/native/read/page-000.navigation.json b/corpus/ink-pressure/native/read/page-000.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..f362538fe065f12f0f1a0a2ef6890d867f25c32c
--- /dev/null
+++ b/corpus/ink-pressure/native/read/page-000.navigation.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Strokes - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/ink-pressure/native/read/page-000.pdf b/corpus/ink-pressure/native/read/page-000.pdf
new file mode 100644
index 0000000000000000000000000000000000000000..3590d49b5587b2d332312b9fb13360ac4048ba97
Binary files /dev/null and b/corpus/ink-pressure/native/read/page-000.pdf differ
diff --git a/corpus/ink-pressure/native/read/page-000.png b/corpus/ink-pressure/native/read/page-000.png
new file mode 100644
index 0000000000000000000000000000000000000000..3f3459a04b7e01b05d484abbc5966145e545759a
Binary files /dev/null and b/corpus/ink-pressure/native/read/page-000.png differ
diff --git a/corpus/ink-pressure/native/read/page-000.xml b/corpus/ink-pressure/native/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..1968183ec2208bf0ff8bfa14eb40cb312406f1bb
--- /dev/null
+++ b/corpus/ink-pressure/native/read/page-000.xml
@@ -0,0 +1,11 @@
+
+AGYdA5AFHAEQKgAaH4tCcEWEdYC+0EXZBAMSSBBF1AEbAgCz/kbUARsCALP+BQM4C2RkAxVGAEAK
+LimCfwEZYsWLFixYsWLFixYAggAAAAAAAIJ+GLLLFlliyxZZYsssWWWAAxVGAEA=AKwBHQOMBVgBECoAGh+LQnBFhHWAvtBF2QQDEEgQRWobAgBZ/0ZqGwIAWf8FAzgLZGQDFUYAQAp2
+KYJ/ARlixYsWLFixYsWLFgCHaEh8fls1ntBoM5mcni8JgkYkUunk8oU6nUqkEQgUPjcrm87n9DnM
+1lMZhcEiQIfifuG1gsRisbkcjlMnk8lj8bicNgsAhESikgkEklEmk0gkEWiUIgUDhsRjsdkcpkoD
+FUYAQA==AGYdA5AFEAEQKgAaH4tCcEWEdYC+0EXZBAMSSBRF1AEbAgCz/kbUARsCALP+BQM4C2RkAxVGAEAK
+LimCfwEZYsWLFixYsWLFixYAggAAAAAAAIJ+GLLLFlliyxZZYsssWWWAAxVGAEA=AJgBHQOQBRgBECoAGh+LQnBFhHWAvtBF2QQDEkgQRdQBGwIAs/5G1AEbAgCz/gUFODs8C2QZGjsL
+0YwB0IwBAwAAyEI8C9GMAdCMAQMAAMhCZAMVRgBACkIpgn8BGWLFixYsWLFixYsWAIIAAAAAAACC
+/gH7+AXQAAlAAASggv6IN/o5wAAAAAAAgv5ZU/llUAAAAAAAAxVGAEA=AEkdA5gDVAEQKgAaH4tCcEWEdYC+0EXZBAMOSBBFahsCAFn/RwFG+wQFAzgLZGQDFUYAQAoVC4J/
+BgyUEIIAAIJ+sJUpKAMVRgBA
+
diff --git a/corpus/ink-pressure/native/read/page-001.navigation.json b/corpus/ink-pressure/native/read/page-001.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..d4959cb4814b6a5c8a0373716bf39f45322e67e2
--- /dev/null
+++ b/corpus/ink-pressure/native/read/page-001.navigation.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Levels - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/ink-pressure/native/read/page-001.pdf b/corpus/ink-pressure/native/read/page-001.pdf
new file mode 100644
index 0000000000000000000000000000000000000000..98021ccd71d4e5cc93f9857526fa0a62b0ff3d9b
Binary files /dev/null and b/corpus/ink-pressure/native/read/page-001.pdf differ
diff --git a/corpus/ink-pressure/native/read/page-001.png b/corpus/ink-pressure/native/read/page-001.png
new file mode 100644
index 0000000000000000000000000000000000000000..2c2c48effc03306b6ccd148d87bbf8c9adf496b4
Binary files /dev/null and b/corpus/ink-pressure/native/read/page-001.png differ
diff --git a/corpus/ink-pressure/native/read/page-001.xml b/corpus/ink-pressure/native/read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..076f215cb2fa1a29df6512f4d8ea2ebfd0709307
--- /dev/null
+++ b/corpus/ink-pressure/native/read/page-001.xml
@@ -0,0 +1,13 @@
+
+AKUCHQSQAsAGARAqABofi0JwRYR1gL7QRdkEAxJIEEXPBhsCALP+Rs8GGwIAs/4FAzgLZGQDFUYA
+QAoPBYJ/BgyUggCCAAMVRgBAChgFgn8GDJSC/iND+I0Qgv3J+5QAAxVGAEAKGQWCfwYMlIL+SvP5
+K9CC/gOT+A5QAxVGAEAKGQWCfwYMlIL+cqP5ypCC/geT+B5QAxVGAEAKGQWCfwYMlIL+mlP6aVCC
+/guT+C5QAxVGAEAKGQWCfwYMlIL+wgP7CBCC/g+L+D4wAxVGAEAKGQWCfwYMlIL+6bP7ptCC/hOL
++E4wAxVGAEAKHAWCfwYMlIL/AAix/gARZACC/heL+F4wAxVGAEAKHAWCfwYMlIL/AByJ/gA5FACC
+/huL+G4wAxVGAEA=AKkCHQSQAsAGARAqABofi0JwRYR1gL7QRdkEAxJIEEXPBhsCALP+Rs8GGwIAs/4FAzgLZBkFOAMA
+/gNkAxVGAEAKDwWCfwYMlIIAggADFUYAQAoYBYJ/BgyUgv4jQ/iNEIL8SfiUAAMVRgBAChgFgn8G
+DJSC/krz+SvQgvzJ+ZQAAxVGAEAKGAWCfwYMlIL+cqP5ypCC/Un6lAADFUYAQAoYBYJ/BgyUgv6a
+U/ppUIL9yfuUAAMVRgBAChkFgn8GDJSC/sID+wgQgv4Ai/gCMAMVRgBAChkFgn8GDJSC/umz+6bQ
+gv4Bi/gGMAMVRgBAChwFgn8GDJSC/wAIsf4AEWQAgv4Ci/gKMAMVRgBAChwFgn8GDJSC/wAcif4A
+ORQAgv4Di/gOMAMVRgBA
+
diff --git a/corpus/ink-pressure/native/read/payloads.json b/corpus/ink-pressure/native/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843
--- /dev/null
+++ b/corpus/ink-pressure/native/read/payloads.json
@@ -0,0 +1,3 @@
+[
+
+]
\ No newline at end of file
diff --git a/corpus/ink-pressure/native/run.json b/corpus/ink-pressure/native/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..8b89ed7d98344779b0ecdccc7d251bb74b58767c
--- /dev/null
+++ b/corpus/ink-pressure/native/run.json
@@ -0,0 +1,19 @@
+{
+ "notebook": "/private/tmp/snowbound-pressure/empty-notebook",
+ "expected_pages": -1,
+ "author": "tools/native/ink-pressure.ps1",
+ "author_timeout_seconds": 600,
+ "inspect": false,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "author.ps1": "bff822c61e6773daba010d89000dee2326282259161a252b65df2ce8acf275c9",
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
+ }
+}
diff --git a/corpus/ink-pressure/native/scripts/author.ps1 b/corpus/ink-pressure/native/scripts/author.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..7aabbc30a20e31e2b732cdbf9031fe367d5ac2b4
--- /dev/null
+++ b/corpus/ink-pressure/native/scripts/author.ps1
@@ -0,0 +1,108 @@
+param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost)
+# Pressure ink as OneNote 2010 keeps it: WPF writes strokes with NormalPressure (and one with
+# tilt) as ISF, and OneNote takes each through UpdatePageContent into a section of its own.
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost
+Add-Type -AssemblyName PresentationCore, WindowsBase
+$P = [System.Windows.Input.StylusPointProperties]
+$U = [System.Windows.Input.StylusPointPropertyUnit]
+
+function Info($property, $min, $max, $unit, $resolution) {
+ if ($null -eq $min) { return New-Object System.Windows.Input.StylusPointPropertyInfo($property) }
+ New-Object System.Windows.Input.StylusPointPropertyInfo($property, $min, $max, $unit, $resolution)
+}
+
+# A stroke through `points` of [x, y, pressure 0..1, extra values...] in WPF units (1/96 inch).
+function Stroke($points, $width, $height, [switch]$Ignore, [switch]$Rectangle, $infos) {
+ if (-not $infos) { $infos = @((Info $P::X), (Info $P::Y), (Info $P::NormalPressure)) }
+ $description = New-Object System.Windows.Input.StylusPointDescription(,[System.Windows.Input.StylusPointPropertyInfo[]]$infos)
+ $pressure = $infos[2]
+ $collection = New-Object System.Windows.Input.StylusPointCollection(,$description)
+ foreach ($p in $points) {
+ $extra = [int[]]@($p | Select-Object -Skip 3)
+ $collection.Add((New-Object System.Windows.Input.StylusPoint($p[0], $p[1], [single]0.5, $description, $extra)))
+ $point = $collection[$collection.Count - 1]
+ $point.SetPropertyValue([System.Windows.Input.StylusPointProperties]::NormalPressure,
+ [int][Math]::Round($pressure.Minimum + $p[2] * ($pressure.Maximum - $pressure.Minimum)))
+ $collection[$collection.Count - 1] = $point
+ }
+ $stroke = New-Object System.Windows.Ink.Stroke -ArgumentList (,$collection)
+ $stroke.DrawingAttributes.Width = $width
+ $stroke.DrawingAttributes.Height = $height
+ $stroke.DrawingAttributes.IgnorePressure = [bool]$Ignore
+ $stroke.DrawingAttributes.FitToCurve = $false
+ if ($Rectangle) { $stroke.DrawingAttributes.StylusTip = [System.Windows.Ink.StylusTip]::Rectangle }
+ $stroke
+}
+
+function Isf($strokes) {
+ $collection = New-Object System.Windows.Ink.StrokeCollection
+ foreach ($stroke in $strokes) { $collection.Add([System.Windows.Ink.Stroke]$stroke) }
+ $stream = New-Object IO.MemoryStream
+ $collection.Save($stream, $true)
+ [Convert]::ToBase64String($stream.ToArray())
+}
+
+function Line($x, $y, $pressure, $count, $step) {
+ $points = @()
+ for ($i = 0; $i -lt $count; $i++) { $points += ,@(($x + $i * $step), $y, $pressure) }
+ ,$points
+}
+
+# "Levels": one drawing of nine 24-point strokes at pressures 0 to 1 in eighths, and one of
+# the same levels on a pen reporting 0..255.
+$levels = @(); $coarse = @()
+$narrow = @((Info $P::X), (Info $P::Y), (Info $P::NormalPressure 0 255 $U::None 1))
+for ($i = 0; $i -le 8; $i++) {
+ $levels += Stroke (Line 0 ($i * 48) ($i / 8) 5 20) 32 32
+ $coarse += Stroke (Line 0 ($i * 48) ($i / 8) 5 20) 32 32 -infos $narrow
+}
+# "Strokes": a rising ramp, a wave, a pen ignoring the pressure it recorded, a stroke with
+# tilt, and a rectangular tip, each a drawing of its own.
+$ramp = @(); $wave = @(); $flat = @(); $tilt = @(); $block = @()
+for ($i = 0; $i -le 40; $i++) {
+ $ramp += ,@(($i * 8), 0, ($i / 40))
+ $wave += ,@(($i * 8), (20 * [Math]::Sin($i / 4)), (0.5 + 0.5 * [Math]::Sin($i / 5)))
+ $flat += ,@(($i * 8), 0, ($i / 40))
+ $tilt += ,@(($i * 8), 0, (0.2 + $i / 60), (-4500 + $i * 200), 3000)
+}
+for ($i = 0; $i -le 10; $i++) { $block += ,@(($i * 20), 0, ($i / 10)) }
+$tilted = @((Info $P::X), (Info $P::Y), (Info $P::NormalPressure),
+ (Info $P::XTiltOrientation -9000 9000 $U::Degrees 100), (Info $P::YTiltOrientation -9000 9000 $U::Degrees 100))
+$pages = [ordered]@{
+ 'Levels' = @(@(36, 90, (Isf $levels)), @(252, 90, (Isf $coarse)))
+ 'Strokes' = @(
+ @(36, 90, (Isf @(Stroke $ramp 8 8))),
+ @(36, 150, (Isf @(Stroke $wave 4 4))),
+ @(36, 220, (Isf @(Stroke $flat 8 8 -Ignore))),
+ @(36, 270, (Isf @(Stroke $tilt 8 8 -infos $tilted))),
+ @(36, 320, (Isf @(Stroke $block 4 24 -Rectangle))))
+}
+
+$namespace = 'http://schemas.microsoft.com/office/onenote/2010/onenote'
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+try {
+ $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0)
+ $sectionId = ''
+ $app.OpenHierarchy('Pressure.one', $notebookId, [ref]$sectionId, 3)
+ foreach ($name in $pages.Keys) {
+ $pageId = ''
+ $app.CreateNewPage($sectionId, [ref]$pageId, 0)
+ $drawings = ($pages[$name] | ForEach-Object {
+ "$($_[2])"
+ }) -join ''
+ $xml = "$drawings"
+ [IO.File]::WriteAllText((Join-Path $Root "author-$name.xml"), $xml, [Text.Encoding]::UTF8)
+ $app.UpdatePageContent($xml, [DateTime]::MinValue, 1, $false)
+ }
+ $app.SyncHierarchy($notebookId)
+ $app.CloseNotebook($notebookId, $false)
+ $notebookId = ''
+} finally {
+ if ($notebookId) { $app.CloseNotebook($notebookId, $false) }
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+}
diff --git a/corpus/ink-pressure/native/scripts/cold.ps1 b/corpus/ink-pressure/native/scripts/cold.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f
--- /dev/null
+++ b/corpus/ink-pressure/native/scripts/cold.ps1
@@ -0,0 +1,27 @@
+param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
+$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
+if ($CloneHost) {
+ if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
+ throw 'The disposable clone hostname does not match this machine.'
+ }
+ New-Item "$key\Options\Paths" -Force | Out-Null
+ New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
+} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
+ -not (Test-Path 'C:\one-tests\profile-original-cache')) {
+ throw 'Park the personal OneNote profile before resetting the test cache.'
+}
+$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
+$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
+if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
+New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
+if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
+if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
+New-Item "$key\OpenNotebooks" | Out-Null
+New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
diff --git a/corpus/ink-pressure/native/scripts/read.ps1 b/corpus/ink-pressure/native/scripts/read.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a42366b32e8baaf13dae92e9472cae7a071220cc
--- /dev/null
+++ b/corpus/ink-pressure/native/scripts/read.ps1
@@ -0,0 +1,144 @@
+param(
+ [Parameter(Mandatory=$true)][string]$Root,
+ [int]$ExpectedPages = -1,
+ [switch]$UseCurrentCache,
+ [switch]$Pdf,
+ [switch]$KeepOpen,
+ [string]$CloneHost = ''
+)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+$notebook = Join-Path $root 'notebook'
+$output = Join-Path $root 'read'
+if (Test-Path $output) { throw 'Choose a new read destination.' }
+if ($UseCurrentCache) {
+ if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
+ throw 'Park the personal OneNote profile before reading test notebooks.'
+ }
+} else {
+ & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
+}
+New-Item -ItemType Directory -Path $output | Out-Null
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+$failure = $null
+try {
+ $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
+ $process = Get-Process ONENOTE
+ @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
+ powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
+ ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
+ $sections = @()
+ foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
+ $id = ''
+ $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
+ $sections += $id
+ }
+ $deadline = [DateTime]::UtcNow.AddSeconds(300)
+ $previous = ''
+ $lastChange = ''
+ $stableSince = [DateTime]::UtcNow
+ $settled = $false
+ do {
+ $pages = @{}
+ foreach ($section in $sections) {
+ $hierarchy = ''
+ $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
+ [xml]$xml = $hierarchy
+ foreach ($node in $xml.SelectNodes('//*[@path]')) {
+ if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'OneNote opened a section outside the copied notebook.'
+ }
+ }
+ foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
+ $id = $node.GetAttribute('ID')
+ $content = ''
+ $app.GetPageContent($id, [ref]$content, 1, 1)
+ $pages[$id] = $content
+ }
+ }
+ $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
+ if ($signature -ne $previous) {
+ $lastChange = $previous
+ $previous = $signature
+ $stableSince = [DateTime]::UtcNow
+ }
+ if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
+ ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
+ ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
+ Start-Sleep -Milliseconds 250
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
+ [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
+ $index = 0
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ $index++
+ }
+ $hierarchy = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
+ [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
+ throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
+ }
+ $index = 0
+ $payloads = @()
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ if ($Pdf) {
+ $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
+ $app.NavigateTo($id, '', $false)
+ $app.Publish($id, $pdfPath, 3, '')
+ if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
+ }
+ [xml]$page = $pages[$id]
+ foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
+ $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
+ $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
+ [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
+ # A paragraph's file is named by its paragraph, one on the page by itself.
+ $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
+ $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
+ kind = $file.LocalName; name = $file.GetAttribute('preferredName');
+ sha256 = $hash; bytes = $bytes.Length }
+ }
+ $index++
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
+ $all = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
+ [xml]$finalTree = $all
+ $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
+ if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
+ throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
+ Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
+} catch {
+ $failure = $_
+ [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ throw
+} finally {
+ try {
+ try {
+ if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
+ if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
+ $app.CloseNotebook($notebookId, $false)
+ }
+ } catch {
+ if ($null -eq $failure) { throw }
+ [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ }
+ } finally {
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ }
+ $app = $null
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+ if (-not $UseCurrentCache -and -not $CloneHost) {
+ Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
+ }
+}
diff --git a/corpus/ink-pressure/native/source.json b/corpus/ink-pressure/native/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..fe51488c7066f6687ef680d6bfaa4f7768ef205c
--- /dev/null
+++ b/corpus/ink-pressure/native/source.json
@@ -0,0 +1 @@
+[]
diff --git a/corpus/ink-pressure/native/teardown.json b/corpus/ink-pressure/native/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/ink-pressure/native/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/corpus/object-outline/README.md b/corpus/object-outline/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..4522c254ee4f9654bad55e5f4f174d203ea3721d
--- /dev/null
+++ b/corpus/object-outline/README.md
@@ -0,0 +1,17 @@
+# Typing beside a picture or file alone in its outline
+
+`native/` is OneNote 2010 authoring `tools/native/pages.ps1` input: two pages in
+`Alone.one`, one an outline holding only a text file, the other only a picture.
+
+`keys/` is `tools/native_object_outline.py` clicking beside each object inside its
+outline in OneNote 2010 and typing "abc": OneNote adds a paragraph after the object
+holding the text, and undoing removes that paragraph again.
+
+`candidate/` is the output of `typing_beside_an_outlines_only_object_adds_a_paragraph`
+in `crates/canvas/tests/object_outlines.rs`: the editor shows each outline with a
+paragraph after its object that is stored only once typed in, and the typing, its undo
+and its redo are published as revisions; `cold/` is a fresh OneNote 2010 read of it,
+whose pages read as `keys/` does. `tools/test_object_outline.py` checks this without a
+VM. Regenerate with `SNOWBOUND_OBJECT_OUTLINE_EXPORT` set to a new absolute directory
+while running the test, then cold-open it with
+`tools/native_runner.py OUTPUT COLD --expected-pages 2 --collect-notebook --screenshots`.
diff --git a/corpus/object-outline/candidate/Alone.one b/corpus/object-outline/candidate/Alone.one
new file mode 100644
index 0000000000000000000000000000000000000000..4aa42e68b535934ea8be212d109730c377d651e1
Binary files /dev/null and b/corpus/object-outline/candidate/Alone.one differ
diff --git a/corpus/object-outline/candidate/Open Notebook.onetoc2 b/corpus/object-outline/candidate/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..f0716c8f06f2b897b6c4d509045c8bed120d5ddd
Binary files /dev/null and b/corpus/object-outline/candidate/Open Notebook.onetoc2 differ
diff --git a/corpus/object-outline/cold/commands.jsonl b/corpus/object-outline/cold/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..5c08072dc0659dac911231068d67ae1834ca09e0
--- /dev/null
+++ b/corpus/object-outline/cold/commands.jsonl
@@ -0,0 +1,3 @@
+{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-9223BC24 -ExpectedPages 2 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 2 pages.\r\n", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
diff --git a/corpus/object-outline/cold/machine.json b/corpus/object-outline/cold/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..d0fbbde039bb643be8788312ff6e49d5763277f2
--- /dev/null
+++ b/corpus/object-outline/cold/machine.json
@@ -0,0 +1 @@
+{"name": "m6-9223bc24", "hostname": "ONE-M6-9223BC24"}
diff --git a/corpus/object-outline/cold/notebook/Alone.one b/corpus/object-outline/cold/notebook/Alone.one
new file mode 100644
index 0000000000000000000000000000000000000000..54a4ffdad5720019849dfc1e6cf126979785a822
Binary files /dev/null and b/corpus/object-outline/cold/notebook/Alone.one differ
diff --git a/corpus/object-outline/cold/notebook/Open Notebook.onetoc2 b/corpus/object-outline/cold/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..6d009857fe5a4767d950bfc96b8dde5347b4677c
Binary files /dev/null and b/corpus/object-outline/cold/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/object-outline/cold/read/93d5f94f89aaa8a154fd3f4b8ee770706ee075bda149fb5b68d4038ab5488259.attachment b/corpus/object-outline/cold/read/93d5f94f89aaa8a154fd3f4b8ee770706ee075bda149fb5b68d4038ab5488259.attachment
new file mode 100644
index 0000000000000000000000000000000000000000..249e2b8dd53ba539efce08db14a4fc4a420f2b73
--- /dev/null
+++ b/corpus/object-outline/cold/read/93d5f94f89aaa8a154fd3f4b8ee770706ee075bda149fb5b68d4038ab5488259.attachment
@@ -0,0 +1 @@
+Plain text attachment
diff --git a/corpus/object-outline/cold/read/environment.json b/corpus/object-outline/cold/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..cdcd7157cb46e0d1955bd172295b161373a9f269
--- /dev/null
+++ b/corpus/object-outline/cold/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-9223BC24",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/object-outline/cold/read/hierarchy.xml b/corpus/object-outline/cold/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..4e44dace2ec57e4853be7255d588dbe87cf747a1
--- /dev/null
+++ b/corpus/object-outline/cold/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/object-outline/cold/read/page-000.navigation.json b/corpus/object-outline/cold/read/page-000.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..ed3fd2785575612f4661f5cdc5e5a8f5201a9e93
--- /dev/null
+++ b/corpus/object-outline/cold/read/page-000.navigation.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "File alone - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/object-outline/cold/read/page-000.png b/corpus/object-outline/cold/read/page-000.png
new file mode 100644
index 0000000000000000000000000000000000000000..ea9aa6560b9aa50f35adebaf14a78bc0ceca7558
Binary files /dev/null and b/corpus/object-outline/cold/read/page-000.png differ
diff --git a/corpus/object-outline/cold/read/page-000.xml b/corpus/object-outline/cold/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..b1f71296031fcbad203b1b54c63f1c35d6aacd39
--- /dev/null
+++ b/corpus/object-outline/cold/read/page-000.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/object-outline/cold/read/page-001.navigation.json b/corpus/object-outline/cold/read/page-001.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..aca8aeba4ad53dbc8917f48caa5943126d869f67
--- /dev/null
+++ b/corpus/object-outline/cold/read/page-001.navigation.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Picture alone - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/object-outline/cold/read/page-001.png b/corpus/object-outline/cold/read/page-001.png
new file mode 100644
index 0000000000000000000000000000000000000000..487311013f085c17d36f3d925bad161b3158c07e
Binary files /dev/null and b/corpus/object-outline/cold/read/page-001.png differ
diff --git a/corpus/object-outline/cold/read/page-001.xml b/corpus/object-outline/cold/read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..aa99cc94f3cdf65fe2cf6b5650868a5f8e750517
--- /dev/null
+++ b/corpus/object-outline/cold/read/page-001.xml
@@ -0,0 +1,5 @@
+
+iVBORw0KGgoAAAANSUhEUgAAAEAAAAAwCAIAAAAuKetIAAAAj0lEQVR4nO3ZsQkDQQwF0bnhit7A
+oYMrx0W4DtfiAswaDgd7A37pRyAJZdoezxdlEidxEidxEidxErfPgjFuXMxx3E8MMCtYZUwWmj8h
+iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4idu/ZBd8EXza/o/uxSRO
+4iRO4lzdwK/eahkMdcPZIWgAAAAASUVORK5CYII=
diff --git a/corpus/object-outline/cold/read/payloads.json b/corpus/object-outline/cold/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..e65c7d119cf0a1dca1b6ea68a6c189ad0ceae60d
--- /dev/null
+++ b/corpus/object-outline/cold/read/payloads.json
@@ -0,0 +1,10 @@
+[
+ {
+ "sha256": "93d5f94f89aaa8a154fd3f4b8ee770706ee075bda149fb5b68d4038ab5488259",
+ "name": "notes.txt",
+ "object": "{0667B952-4A41-0044-27AE-6ABA17C71F9C}{24}{B0}",
+ "kind": "InsertedFile",
+ "page": "{066E2411-379B-069C-05F5-B4C764463F16}{1}{B0}",
+ "bytes": 23
+ }
+]
\ No newline at end of file
diff --git a/corpus/object-outline/cold/run.json b/corpus/object-outline/cold/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..d3382dfe997c67a7b1b4c09af2abaf62e69d509d
--- /dev/null
+++ b/corpus/object-outline/cold/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/private/tmp/unsup-lab/alone-candidate",
+ "expected_pages": 2,
+ "author": null,
+ "author_timeout_seconds": 600,
+ "inspect": false,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
+ }
+}
diff --git a/corpus/object-outline/cold/scripts/cold.ps1 b/corpus/object-outline/cold/scripts/cold.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f
--- /dev/null
+++ b/corpus/object-outline/cold/scripts/cold.ps1
@@ -0,0 +1,27 @@
+param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
+$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
+if ($CloneHost) {
+ if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
+ throw 'The disposable clone hostname does not match this machine.'
+ }
+ New-Item "$key\Options\Paths" -Force | Out-Null
+ New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
+} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
+ -not (Test-Path 'C:\one-tests\profile-original-cache')) {
+ throw 'Park the personal OneNote profile before resetting the test cache.'
+}
+$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
+$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
+if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
+New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
+if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
+if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
+New-Item "$key\OpenNotebooks" | Out-Null
+New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
diff --git a/corpus/object-outline/cold/scripts/read.ps1 b/corpus/object-outline/cold/scripts/read.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a42366b32e8baaf13dae92e9472cae7a071220cc
--- /dev/null
+++ b/corpus/object-outline/cold/scripts/read.ps1
@@ -0,0 +1,144 @@
+param(
+ [Parameter(Mandatory=$true)][string]$Root,
+ [int]$ExpectedPages = -1,
+ [switch]$UseCurrentCache,
+ [switch]$Pdf,
+ [switch]$KeepOpen,
+ [string]$CloneHost = ''
+)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+$notebook = Join-Path $root 'notebook'
+$output = Join-Path $root 'read'
+if (Test-Path $output) { throw 'Choose a new read destination.' }
+if ($UseCurrentCache) {
+ if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
+ throw 'Park the personal OneNote profile before reading test notebooks.'
+ }
+} else {
+ & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
+}
+New-Item -ItemType Directory -Path $output | Out-Null
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+$failure = $null
+try {
+ $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
+ $process = Get-Process ONENOTE
+ @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
+ powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
+ ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
+ $sections = @()
+ foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
+ $id = ''
+ $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
+ $sections += $id
+ }
+ $deadline = [DateTime]::UtcNow.AddSeconds(300)
+ $previous = ''
+ $lastChange = ''
+ $stableSince = [DateTime]::UtcNow
+ $settled = $false
+ do {
+ $pages = @{}
+ foreach ($section in $sections) {
+ $hierarchy = ''
+ $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
+ [xml]$xml = $hierarchy
+ foreach ($node in $xml.SelectNodes('//*[@path]')) {
+ if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'OneNote opened a section outside the copied notebook.'
+ }
+ }
+ foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
+ $id = $node.GetAttribute('ID')
+ $content = ''
+ $app.GetPageContent($id, [ref]$content, 1, 1)
+ $pages[$id] = $content
+ }
+ }
+ $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
+ if ($signature -ne $previous) {
+ $lastChange = $previous
+ $previous = $signature
+ $stableSince = [DateTime]::UtcNow
+ }
+ if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
+ ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
+ ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
+ Start-Sleep -Milliseconds 250
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
+ [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
+ $index = 0
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ $index++
+ }
+ $hierarchy = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
+ [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
+ throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
+ }
+ $index = 0
+ $payloads = @()
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ if ($Pdf) {
+ $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
+ $app.NavigateTo($id, '', $false)
+ $app.Publish($id, $pdfPath, 3, '')
+ if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
+ }
+ [xml]$page = $pages[$id]
+ foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
+ $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
+ $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
+ [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
+ # A paragraph's file is named by its paragraph, one on the page by itself.
+ $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
+ $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
+ kind = $file.LocalName; name = $file.GetAttribute('preferredName');
+ sha256 = $hash; bytes = $bytes.Length }
+ }
+ $index++
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
+ $all = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
+ [xml]$finalTree = $all
+ $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
+ if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
+ throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
+ Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
+} catch {
+ $failure = $_
+ [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ throw
+} finally {
+ try {
+ try {
+ if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
+ if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
+ $app.CloseNotebook($notebookId, $false)
+ }
+ } catch {
+ if ($null -eq $failure) { throw }
+ [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ }
+ } finally {
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ }
+ $app = $null
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+ if (-not $UseCurrentCache -and -not $CloneHost) {
+ Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
+ }
+}
diff --git a/corpus/object-outline/cold/source.json b/corpus/object-outline/cold/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..8a80cb36fdff9e59a3da9a9fcd12a2fadbeb3436
--- /dev/null
+++ b/corpus/object-outline/cold/source.json
@@ -0,0 +1,14 @@
+[
+ {
+ "path": "Alone.one",
+ "bytes": 23784,
+ "sha256": "640df8294a0321ab33d8bea926027385c7e741ca6614190dccdbd9d094f8b499",
+ "mtime_ns": 1790745185101431771
+ },
+ {
+ "path": "Open Notebook.onetoc2",
+ "bytes": 1604,
+ "sha256": "25817d6bf13a331f77563bac871208b831b5b2d7b6f9a4f3e916cc672dc8617c",
+ "mtime_ns": 1790745185101625391
+ }
+]
diff --git a/corpus/object-outline/cold/teardown.json b/corpus/object-outline/cold/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/object-outline/cold/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/corpus/object-outline/keys/before-read/93d5f94f89aaa8a154fd3f4b8ee770706ee075bda149fb5b68d4038ab5488259.attachment b/corpus/object-outline/keys/before-read/93d5f94f89aaa8a154fd3f4b8ee770706ee075bda149fb5b68d4038ab5488259.attachment
new file mode 100644
index 0000000000000000000000000000000000000000..249e2b8dd53ba539efce08db14a4fc4a420f2b73
--- /dev/null
+++ b/corpus/object-outline/keys/before-read/93d5f94f89aaa8a154fd3f4b8ee770706ee075bda149fb5b68d4038ab5488259.attachment
@@ -0,0 +1 @@
+Plain text attachment
diff --git a/corpus/object-outline/keys/before-read/environment.json b/corpus/object-outline/keys/before-read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..07a1d30abfedb1d7e9da2f1c5eb65613f08d3649
--- /dev/null
+++ b/corpus/object-outline/keys/before-read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-2BB9C6C4",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/object-outline/keys/before-read/hierarchy.xml b/corpus/object-outline/keys/before-read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..6b1857878b6245dc11352492f51f25331f52f38d
--- /dev/null
+++ b/corpus/object-outline/keys/before-read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/object-outline/keys/before-read/page-000.xml b/corpus/object-outline/keys/before-read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..fce0eeb90bc5df3a98b61fe99b40f949b0c6e273
--- /dev/null
+++ b/corpus/object-outline/keys/before-read/page-000.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/object-outline/keys/before-read/page-001.xml b/corpus/object-outline/keys/before-read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..f4e9e23104b9bbd0f6c1fc15bf4f983f975445c9
--- /dev/null
+++ b/corpus/object-outline/keys/before-read/page-001.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/object-outline/keys/before-read/page-002.xml b/corpus/object-outline/keys/before-read/page-002.xml
new file mode 100644
index 0000000000000000000000000000000000000000..131fb7750ba21b8414ca123ecc5a582c0dbfc0cd
--- /dev/null
+++ b/corpus/object-outline/keys/before-read/page-002.xml
@@ -0,0 +1,5 @@
+
+iVBORw0KGgoAAAANSUhEUgAAAEAAAAAwCAIAAAAuKetIAAAAj0lEQVR4nO3ZsQkDQQwF0bnhit7A
+oYMrx0W4DtfiAswaDgd7A37pRyAJZdoezxdlEidxEidxEidxErfPgjFuXMxx3E8MMCtYZUwWmj8h
+iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4idu/ZBd8EXza/o/uxSRO
+4iRO4lzdwK/eahkMdcPZIWgAAAAASUVORK5CYII=
diff --git a/corpus/object-outline/keys/before-read/payloads.json b/corpus/object-outline/keys/before-read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..b0e3fe2308903c84baf0421514b818c859546d38
--- /dev/null
+++ b/corpus/object-outline/keys/before-read/payloads.json
@@ -0,0 +1,10 @@
+[
+ {
+ "sha256": "93d5f94f89aaa8a154fd3f4b8ee770706ee075bda149fb5b68d4038ab5488259",
+ "name": "notes.txt",
+ "object": "{61BCCC54-7CAF-01FE-29AB-CBA3BD28084B}{24}{B0}",
+ "kind": "InsertedFile",
+ "page": "{61B55117-0175-0726-0BF0-15DECEA928C1}{1}{B0}",
+ "bytes": 23
+ }
+]
\ No newline at end of file
diff --git a/corpus/object-outline/keys/commands.jsonl b/corpus/object-outline/keys/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..4cb34f7ec1deea36028d6dcaf16cbca35f17209a
--- /dev/null
+++ b/corpus/object-outline/keys/commands.jsonl
@@ -0,0 +1,6 @@
+{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-2BB9C6C4 -ExpectedPages -1 -KeepOpen", "exit": 0, "stdout": "Read 2 sections and 3 pages.\r\n", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -Command \"Rename-Item C:\\one-tests\\runs\\capture\\read before-read\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-2BB9C6C4 -UseCurrentCache -ExpectedPages -1", "exit": 0, "stdout": "Read 2 sections and 3 pages.\r\n", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
diff --git a/corpus/object-outline/keys/file-alone.ahk b/corpus/object-outline/keys/file-alone.ahk
new file mode 100644
index 0000000000000000000000000000000000000000..08c2f1802712ab90f3f34a2b69d998413835488f
--- /dev/null
+++ b/corpus/object-outline/keys/file-alone.ahk
@@ -0,0 +1,13 @@
+ComObject("OneNote.Application").NavigateTo("{61B55117-0175-0726-0BF0-15DECEA928C1}{1}{B0}", "", false)
+WinWait("ahk_exe ONENOTE.EXE", , 30)
+WinActivate("ahk_exe ONENOTE.EXE")
+WinWaitActive("ahk_exe ONENOTE.EXE", , 30)
+WinMaximize("ahk_exe ONENOTE.EXE")
+Sleep(3000)
+CoordMode("Mouse", "Screen")
+MouseMove(190, 260)
+Sleep(800)
+Click(236, 301)
+Sleep(500)
+SendText("abc")
+Sleep(2000)
diff --git a/corpus/object-outline/keys/file-alone.json b/corpus/object-outline/keys/file-alone.json
new file mode 100644
index 0000000000000000000000000000000000000000..ed3fd2785575612f4661f5cdc5e5a8f5201a9e93
--- /dev/null
+++ b/corpus/object-outline/keys/file-alone.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "File alone - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/object-outline/keys/file-alone.png b/corpus/object-outline/keys/file-alone.png
new file mode 100644
index 0000000000000000000000000000000000000000..befa61dd11f400e983c4b3ffc3664dfb6a11965c
Binary files /dev/null and b/corpus/object-outline/keys/file-alone.png differ
diff --git a/corpus/object-outline/keys/machine.json b/corpus/object-outline/keys/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..81a065c4392d33fafcba8a73eb0ff23a4f685699
--- /dev/null
+++ b/corpus/object-outline/keys/machine.json
@@ -0,0 +1 @@
+{"name": "m6-2bb9c6c4", "hostname": "ONE-M6-2BB9C6C4"}
diff --git a/corpus/object-outline/keys/notebook/Alone.one b/corpus/object-outline/keys/notebook/Alone.one
new file mode 100644
index 0000000000000000000000000000000000000000..0825e6c86a5634d214819c438943015aa27b60e6
Binary files /dev/null and b/corpus/object-outline/keys/notebook/Alone.one differ
diff --git a/corpus/object-outline/keys/notebook/Open Notebook.onetoc2 b/corpus/object-outline/keys/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..bdbffbaec4fbd2ff9bbbf826863008fefb549a6d
Binary files /dev/null and b/corpus/object-outline/keys/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/object-outline/keys/notebook/fixture-data/Open Notebook.onetoc2 b/corpus/object-outline/keys/notebook/fixture-data/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..faf1cb4a07d510a73f8dc12a13a8490bc834f3c2
Binary files /dev/null and b/corpus/object-outline/keys/notebook/fixture-data/Open Notebook.onetoc2 differ
diff --git a/corpus/object-outline/keys/notebook/fixture-data/notes.txt b/corpus/object-outline/keys/notebook/fixture-data/notes.txt
new file mode 100644
index 0000000000000000000000000000000000000000..249e2b8dd53ba539efce08db14a4fc4a420f2b73
--- /dev/null
+++ b/corpus/object-outline/keys/notebook/fixture-data/notes.txt
@@ -0,0 +1 @@
+Plain text attachment
diff --git a/corpus/object-outline/keys/notebook/fixture.json b/corpus/object-outline/keys/notebook/fixture.json
new file mode 100644
index 0000000000000000000000000000000000000000..4a481c470291288052a5264feedebf411dfb3943
--- /dev/null
+++ b/corpus/object-outline/keys/notebook/fixture.json
@@ -0,0 +1,15 @@
+{
+ "sections": [
+ "Alone.one"
+ ],
+ "pages": [
+ {
+ "section": "Alone.one",
+ "xml": ""
+ },
+ {
+ "section": "Alone.one",
+ "xml": "iVBORw0KGgoAAAANSUhEUgAAAEAAAAAwCAIAAAAuKetIAAAAj0lEQVR4nO3ZsQkDQQwF0bnhit7AoYMrx0W4DtfiAswaDgd7A37pRyAJZdoezxdlEidxEidxEidxErfPgjFuXMxx3E8MMCtYZUwWmj8hiZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4idu/ZBd8EXza/o/uxSRO4iRO4lzdwK/eahkMdcPZIWgAAAAASUVORK5CYII="
+ }
+ ]
+}
\ No newline at end of file
diff --git a/corpus/object-outline/keys/notebook/synthetic.one b/corpus/object-outline/keys/notebook/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..4c641f1d43053dd5122b8fd944d4da15c51bf56a
Binary files /dev/null and b/corpus/object-outline/keys/notebook/synthetic.one differ
diff --git a/corpus/object-outline/keys/picture-alone.ahk b/corpus/object-outline/keys/picture-alone.ahk
new file mode 100644
index 0000000000000000000000000000000000000000..c2c19dbf01448e96fcf4c1550e7ec09a140e91ea
--- /dev/null
+++ b/corpus/object-outline/keys/picture-alone.ahk
@@ -0,0 +1,13 @@
+ComObject("OneNote.Application").NavigateTo("{A57769C4-804E-0B9F-0DD2-A7A457B448E4}{1}{B0}", "", false)
+WinWait("ahk_exe ONENOTE.EXE", , 30)
+WinActivate("ahk_exe ONENOTE.EXE")
+WinWaitActive("ahk_exe ONENOTE.EXE", , 30)
+WinMaximize("ahk_exe ONENOTE.EXE")
+Sleep(3000)
+CoordMode("Mouse", "Screen")
+MouseMove(190, 260)
+Sleep(800)
+Click(238, 282)
+Sleep(500)
+SendText("abc")
+Sleep(2000)
diff --git a/corpus/object-outline/keys/picture-alone.json b/corpus/object-outline/keys/picture-alone.json
new file mode 100644
index 0000000000000000000000000000000000000000..aca8aeba4ad53dbc8917f48caa5943126d869f67
--- /dev/null
+++ b/corpus/object-outline/keys/picture-alone.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Picture alone - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/object-outline/keys/picture-alone.png b/corpus/object-outline/keys/picture-alone.png
new file mode 100644
index 0000000000000000000000000000000000000000..0b19e89d8e12a740899f4060908df384352728b1
Binary files /dev/null and b/corpus/object-outline/keys/picture-alone.png differ
diff --git a/corpus/object-outline/keys/read/93d5f94f89aaa8a154fd3f4b8ee770706ee075bda149fb5b68d4038ab5488259.attachment b/corpus/object-outline/keys/read/93d5f94f89aaa8a154fd3f4b8ee770706ee075bda149fb5b68d4038ab5488259.attachment
new file mode 100644
index 0000000000000000000000000000000000000000..249e2b8dd53ba539efce08db14a4fc4a420f2b73
--- /dev/null
+++ b/corpus/object-outline/keys/read/93d5f94f89aaa8a154fd3f4b8ee770706ee075bda149fb5b68d4038ab5488259.attachment
@@ -0,0 +1 @@
+Plain text attachment
diff --git a/corpus/object-outline/keys/read/environment.json b/corpus/object-outline/keys/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..8936a097e4654cdf89f1171959397cf6a41e6ef8
--- /dev/null
+++ b/corpus/object-outline/keys/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-2BB9C6C4",
+ "cold": false,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/object-outline/keys/read/hierarchy.xml b/corpus/object-outline/keys/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..1676e03e26b3528934cf4f54e56aa4bf42e77744
--- /dev/null
+++ b/corpus/object-outline/keys/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/object-outline/keys/read/page-000.xml b/corpus/object-outline/keys/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..1156989697848cc7fd8f227a71a627f8211eacf4
--- /dev/null
+++ b/corpus/object-outline/keys/read/page-000.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/object-outline/keys/read/page-001.xml b/corpus/object-outline/keys/read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..a9ba777420b03e6936c773e1b6f0f1958cecc924
--- /dev/null
+++ b/corpus/object-outline/keys/read/page-001.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/object-outline/keys/read/page-002.xml b/corpus/object-outline/keys/read/page-002.xml
new file mode 100644
index 0000000000000000000000000000000000000000..9d7b509b1ce562aecddf07dfaf1c1d2f00a44aa3
--- /dev/null
+++ b/corpus/object-outline/keys/read/page-002.xml
@@ -0,0 +1,5 @@
+
+iVBORw0KGgoAAAANSUhEUgAAAEAAAAAwCAIAAAAuKetIAAAAj0lEQVR4nO3ZsQkDQQwF0bnhit7A
+oYMrx0W4DtfiAswaDgd7A37pRyAJZdoezxdlEidxEidxEidxErfPgjFuXMxx3E8MMCtYZUwWmj8h
+iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4idu/ZBd8EXza/o/uxSRO
+4iRO4lzdwK/eahkMdcPZIWgAAAAASUVORK5CYII=
diff --git a/corpus/object-outline/keys/read/payloads.json b/corpus/object-outline/keys/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..b0e3fe2308903c84baf0421514b818c859546d38
--- /dev/null
+++ b/corpus/object-outline/keys/read/payloads.json
@@ -0,0 +1,10 @@
+[
+ {
+ "sha256": "93d5f94f89aaa8a154fd3f4b8ee770706ee075bda149fb5b68d4038ab5488259",
+ "name": "notes.txt",
+ "object": "{61BCCC54-7CAF-01FE-29AB-CBA3BD28084B}{24}{B0}",
+ "kind": "InsertedFile",
+ "page": "{61B55117-0175-0726-0BF0-15DECEA928C1}{1}{B0}",
+ "bytes": 23
+ }
+]
\ No newline at end of file
diff --git a/corpus/object-outline/keys/run.json b/corpus/object-outline/keys/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..51885d77cc99989f0dde02a9bb081ddbe4926604
--- /dev/null
+++ b/corpus/object-outline/keys/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/Users/clo/dev/one/corpus/object-outline/native/notebook",
+ "expected_pages": -1,
+ "author": null,
+ "author_timeout_seconds": 600,
+ "inspect": false,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
+ }
+}
diff --git a/corpus/object-outline/keys/scripts/cold.ps1 b/corpus/object-outline/keys/scripts/cold.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f
--- /dev/null
+++ b/corpus/object-outline/keys/scripts/cold.ps1
@@ -0,0 +1,27 @@
+param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
+$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
+if ($CloneHost) {
+ if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
+ throw 'The disposable clone hostname does not match this machine.'
+ }
+ New-Item "$key\Options\Paths" -Force | Out-Null
+ New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
+} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
+ -not (Test-Path 'C:\one-tests\profile-original-cache')) {
+ throw 'Park the personal OneNote profile before resetting the test cache.'
+}
+$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
+$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
+if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
+New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
+if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
+if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
+New-Item "$key\OpenNotebooks" | Out-Null
+New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
diff --git a/corpus/object-outline/keys/scripts/read.ps1 b/corpus/object-outline/keys/scripts/read.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a42366b32e8baaf13dae92e9472cae7a071220cc
--- /dev/null
+++ b/corpus/object-outline/keys/scripts/read.ps1
@@ -0,0 +1,144 @@
+param(
+ [Parameter(Mandatory=$true)][string]$Root,
+ [int]$ExpectedPages = -1,
+ [switch]$UseCurrentCache,
+ [switch]$Pdf,
+ [switch]$KeepOpen,
+ [string]$CloneHost = ''
+)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+$notebook = Join-Path $root 'notebook'
+$output = Join-Path $root 'read'
+if (Test-Path $output) { throw 'Choose a new read destination.' }
+if ($UseCurrentCache) {
+ if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
+ throw 'Park the personal OneNote profile before reading test notebooks.'
+ }
+} else {
+ & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
+}
+New-Item -ItemType Directory -Path $output | Out-Null
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+$failure = $null
+try {
+ $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
+ $process = Get-Process ONENOTE
+ @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
+ powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
+ ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
+ $sections = @()
+ foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
+ $id = ''
+ $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
+ $sections += $id
+ }
+ $deadline = [DateTime]::UtcNow.AddSeconds(300)
+ $previous = ''
+ $lastChange = ''
+ $stableSince = [DateTime]::UtcNow
+ $settled = $false
+ do {
+ $pages = @{}
+ foreach ($section in $sections) {
+ $hierarchy = ''
+ $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
+ [xml]$xml = $hierarchy
+ foreach ($node in $xml.SelectNodes('//*[@path]')) {
+ if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'OneNote opened a section outside the copied notebook.'
+ }
+ }
+ foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
+ $id = $node.GetAttribute('ID')
+ $content = ''
+ $app.GetPageContent($id, [ref]$content, 1, 1)
+ $pages[$id] = $content
+ }
+ }
+ $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
+ if ($signature -ne $previous) {
+ $lastChange = $previous
+ $previous = $signature
+ $stableSince = [DateTime]::UtcNow
+ }
+ if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
+ ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
+ ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
+ Start-Sleep -Milliseconds 250
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
+ [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
+ $index = 0
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ $index++
+ }
+ $hierarchy = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
+ [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
+ throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
+ }
+ $index = 0
+ $payloads = @()
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ if ($Pdf) {
+ $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
+ $app.NavigateTo($id, '', $false)
+ $app.Publish($id, $pdfPath, 3, '')
+ if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
+ }
+ [xml]$page = $pages[$id]
+ foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
+ $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
+ $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
+ [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
+ # A paragraph's file is named by its paragraph, one on the page by itself.
+ $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
+ $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
+ kind = $file.LocalName; name = $file.GetAttribute('preferredName');
+ sha256 = $hash; bytes = $bytes.Length }
+ }
+ $index++
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
+ $all = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
+ [xml]$finalTree = $all
+ $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
+ if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
+ throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
+ Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
+} catch {
+ $failure = $_
+ [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ throw
+} finally {
+ try {
+ try {
+ if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
+ if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
+ $app.CloseNotebook($notebookId, $false)
+ }
+ } catch {
+ if ($null -eq $failure) { throw }
+ [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ }
+ } finally {
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ }
+ $app = $null
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+ if (-not $UseCurrentCache -and -not $CloneHost) {
+ Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
+ }
+}
diff --git a/corpus/object-outline/keys/source.json b/corpus/object-outline/keys/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..48f1f68639a6c6d352ec34d74c70039841eaf937
--- /dev/null
+++ b/corpus/object-outline/keys/source.json
@@ -0,0 +1,38 @@
+[
+ {
+ "path": "Alone.one",
+ "bytes": 20784,
+ "sha256": "5fe3413bdd11788de729b1b8b375b5174af7acfcf5624b1f13e43b19334d4c3b",
+ "mtime_ns": 1790744804347247366
+ },
+ {
+ "path": "Open Notebook.onetoc2",
+ "bytes": 3980,
+ "sha256": "49e914844513913cd4c3ec5e8fe3a237aa4d10a8575da1f2af1a9c3f97515656",
+ "mtime_ns": 1790744804345074117
+ },
+ {
+ "path": "fixture-data/Open Notebook.onetoc2",
+ "bytes": 3656,
+ "sha256": "e4f66353f0be442324927f1b4a158b8daad7900da2cc25f82a971605d1770a3e",
+ "mtime_ns": 1790744804346997658
+ },
+ {
+ "path": "fixture-data/notes.txt",
+ "bytes": 23,
+ "sha256": "93d5f94f89aaa8a154fd3f4b8ee770706ee075bda149fb5b68d4038ab5488259",
+ "mtime_ns": 1790744804346747949
+ },
+ {
+ "path": "fixture.json",
+ "bytes": 1240,
+ "sha256": "2b40bc72f2d9aefa7235fcb8f11a905904e6df46c6caa8f25444ce2d31b82457",
+ "mtime_ns": 1790744804344731700
+ },
+ {
+ "path": "synthetic.one",
+ "bytes": 5892,
+ "sha256": "a91822e4b09999a7f57435856ea754185d0e37079db20e52a62b417017a6a8e5",
+ "mtime_ns": 1790744804347492408
+ }
+]
diff --git a/corpus/object-outline/keys/teardown.json b/corpus/object-outline/keys/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/object-outline/keys/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/corpus/object-outline/native/commands.jsonl b/corpus/object-outline/native/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..403d2808c096758521dfb4f5f4e2d9afec080988
--- /dev/null
+++ b/corpus/object-outline/native/commands.jsonl
@@ -0,0 +1,4 @@
+{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\author.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-242DCCBE", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-242DCCBE -ExpectedPages 3 -UseCurrentCache", "exit": 0, "stdout": "Read 2 sections and 3 pages.\r\n", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
diff --git a/corpus/object-outline/native/machine.json b/corpus/object-outline/native/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..eb4325c839bb846a58495a5c1d64d7866cac8cc7
--- /dev/null
+++ b/corpus/object-outline/native/machine.json
@@ -0,0 +1 @@
+{"name": "m6-242dccbe", "hostname": "ONE-M6-242DCCBE"}
diff --git a/corpus/object-outline/native/notebook/Alone.one b/corpus/object-outline/native/notebook/Alone.one
new file mode 100644
index 0000000000000000000000000000000000000000..48d5ca5385260f33db4e307bd1d75eeca7564775
Binary files /dev/null and b/corpus/object-outline/native/notebook/Alone.one differ
diff --git a/corpus/object-outline/native/notebook/Open Notebook.onetoc2 b/corpus/object-outline/native/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..bdbffbaec4fbd2ff9bbbf826863008fefb549a6d
Binary files /dev/null and b/corpus/object-outline/native/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/object-outline/native/notebook/fixture-data/Open Notebook.onetoc2 b/corpus/object-outline/native/notebook/fixture-data/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..faf1cb4a07d510a73f8dc12a13a8490bc834f3c2
Binary files /dev/null and b/corpus/object-outline/native/notebook/fixture-data/Open Notebook.onetoc2 differ
diff --git a/corpus/object-outline/native/notebook/fixture-data/notes.txt b/corpus/object-outline/native/notebook/fixture-data/notes.txt
new file mode 100644
index 0000000000000000000000000000000000000000..249e2b8dd53ba539efce08db14a4fc4a420f2b73
--- /dev/null
+++ b/corpus/object-outline/native/notebook/fixture-data/notes.txt
@@ -0,0 +1 @@
+Plain text attachment
diff --git a/corpus/object-outline/native/notebook/fixture.json b/corpus/object-outline/native/notebook/fixture.json
new file mode 100644
index 0000000000000000000000000000000000000000..4a481c470291288052a5264feedebf411dfb3943
--- /dev/null
+++ b/corpus/object-outline/native/notebook/fixture.json
@@ -0,0 +1,15 @@
+{
+ "sections": [
+ "Alone.one"
+ ],
+ "pages": [
+ {
+ "section": "Alone.one",
+ "xml": ""
+ },
+ {
+ "section": "Alone.one",
+ "xml": "iVBORw0KGgoAAAANSUhEUgAAAEAAAAAwCAIAAAAuKetIAAAAj0lEQVR4nO3ZsQkDQQwF0bnhit7AoYMrx0W4DtfiAswaDgd7A37pRyAJZdoezxdlEidxEidxEidxErfPgjFuXMxx3E8MMCtYZUwWmj8hiZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4idu/ZBd8EXza/o/uxSRO4iRO4lzdwK/eahkMdcPZIWgAAAAASUVORK5CYII="
+ }
+ ]
+}
\ No newline at end of file
diff --git a/corpus/object-outline/native/notebook/synthetic.one b/corpus/object-outline/native/notebook/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..858d8a574fea2fd15de8d440db7b1eef5d495265
Binary files /dev/null and b/corpus/object-outline/native/notebook/synthetic.one differ
diff --git a/corpus/object-outline/native/read/93d5f94f89aaa8a154fd3f4b8ee770706ee075bda149fb5b68d4038ab5488259.attachment b/corpus/object-outline/native/read/93d5f94f89aaa8a154fd3f4b8ee770706ee075bda149fb5b68d4038ab5488259.attachment
new file mode 100644
index 0000000000000000000000000000000000000000..249e2b8dd53ba539efce08db14a4fc4a420f2b73
--- /dev/null
+++ b/corpus/object-outline/native/read/93d5f94f89aaa8a154fd3f4b8ee770706ee075bda149fb5b68d4038ab5488259.attachment
@@ -0,0 +1 @@
+Plain text attachment
diff --git a/corpus/object-outline/native/read/environment.json b/corpus/object-outline/native/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..def8c2b897144aa3a6b9f0ca595da8b73572d5fe
--- /dev/null
+++ b/corpus/object-outline/native/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-242DCCBE",
+ "cold": false,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/object-outline/native/read/hierarchy.xml b/corpus/object-outline/native/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..ba400b1769d6a01f25b48bab02ec9c81e3ec026e
--- /dev/null
+++ b/corpus/object-outline/native/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/object-outline/native/read/page-000.xml b/corpus/object-outline/native/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..b22d655c9cd9e53ff20dcc3508f2f56fe4bb4c94
--- /dev/null
+++ b/corpus/object-outline/native/read/page-000.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/object-outline/native/read/page-001.xml b/corpus/object-outline/native/read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..dd049e59e569099c11c86760b3eeccf84639b660
--- /dev/null
+++ b/corpus/object-outline/native/read/page-001.xml
@@ -0,0 +1,5 @@
+
+iVBORw0KGgoAAAANSUhEUgAAAEAAAAAwCAIAAAAuKetIAAAAj0lEQVR4nO3ZsQkDQQwF0bnhit7A
+oYMrx0W4DtfiAswaDgd7A37pRyAJZdoezxdlEidxEidxEidxErfPgjFuXMxx3E8MMCtYZUwWmj8h
+iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4iZM4idu/ZBd8EXza/o/uxSRO
+4iRO4lzdwK/eahkMdcPZIWgAAAAASUVORK5CYII=
diff --git a/corpus/object-outline/native/read/page-002.xml b/corpus/object-outline/native/read/page-002.xml
new file mode 100644
index 0000000000000000000000000000000000000000..106edff4755eb23290b5274aedb8b3e44add1df3
--- /dev/null
+++ b/corpus/object-outline/native/read/page-002.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/object-outline/native/read/payloads.json b/corpus/object-outline/native/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..e9811dcade5ea6e76829cd0e42e86955b081fbc0
--- /dev/null
+++ b/corpus/object-outline/native/read/payloads.json
@@ -0,0 +1,10 @@
+[
+ {
+ "sha256": "93d5f94f89aaa8a154fd3f4b8ee770706ee075bda149fb5b68d4038ab5488259",
+ "name": "notes.txt",
+ "object": "{6765D3EA-B77A-0052-0237-D533EA08D377}{24}{B0}",
+ "kind": "InsertedFile",
+ "page": "{676C4EA9-CAA0-068A-206C-0B4E9989F3FD}{1}{B0}",
+ "bytes": 23
+ }
+]
\ No newline at end of file
diff --git a/corpus/object-outline/native/run.json b/corpus/object-outline/native/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..7d9a49d8449d9e32fb016e124444ba1fc4038b56
--- /dev/null
+++ b/corpus/object-outline/native/run.json
@@ -0,0 +1,19 @@
+{
+ "notebook": "/private/tmp/unsup-lab/alone-input/notebook",
+ "expected_pages": 3,
+ "author": "tools/native/pages.ps1",
+ "author_timeout_seconds": 600,
+ "inspect": false,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "author.ps1": "dfbf0e016aa4c8486ecea9f39768c633de05ac0db8214139052ca2e9c43bbd02",
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
+ }
+}
diff --git a/corpus/object-outline/native/scripts/author.ps1 b/corpus/object-outline/native/scripts/author.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..8e428618e3452caa35da88687293b520c273b6c6
--- /dev/null
+++ b/corpus/object-outline/native/scripts/author.ps1
@@ -0,0 +1,42 @@
+param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost
+$fixture = Get-Content (Join-Path $Root 'notebook\fixture.json') -Raw -Encoding UTF8 | ConvertFrom-Json
+$inputs = Join-Path $Root 'inputs'
+New-Item -ItemType Directory -Path $inputs | Out-Null
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+try {
+ $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0)
+ $sections = @{}
+ foreach ($path in $fixture.sections) {
+ $parts = $path.Split('/')
+ if (@($parts | Where-Object { $_ -in @('', '.', '..') -or $_.Contains('\') -or $_.Contains(':') }).Count) { throw 'Invalid fixture section path.' }
+ $parent = $notebookId
+ for ($index = 0; $index -lt $parts.Count; $index++) {
+ $id = ''
+ $app.OpenHierarchy($parts[$index], $parent, [ref]$id, $(if ($index -eq $parts.Count - 1) {3} else {2}))
+ $parent = $id
+ }
+ $sections[$path] = $id
+ }
+ $index = 0
+ foreach ($item in $fixture.pages) {
+ $pageId = ''
+ $app.CreateNewPage($sections[$item.section], [ref]$pageId, 0)
+ [xml]$xml = $item.xml.Replace('__ASSETS__', [Security.SecurityElement]::Escape((Join-Path $Root 'notebook\fixture-data')))
+ $xml.DocumentElement.SetAttribute('ID', $pageId)
+ [IO.File]::WriteAllText((Join-Path $inputs ('page-{0:d3}.xml' -f $index)), $xml.OuterXml, [Text.Encoding]::UTF8)
+ $app.UpdatePageContent($xml.OuterXml, [DateTime]::MinValue, 1, $true)
+ $index++
+ }
+ $app.SyncHierarchy($notebookId)
+ $app.CloseNotebook($notebookId, $false)
+ $notebookId = ''
+} finally {
+ if ($notebookId) { $app.CloseNotebook($notebookId, $false) }
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+}
diff --git a/corpus/object-outline/native/scripts/cold.ps1 b/corpus/object-outline/native/scripts/cold.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f
--- /dev/null
+++ b/corpus/object-outline/native/scripts/cold.ps1
@@ -0,0 +1,27 @@
+param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
+$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
+if ($CloneHost) {
+ if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
+ throw 'The disposable clone hostname does not match this machine.'
+ }
+ New-Item "$key\Options\Paths" -Force | Out-Null
+ New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
+} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
+ -not (Test-Path 'C:\one-tests\profile-original-cache')) {
+ throw 'Park the personal OneNote profile before resetting the test cache.'
+}
+$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
+$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
+if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
+New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
+if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
+if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
+New-Item "$key\OpenNotebooks" | Out-Null
+New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
diff --git a/corpus/object-outline/native/scripts/read.ps1 b/corpus/object-outline/native/scripts/read.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a42366b32e8baaf13dae92e9472cae7a071220cc
--- /dev/null
+++ b/corpus/object-outline/native/scripts/read.ps1
@@ -0,0 +1,144 @@
+param(
+ [Parameter(Mandatory=$true)][string]$Root,
+ [int]$ExpectedPages = -1,
+ [switch]$UseCurrentCache,
+ [switch]$Pdf,
+ [switch]$KeepOpen,
+ [string]$CloneHost = ''
+)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+$notebook = Join-Path $root 'notebook'
+$output = Join-Path $root 'read'
+if (Test-Path $output) { throw 'Choose a new read destination.' }
+if ($UseCurrentCache) {
+ if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
+ throw 'Park the personal OneNote profile before reading test notebooks.'
+ }
+} else {
+ & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
+}
+New-Item -ItemType Directory -Path $output | Out-Null
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+$failure = $null
+try {
+ $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
+ $process = Get-Process ONENOTE
+ @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
+ powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
+ ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
+ $sections = @()
+ foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
+ $id = ''
+ $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
+ $sections += $id
+ }
+ $deadline = [DateTime]::UtcNow.AddSeconds(300)
+ $previous = ''
+ $lastChange = ''
+ $stableSince = [DateTime]::UtcNow
+ $settled = $false
+ do {
+ $pages = @{}
+ foreach ($section in $sections) {
+ $hierarchy = ''
+ $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
+ [xml]$xml = $hierarchy
+ foreach ($node in $xml.SelectNodes('//*[@path]')) {
+ if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'OneNote opened a section outside the copied notebook.'
+ }
+ }
+ foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
+ $id = $node.GetAttribute('ID')
+ $content = ''
+ $app.GetPageContent($id, [ref]$content, 1, 1)
+ $pages[$id] = $content
+ }
+ }
+ $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
+ if ($signature -ne $previous) {
+ $lastChange = $previous
+ $previous = $signature
+ $stableSince = [DateTime]::UtcNow
+ }
+ if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
+ ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
+ ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
+ Start-Sleep -Milliseconds 250
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
+ [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
+ $index = 0
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ $index++
+ }
+ $hierarchy = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
+ [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
+ throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
+ }
+ $index = 0
+ $payloads = @()
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ if ($Pdf) {
+ $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
+ $app.NavigateTo($id, '', $false)
+ $app.Publish($id, $pdfPath, 3, '')
+ if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
+ }
+ [xml]$page = $pages[$id]
+ foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
+ $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
+ $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
+ [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
+ # A paragraph's file is named by its paragraph, one on the page by itself.
+ $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
+ $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
+ kind = $file.LocalName; name = $file.GetAttribute('preferredName');
+ sha256 = $hash; bytes = $bytes.Length }
+ }
+ $index++
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
+ $all = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
+ [xml]$finalTree = $all
+ $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
+ if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
+ throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
+ Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
+} catch {
+ $failure = $_
+ [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ throw
+} finally {
+ try {
+ try {
+ if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
+ if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
+ $app.CloseNotebook($notebookId, $false)
+ }
+ } catch {
+ if ($null -eq $failure) { throw }
+ [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ }
+ } finally {
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ }
+ $app = $null
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+ if (-not $UseCurrentCache -and -not $CloneHost) {
+ Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
+ }
+}
diff --git a/corpus/object-outline/native/source.json b/corpus/object-outline/native/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..8e29e643305ed8b9ed22ce363581dd841e539457
--- /dev/null
+++ b/corpus/object-outline/native/source.json
@@ -0,0 +1,26 @@
+[
+ {
+ "path": "Open Notebook.onetoc2",
+ "bytes": 1612,
+ "sha256": "fafa5dcb3cd827c7edd30c8ea2fbc957a59fb4d2a8b28b667a3c551c8261ab86",
+ "mtime_ns": 1790744194106080813
+ },
+ {
+ "path": "fixture-data/notes.txt",
+ "bytes": 23,
+ "sha256": "93d5f94f89aaa8a154fd3f4b8ee770706ee075bda149fb5b68d4038ab5488259",
+ "mtime_ns": 1790744194111002128
+ },
+ {
+ "path": "fixture.json",
+ "bytes": 1240,
+ "sha256": "2b40bc72f2d9aefa7235fcb8f11a905904e6df46c6caa8f25444ce2d31b82457",
+ "mtime_ns": 1790744196600541274
+ },
+ {
+ "path": "synthetic.one",
+ "bytes": 3348,
+ "sha256": "b0662387c3fdc256d8dbe809e3b8f73b3586559696467fb1fff8f589095fa797",
+ "mtime_ns": 1790744194106931525
+ }
+]
diff --git a/corpus/object-outline/native/teardown.json b/corpus/object-outline/native/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/object-outline/native/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/corpus/printout/README.md b/corpus/printout/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..b04d364e2740906d9cf5cd24acf1ad755fb7ad2e
--- /dev/null
+++ b/corpus/printout/README.md
@@ -0,0 +1,19 @@
+# File printouts
+
+`native/` is a notebook OneNote 2010 made in the lab (Insert, File Printout of a
+two-page Word document, and a shape pasted from Word), cold-read with
+`tools/native_runner.py`. Each printout page is a picture whose PictureContainer is the
+printout's XPS package, shared by its pages, with IsPrintout, the page shown
+(DisplayedPageNumber) and OneNote's PNG rendering of that page as its
+WebPictureContainer14 (MS-ONE 2.3.98), which Snowbound draws, framed in grey as OneNote
+frames printout pages.
+
+`candidate/` is the output of `printouts_show_onenotes_rendering_and_move_as_pictures`
+in `crates/canvas/tests/printouts.rs`: one printout page moved down, the other deleted
+and the deletion undone, and text typed before the citation, published as one revision.
+Snowbound does not write XPS packages, so the undone page returns as the PNG OneNote
+showed of it. `cold/` is a fresh OneNote 2010 read: the moved page is still a printout,
+the restored one a picture. `tools/test_printout.py` checks this without a VM.
+Regenerate with `SNOWBOUND_PRINTOUT_EXPORT` set to a new absolute directory while
+running the test, then cold-open it with
+`tools/native_runner.py OUTPUT COLD --expected-pages 2 --collect-notebook --screenshots`.
diff --git a/corpus/printout/candidate/Open Notebook.onetoc2 b/corpus/printout/candidate/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..90da353c9de9888642b5d42f22eb53e74962833d
Binary files /dev/null and b/corpus/printout/candidate/Open Notebook.onetoc2 differ
diff --git a/corpus/printout/candidate/Printouts.one b/corpus/printout/candidate/Printouts.one
new file mode 100644
index 0000000000000000000000000000000000000000..d2e66594be0d9f0b2ec85053feb723d819eabd7b
Binary files /dev/null and b/corpus/printout/candidate/Printouts.one differ
diff --git a/corpus/printout/cold/commands.jsonl b/corpus/printout/cold/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..06aa4959935e4e5b9720e0342b9fe633deb3d447
--- /dev/null
+++ b/corpus/printout/cold/commands.jsonl
@@ -0,0 +1,3 @@
+{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-B402FE00 -ExpectedPages 2 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 2 pages.\r\n", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
diff --git a/corpus/printout/cold/machine.json b/corpus/printout/cold/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..41d3b5748eadd4f55fb2f0f51ffb9e06b59334b5
--- /dev/null
+++ b/corpus/printout/cold/machine.json
@@ -0,0 +1 @@
+{"name": "m6-b402fe00", "hostname": "ONE-M6-B402FE00"}
diff --git a/corpus/printout/cold/notebook/Open Notebook.onetoc2 b/corpus/printout/cold/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..e052f7f02ea4094221dca80760b88f7952848e6b
Binary files /dev/null and b/corpus/printout/cold/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/printout/cold/notebook/Printouts.one b/corpus/printout/cold/notebook/Printouts.one
new file mode 100644
index 0000000000000000000000000000000000000000..8c35ff854862c80e3fc852dfdbcb9bc452ed76d1
Binary files /dev/null and b/corpus/printout/cold/notebook/Printouts.one differ
diff --git a/corpus/printout/cold/read/e15f2722a8d11f9e68ac5eff54320f7c4ef5540fb6eb858223e31b983fa1bed7.attachment b/corpus/printout/cold/read/e15f2722a8d11f9e68ac5eff54320f7c4ef5540fb6eb858223e31b983fa1bed7.attachment
new file mode 100644
index 0000000000000000000000000000000000000000..46b3e8dde9d528a18c72122460c6a8c018f0830b
Binary files /dev/null and b/corpus/printout/cold/read/e15f2722a8d11f9e68ac5eff54320f7c4ef5540fb6eb858223e31b983fa1bed7.attachment differ
diff --git a/corpus/printout/cold/read/environment.json b/corpus/printout/cold/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..b60b7a9a6d1208e3110d998f20a286622b5034bc
--- /dev/null
+++ b/corpus/printout/cold/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-B402FE00",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/printout/cold/read/hierarchy.xml b/corpus/printout/cold/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..c8ca7950adf00d326e81c9763e6b67748cc78a5d
--- /dev/null
+++ b/corpus/printout/cold/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/printout/cold/read/page-000.navigation.json b/corpus/printout/cold/read/page-000.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..61f3843fae261a8867978a4dbc42f1463a422ab2
--- /dev/null
+++ b/corpus/printout/cold/read/page-000.navigation.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Pasted shape - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/printout/cold/read/page-000.png b/corpus/printout/cold/read/page-000.png
new file mode 100644
index 0000000000000000000000000000000000000000..713354cb41d1fe4527d207f8f6ae13bb71d379ce
Binary files /dev/null and b/corpus/printout/cold/read/page-000.png differ
diff --git a/corpus/printout/cold/read/page-000.xml b/corpus/printout/cold/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..e048f48128f2ab49aad3817203b548ebc69f12cf
--- /dev/null
+++ b/corpus/printout/cold/read/page-000.xml
@@ -0,0 +1,74 @@
+
+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
diff --git a/corpus/printout/cold/read/page-001.navigation.json b/corpus/printout/cold/read/page-001.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..86a0d5f7e25a7ab73646345e7d3471637334f39e
--- /dev/null
+++ b/corpus/printout/cold/read/page-001.navigation.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Printout - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/printout/cold/read/page-001.png b/corpus/printout/cold/read/page-001.png
new file mode 100644
index 0000000000000000000000000000000000000000..5865baf7c33b8ee492af2900abb11b5dd99a4a30
Binary files /dev/null and b/corpus/printout/cold/read/page-001.png differ
diff --git a/corpus/printout/cold/read/page-001.xml b/corpus/printout/cold/read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..fcdc47760913a58a9bf0c0d30b661a0b1fd0d0af
--- /dev/null
+++ b/corpus/printout/cold/read/page-001.xml
@@ -0,0 +1,526 @@
+
+file://C:\work\printout\inputs\two-pages.docx>]]>iVBORw0KGgoAAAANSUhEUgAAAzAAAAQgCAIAAAC2Gy5ZAAAAAXNSR0IArs4c6QAAAAlwSFlzAAAO
+xAAADsQBlSsOGwAANTtJREFUeF7t1sENACAMAzFg/51bMcV93AUiOX3kzsxxBAgQIECAAAECncDr
+oiUTIECAAAECBAh8AYPMHxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBP
+gAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECA
+AAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwA
+AQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAAB
+AgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI
+4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAEC
+BAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIE
+DDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQI
+ECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQ
+iAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsL
+EE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAg
+QIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI
+/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECA
+AAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAW
+MMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8
+AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAAB
+AgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMD
+BAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIE
+CBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAg
+iwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQI
+ECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQ
+MMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAg
+QIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBA
+IBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywu
+QDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECA
+AAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg
+8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAAB
+AgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFY
+wCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADx
+BAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIE
+CBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wP
+ECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQ
+IEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGD
+LC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMg
+QIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBA
+wCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CA
+AAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAA
+gVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4
+APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAAB
+AgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGD
+zA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIE
+CBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRi
+AYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLE
+EyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQ
+IEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/
+QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBA
+gACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUM
+srgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+A
+AAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAA
+AYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AAB
+AgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAEC
+BGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMji
+AsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIE
+CBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQM
+Mj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQ
+IECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCI
+BQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQ
+T4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBA
+gAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8
+AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAA
+AQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYw
+yOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwB
+AgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAEC
+BAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wME
+CBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQI
+EIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCL
+CxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQ
+IECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAw
+yPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBA
+gAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAg
+FjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5A
+PAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAA
+AQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDz
+AwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAEC
+BAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjA
+IIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEE
+CBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQI
+EDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8Q
+IECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAg
+QCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMs
+LkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBA
+gAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDA
+IPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAA
+AQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACB
+WMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA
+8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAEC
+BAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPM
+DxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQI
+ECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIB
+gywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQT
+IECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAg
+QMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9A
+gAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECA
+AIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyy
+uADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AA
+AQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAAB
+g8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAEC
+BAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIE
+YgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOIC
+xBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQI
+ECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwy
+P0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAg
+QIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgF
+DLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBP
+gAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECA
+AAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwA
+AQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAAB
+AgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI
+4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAEC
+BAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIE
+DDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQI
+ECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQ
+iAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsL
+EE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAg
+QIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI
+/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECA
+AAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAW
+MMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8
+AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAAB
+AgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMD
+BAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIE
+CBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAg
+iwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQI
+ECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQ
+MMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAg
+QIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBA
+IBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywu
+QDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECA
+AAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg
+8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAAB
+AgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFY
+wCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADx
+BAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIE
+CBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wP
+ECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQ
+IEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGD
+LC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMg
+QIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBA
+wCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CA
+AAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAA
+gVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4
+APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAAB
+AgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGD
+zA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIE
+CBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRi
+AYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLE
+EyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQ
+IEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/
+QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBA
+gACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUM
+srgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+A
+AAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAA
+AYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AAB
+AgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAEC
+BGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMji
+AsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIE
+CBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQM
+Mj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQ
+IECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCI
+BQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQ
+T4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBA
+gAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8
+AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAA
+AQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYw
+yOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwB
+AgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAEC
+BAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wME
+CBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQI
+EIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCL
+CxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQ
+IECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAw
+yPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBA
+gAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAg
+FjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5A
+PAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAA
+AQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDz
+AwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAEC
+BAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjA
+IIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEE
+CBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQI
+EDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8Q
+IECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAg
+QCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMs
+LkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBA
+gAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDA
+IPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAA
+AQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACB
+WMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA
+8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAEC
+BAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPM
+DxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQI
+ECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIB
+gywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQT
+IECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAg
+QMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9A
+gAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECA
+AIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyy
+uADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AA
+AQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAAB
+g8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAEC
+BAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIE
+YgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOIC
+xBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQI
+ECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwy
+P0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAg
+QIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgF
+DLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBP
+gAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECA
+AAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwA
+AQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAAB
+AgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI
+4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAEC
+BAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIE
+DDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQI
+ECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQ
+iAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsL
+EE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAg
+QIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI
+/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECA
+AAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAW
+MMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8
+AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAAB
+AgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMD
+BAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIE
+CBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAg
+iwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQI
+ECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQ
+MMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAg
+QIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBA
+IBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywu
+QDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECA
+AAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg
+8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAAB
+AgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFY
+wCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADx
+BAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIE
+CBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wP
+ECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQ
+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
+b7w9fTfueZOd73/e+no85cQ4n3a+6vxw83zPK8yV7fpH5xHPfP71T7///e9/8IMffLyR9vS5j5BP
+b/zDH/7wz3me+eaP9+SennzFOH+ueM9QPP3M/8l3H1+8dZ9BgAABAgT+JwXOOyX+vLDAxz/+8Ve8
+4hV//vOfn37aq171qp/85CfnI+fj5++//e1vz9/PKvrqV796fdr54PnPM27O1/7pT386fz+fc/5+
+febXvva1Z/7++Krr216f/NGPfvSZbOeD58/1wfNNTrbHJ5//en38RHo89Ap/BTh/vz7/+vv1oGdO
+uJ57Hfs457rxvCyfCfP0m5/vdgU7u+rxlEfC88FH7Mejrw9e2R5f/swj/FcCBAgQILAg4B2yF1+/
+3/72t7/yla+88Y1vfPp+0nnb7AMf+MD54te97nVf+tKXfv7zn5+3gn7wgx987GMfe/pe2je/+c3z
+487Xv/7154PnZ4gf+chHzmden/DlL3/5+qni+973vvPrWefLz9tUj297/tF56POG+/Wvf319/Hzh
+eaPregvqfIdPf/rTz/v5jwCf+tSnzuf/8+ecEfahD33oCnOF/M1vfvP0nHPjj370oxf+5u94xzvO
+J5yNdX5gesSuT37zm998PfGsrg9/+MPXB9/73vde77cdioNw4VyY3iR7XmQfJECAAIGXvYBB9pIq
+Plvn/FbWWTbvfOc7z2g48+IMoMdP3z73uc+d8f7Xv/71n38H/0y0pw84W+R85jOPfO1rX3u9e3R9
+25cU6F8/6Xzh+c9//ob/1nc4P3h93u9/5tEPf/jDxznXhHrec14g2xltj3/6+Onk+ano9cSzus4j
+rk/4whe+cIba+csvfvGLz3/+84/nvvTDfSYBAgQIEHiZCRhk/45Cr3dxzmy63tS5fs3/+vOZz3zm
+Na95zT9/r8fbQtc/+uUvf/kCz7u+7Uv/czbTS//kF/jMN73pTSfn03POpc97zgt8k8fvip1fC/vs
+Zz97fbczYa8vOT/0fEzY827cJfme97znvEP29Lkv/C8i/Jcc65sQIECAAIGggEH2IqWcefH4nf3z
+I8Xz2W9/+9uv97Se+Z+NuN4i+u53v3t9x/OL8+c/z1ti512i6yPny7/3ve+9//3v/7ce+fTbnk8+
+b7w972eed+muj3/xi188Q+rfO+Me3/Pxe/rnnbPz9tUzPy58es4ZW2dFPW+Yc901xa7Dz6I63/Yx
+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
diff --git a/corpus/printout/cold/read/payloads.json b/corpus/printout/cold/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1973a2e9f9b9a482fd4b86897627e4103866cb04
--- /dev/null
+++ b/corpus/printout/cold/read/payloads.json
@@ -0,0 +1,10 @@
+[
+ {
+ "sha256": "e15f2722a8d11f9e68ac5eff54320f7c4ef5540fb6eb858223e31b983fa1bed7",
+ "name": "two-pages.docx",
+ "object": "{DB7C4F71-B073-0121-2D07-84DA732F3A82}{23}{B0}",
+ "kind": "InsertedFile",
+ "page": "{DC91B969-8F8E-0E38-22C6-17772EFD8803}{1}{B0}",
+ "bytes": 12589
+ }
+]
\ No newline at end of file
diff --git a/corpus/printout/cold/run.json b/corpus/printout/cold/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..569608b03b8438b13f9f79044d5a6ea8191630d5
--- /dev/null
+++ b/corpus/printout/cold/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/private/tmp/unsup-lab/printout-candidate",
+ "expected_pages": 2,
+ "author": null,
+ "author_timeout_seconds": 600,
+ "inspect": false,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
+ }
+}
diff --git a/corpus/printout/cold/scripts/cold.ps1 b/corpus/printout/cold/scripts/cold.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f
--- /dev/null
+++ b/corpus/printout/cold/scripts/cold.ps1
@@ -0,0 +1,27 @@
+param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
+$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
+if ($CloneHost) {
+ if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
+ throw 'The disposable clone hostname does not match this machine.'
+ }
+ New-Item "$key\Options\Paths" -Force | Out-Null
+ New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
+} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
+ -not (Test-Path 'C:\one-tests\profile-original-cache')) {
+ throw 'Park the personal OneNote profile before resetting the test cache.'
+}
+$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
+$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
+if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
+New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
+if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
+if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
+New-Item "$key\OpenNotebooks" | Out-Null
+New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
diff --git a/corpus/printout/cold/scripts/read.ps1 b/corpus/printout/cold/scripts/read.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a42366b32e8baaf13dae92e9472cae7a071220cc
--- /dev/null
+++ b/corpus/printout/cold/scripts/read.ps1
@@ -0,0 +1,144 @@
+param(
+ [Parameter(Mandatory=$true)][string]$Root,
+ [int]$ExpectedPages = -1,
+ [switch]$UseCurrentCache,
+ [switch]$Pdf,
+ [switch]$KeepOpen,
+ [string]$CloneHost = ''
+)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+$notebook = Join-Path $root 'notebook'
+$output = Join-Path $root 'read'
+if (Test-Path $output) { throw 'Choose a new read destination.' }
+if ($UseCurrentCache) {
+ if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
+ throw 'Park the personal OneNote profile before reading test notebooks.'
+ }
+} else {
+ & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
+}
+New-Item -ItemType Directory -Path $output | Out-Null
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+$failure = $null
+try {
+ $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
+ $process = Get-Process ONENOTE
+ @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
+ powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
+ ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
+ $sections = @()
+ foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
+ $id = ''
+ $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
+ $sections += $id
+ }
+ $deadline = [DateTime]::UtcNow.AddSeconds(300)
+ $previous = ''
+ $lastChange = ''
+ $stableSince = [DateTime]::UtcNow
+ $settled = $false
+ do {
+ $pages = @{}
+ foreach ($section in $sections) {
+ $hierarchy = ''
+ $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
+ [xml]$xml = $hierarchy
+ foreach ($node in $xml.SelectNodes('//*[@path]')) {
+ if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'OneNote opened a section outside the copied notebook.'
+ }
+ }
+ foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
+ $id = $node.GetAttribute('ID')
+ $content = ''
+ $app.GetPageContent($id, [ref]$content, 1, 1)
+ $pages[$id] = $content
+ }
+ }
+ $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
+ if ($signature -ne $previous) {
+ $lastChange = $previous
+ $previous = $signature
+ $stableSince = [DateTime]::UtcNow
+ }
+ if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
+ ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
+ ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
+ Start-Sleep -Milliseconds 250
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
+ [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
+ $index = 0
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ $index++
+ }
+ $hierarchy = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
+ [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
+ throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
+ }
+ $index = 0
+ $payloads = @()
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ if ($Pdf) {
+ $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
+ $app.NavigateTo($id, '', $false)
+ $app.Publish($id, $pdfPath, 3, '')
+ if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
+ }
+ [xml]$page = $pages[$id]
+ foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
+ $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
+ $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
+ [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
+ # A paragraph's file is named by its paragraph, one on the page by itself.
+ $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
+ $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
+ kind = $file.LocalName; name = $file.GetAttribute('preferredName');
+ sha256 = $hash; bytes = $bytes.Length }
+ }
+ $index++
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
+ $all = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
+ [xml]$finalTree = $all
+ $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
+ if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
+ throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
+ Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
+} catch {
+ $failure = $_
+ [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ throw
+} finally {
+ try {
+ try {
+ if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
+ if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
+ $app.CloseNotebook($notebookId, $false)
+ }
+ } catch {
+ if ($null -eq $failure) { throw }
+ [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ }
+ } finally {
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ }
+ $app = $null
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+ if (-not $UseCurrentCache -and -not $CloneHost) {
+ Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
+ }
+}
diff --git a/corpus/printout/cold/source.json b/corpus/printout/cold/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..62181874431e571489e154622f1c1b3ac870956a
--- /dev/null
+++ b/corpus/printout/cold/source.json
@@ -0,0 +1,14 @@
+[
+ {
+ "path": "Open Notebook.onetoc2",
+ "bytes": 1612,
+ "sha256": "29bbfd869154aea1720f98cf7c7cc40387313b00c8dfdb61d159f968312367b4",
+ "mtime_ns": 1790744275764608328
+ },
+ {
+ "path": "Printouts.one",
+ "bytes": 268208,
+ "sha256": "5b48da81b1b374da9af23b4dd664b01b100c34be71aef721907f4365afe5577e",
+ "mtime_ns": 1790744275764329165
+ }
+]
diff --git a/corpus/printout/cold/teardown.json b/corpus/printout/cold/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/printout/cold/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/corpus/printout/native/commands.jsonl b/corpus/printout/native/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..fcb05fee3efac52b109c62f715986651312665ad
--- /dev/null
+++ b/corpus/printout/native/commands.jsonl
@@ -0,0 +1,3 @@
+{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-5A6EE6B8 -ExpectedPages 2 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 2 pages.\r\n", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
diff --git a/corpus/printout/native/machine.json b/corpus/printout/native/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..b46527e91889a33db509cace5f21bf63675cee00
--- /dev/null
+++ b/corpus/printout/native/machine.json
@@ -0,0 +1 @@
+{"name": "m6-5a6ee6b8", "hostname": "ONE-M6-5A6EE6B8"}
diff --git a/corpus/printout/native/notebook/Open Notebook.onetoc2 b/corpus/printout/native/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..3831241391b8036277838b65a5b23ea67c24cd24
Binary files /dev/null and b/corpus/printout/native/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/printout/native/notebook/Printouts.one b/corpus/printout/native/notebook/Printouts.one
new file mode 100644
index 0000000000000000000000000000000000000000..9129491d6e151e32ddde445edaced78d9b00c195
Binary files /dev/null and b/corpus/printout/native/notebook/Printouts.one differ
diff --git a/corpus/printout/native/read/e15f2722a8d11f9e68ac5eff54320f7c4ef5540fb6eb858223e31b983fa1bed7.attachment b/corpus/printout/native/read/e15f2722a8d11f9e68ac5eff54320f7c4ef5540fb6eb858223e31b983fa1bed7.attachment
new file mode 100644
index 0000000000000000000000000000000000000000..46b3e8dde9d528a18c72122460c6a8c018f0830b
Binary files /dev/null and b/corpus/printout/native/read/e15f2722a8d11f9e68ac5eff54320f7c4ef5540fb6eb858223e31b983fa1bed7.attachment differ
diff --git a/corpus/printout/native/read/environment.json b/corpus/printout/native/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..7cca60d7ec965e627aaca74ef84a43b1b04dae35
--- /dev/null
+++ b/corpus/printout/native/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-5A6EE6B8",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/printout/native/read/hierarchy.xml b/corpus/printout/native/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..03e0a44cf8d7414f107e592c7af32b3903489811
--- /dev/null
+++ b/corpus/printout/native/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/printout/native/read/page-000.navigation.json b/corpus/printout/native/read/page-000.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..61f3843fae261a8867978a4dbc42f1463a422ab2
--- /dev/null
+++ b/corpus/printout/native/read/page-000.navigation.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Pasted shape - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/printout/native/read/page-000.png b/corpus/printout/native/read/page-000.png
new file mode 100644
index 0000000000000000000000000000000000000000..a5700cd124e05504603cf988dc4a9811e528bc33
Binary files /dev/null and b/corpus/printout/native/read/page-000.png differ
diff --git a/corpus/printout/native/read/page-000.xml b/corpus/printout/native/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..900c715d278fe3d6de3b0ff8af3c5e963a351363
--- /dev/null
+++ b/corpus/printout/native/read/page-000.xml
@@ -0,0 +1,74 @@
+
+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
diff --git a/corpus/printout/native/read/page-001.navigation.json b/corpus/printout/native/read/page-001.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..86a0d5f7e25a7ab73646345e7d3471637334f39e
--- /dev/null
+++ b/corpus/printout/native/read/page-001.navigation.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Printout - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/printout/native/read/page-001.png b/corpus/printout/native/read/page-001.png
new file mode 100644
index 0000000000000000000000000000000000000000..d82c5b3f80e96216b8df6e09085e08b46ebb8762
Binary files /dev/null and b/corpus/printout/native/read/page-001.png differ
diff --git a/corpus/printout/native/read/page-001.xml b/corpus/printout/native/read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..e52836e4e51f7d7a9efeb21b0cd36a939620ddb9
--- /dev/null
+++ b/corpus/printout/native/read/page-001.xml
@@ -0,0 +1,527 @@
+
+file://C:\work\printout\inputs\two-pages.docx>]]>iVBORw0KGgoAAAANSUhEUgAAAzAAAAQgCAIAAAC2Gy5ZAAAAAXNSR0IArs4c6QAAAAlwSFlzAAAO
+xAAADsQBlSsOGwAANTtJREFUeF7t1sENACAMAzFg/51bMcV93AUiOX3kzsxxBAgQIECAAAECncDr
+oiUTIECAAAECBAh8AYPMHxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBP
+gAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECA
+AAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwA
+AQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAAB
+AgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI
+4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAEC
+BAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIE
+DDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQI
+ECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQ
+iAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsL
+EE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAg
+QIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI
+/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECA
+AAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAW
+MMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8
+AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAAB
+AgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMD
+BAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIE
+CBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAg
+iwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQI
+ECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQ
+MMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAg
+QIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBA
+IBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywu
+QDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECA
+AAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg
+8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAAB
+AgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFY
+wCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADx
+BAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIE
+CBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wP
+ECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQ
+IEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGD
+LC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMg
+QIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBA
+wCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CA
+AAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAA
+gVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4
+APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAAB
+AgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGD
+zA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIE
+CBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRi
+AYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLE
+EyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQ
+IEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/
+QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBA
+gACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUM
+srgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+A
+AAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAA
+AYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AAB
+AgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAEC
+BGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMji
+AsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIE
+CBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQM
+Mj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQ
+IECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCI
+BQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQ
+T4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBA
+gAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8
+AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAA
+AQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYw
+yOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwB
+AgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAEC
+BAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wME
+CBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQI
+EIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCL
+CxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQ
+IECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAw
+yPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBA
+gAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAg
+FjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5A
+PAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAA
+AQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDz
+AwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAEC
+BAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjA
+IIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEE
+CBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQI
+EDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8Q
+IECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAg
+QCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMs
+LkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBA
+gAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDA
+IPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAA
+AQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACB
+WMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA
+8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAEC
+BAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPM
+DxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQI
+ECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIB
+gywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQT
+IECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAg
+QMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9A
+gAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECA
+AIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyy
+uADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AA
+AQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAAB
+g8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAEC
+BAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIE
+YgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOIC
+xBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQI
+ECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwy
+P0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAg
+QIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgF
+DLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBP
+gAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECA
+AAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwA
+AQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAAB
+AgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI
+4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAEC
+BAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIE
+DDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQI
+ECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQ
+iAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsL
+EE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAg
+QIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI
+/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECA
+AAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAW
+MMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8
+AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAAB
+AgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMD
+BAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIE
+CBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAg
+iwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQI
+ECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQ
+MMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAg
+QIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBA
+IBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywu
+QDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECA
+AAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg
+8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAAB
+AgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFY
+wCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADx
+BAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIE
+CBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wP
+ECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQ
+IEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGD
+LC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMg
+QIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBA
+wCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CA
+AAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAA
+gVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4
+APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAAB
+AgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGD
+zA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIE
+CBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRi
+AYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLE
+EyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQ
+IEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/
+QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBA
+gACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUM
+srgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+A
+AAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAA
+AYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AAB
+AgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAEC
+BGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMji
+AsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIE
+CBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQM
+Mj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQ
+IECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCI
+BQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQ
+T4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBA
+gAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8
+AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAA
+AQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYw
+yOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwB
+AgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAEC
+BAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wME
+CBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQI
+EIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCL
+CxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQ
+IECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAw
+yPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBA
+gAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAg
+FjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5A
+PAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAA
+AQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDz
+AwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAEC
+BAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjA
+IIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEE
+CBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQI
+EDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8Q
+IECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAg
+QCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMs
+LkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBA
+gAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDA
+IPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAA
+AQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACB
+WMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA
+8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAEC
+BAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPM
+DxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQI
+ECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIB
+gywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQT
+IECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAg
+QMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9A
+gAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECA
+AIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyy
+uADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AA
+AQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAAB
+g8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAEC
+BAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIE
+YgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOIC
+xBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQI
+ECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwy
+P0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAg
+QIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgF
+DLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBP
+gAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECA
+AAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwA
+AQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAAB
+AgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI
+4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAEC
+BAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIE
+DDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQI
+ECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQ
+iAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsL
+EE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAg
+QIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI
+/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECA
+AAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAW
+MMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8
+AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAAB
+AgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMD
+BAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIE
+CBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAg
+iwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQI
+ECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQ
+MMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAg
+QIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBA
+IBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywu
+QDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECA
+AAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg
+8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAAB
+AgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFY
+wCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADx
+BAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIE
+CBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wP
+ECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQ
+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
+b7w9fTfueZOd73/e+no85cQ4n3a+6vxw83zPK8yV7fpH5xHPfP71T7///e9/8IMffLyR9vS5j5BP
+b/zDH/7wz3me+eaP9+SennzFOH+ueM9QPP3M/8l3H1+8dZ9BgAABAgT+JwXOOyX+vLDAxz/+8Ve8
+4hV//vOfn37aq171qp/85CfnI+fj5++//e1vz9/PKvrqV796fdr54PnPM27O1/7pT386fz+fc/5+
+febXvva1Z/7++Krr216f/NGPfvSZbOeD58/1wfNNTrbHJ5//en38RHo89Ap/BTh/vz7/+vv1oGdO
+uJ57Hfs457rxvCyfCfP0m5/vdgU7u+rxlEfC88FH7Mejrw9e2R5f/swj/FcCBAgQILAg4B2yF1+/
+3/72t7/yla+88Y1vfPp+0nnb7AMf+MD54te97nVf+tKXfv7zn5+3gn7wgx987GMfe/pe2je/+c3z
+487Xv/7154PnZ4gf+chHzmden/DlL3/5+qni+973vvPrWefLz9tUj297/tF56POG+/Wvf319/Hzh
+eaPregvqfIdPf/rTz/v5jwCf+tSnzuf/8+ecEfahD33oCnOF/M1vfvP0nHPjj370oxf+5u94xzvO
+J5yNdX5gesSuT37zm998PfGsrg9/+MPXB9/73vde77cdioNw4VyY3iR7XmQfJECAAIGXvYBB9pIq
+Plvn/FbWWTbvfOc7z2g48+IMoMdP3z73uc+d8f7Xv/71n38H/0y0pw84W+R85jOPfO1rX3u9e3R9
+25cU6F8/6Xzh+c9//ob/1nc4P3h93u9/5tEPf/jDxznXhHrec14g2xltj3/6+Onk+ano9cSzus4j
+rk/4whe+cIba+csvfvGLz3/+84/nvvTDfSYBAgQIEHiZCRhk/45Cr3dxzmy63tS5fs3/+vOZz3zm
+Na95zT9/r8fbQtc/+uUvf/kCz7u+7Uv/czbTS//kF/jMN73pTSfn03POpc97zgt8k8fvip1fC/vs
+Zz97fbczYa8vOT/0fEzY827cJfme97znvEP29Lkv/C8i/Jcc65sQIECAAIGggEH2IqWcefH4nf3z
+I8Xz2W9/+9uv97Se+Z+NuN4i+u53v3t9x/OL8+c/z1ti512i6yPny7/3ve+9//3v/7ce+fTbnk8+
+b7w972eed+muj3/xi188Q+rfO+Me3/Pxe/rnnbPz9tUzPy58es4ZW2dFPW+Yc901xa7Dz6I63/Yx
+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
diff --git a/corpus/printout/native/read/payloads.json b/corpus/printout/native/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..0c2ba107b6e7d335310b92d6c59843d9339fca24
--- /dev/null
+++ b/corpus/printout/native/read/payloads.json
@@ -0,0 +1,10 @@
+[
+ {
+ "sha256": "e15f2722a8d11f9e68ac5eff54320f7c4ef5540fb6eb858223e31b983fa1bed7",
+ "name": "two-pages.docx",
+ "object": "{EA6778A6-E35F-0697-11B6-2FB222D735B1}{23}{B0}",
+ "kind": "InsertedFile",
+ "page": "{ED8A8EBE-DCA2-098E-1E77-BC1F7F058730}{1}{B0}",
+ "bytes": 12589
+ }
+]
\ No newline at end of file
diff --git a/corpus/printout/native/run.json b/corpus/printout/native/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..8b32d882ca9a25dc4463cf536d6d38737a5fd4c6
--- /dev/null
+++ b/corpus/printout/native/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/private/tmp/unsup-lab/printout/notebook",
+ "expected_pages": 2,
+ "author": null,
+ "author_timeout_seconds": 600,
+ "inspect": false,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
+ }
+}
diff --git a/corpus/printout/native/scripts/cold.ps1 b/corpus/printout/native/scripts/cold.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f
--- /dev/null
+++ b/corpus/printout/native/scripts/cold.ps1
@@ -0,0 +1,27 @@
+param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
+$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
+if ($CloneHost) {
+ if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
+ throw 'The disposable clone hostname does not match this machine.'
+ }
+ New-Item "$key\Options\Paths" -Force | Out-Null
+ New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
+} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
+ -not (Test-Path 'C:\one-tests\profile-original-cache')) {
+ throw 'Park the personal OneNote profile before resetting the test cache.'
+}
+$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
+$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
+if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
+New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
+if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
+if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
+New-Item "$key\OpenNotebooks" | Out-Null
+New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
diff --git a/corpus/printout/native/scripts/read.ps1 b/corpus/printout/native/scripts/read.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a42366b32e8baaf13dae92e9472cae7a071220cc
--- /dev/null
+++ b/corpus/printout/native/scripts/read.ps1
@@ -0,0 +1,144 @@
+param(
+ [Parameter(Mandatory=$true)][string]$Root,
+ [int]$ExpectedPages = -1,
+ [switch]$UseCurrentCache,
+ [switch]$Pdf,
+ [switch]$KeepOpen,
+ [string]$CloneHost = ''
+)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+$notebook = Join-Path $root 'notebook'
+$output = Join-Path $root 'read'
+if (Test-Path $output) { throw 'Choose a new read destination.' }
+if ($UseCurrentCache) {
+ if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
+ throw 'Park the personal OneNote profile before reading test notebooks.'
+ }
+} else {
+ & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
+}
+New-Item -ItemType Directory -Path $output | Out-Null
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+$failure = $null
+try {
+ $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
+ $process = Get-Process ONENOTE
+ @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
+ powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
+ ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
+ $sections = @()
+ foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
+ $id = ''
+ $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
+ $sections += $id
+ }
+ $deadline = [DateTime]::UtcNow.AddSeconds(300)
+ $previous = ''
+ $lastChange = ''
+ $stableSince = [DateTime]::UtcNow
+ $settled = $false
+ do {
+ $pages = @{}
+ foreach ($section in $sections) {
+ $hierarchy = ''
+ $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
+ [xml]$xml = $hierarchy
+ foreach ($node in $xml.SelectNodes('//*[@path]')) {
+ if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'OneNote opened a section outside the copied notebook.'
+ }
+ }
+ foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
+ $id = $node.GetAttribute('ID')
+ $content = ''
+ $app.GetPageContent($id, [ref]$content, 1, 1)
+ $pages[$id] = $content
+ }
+ }
+ $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
+ if ($signature -ne $previous) {
+ $lastChange = $previous
+ $previous = $signature
+ $stableSince = [DateTime]::UtcNow
+ }
+ if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
+ ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
+ ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
+ Start-Sleep -Milliseconds 250
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
+ [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
+ $index = 0
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ $index++
+ }
+ $hierarchy = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
+ [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
+ throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
+ }
+ $index = 0
+ $payloads = @()
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ if ($Pdf) {
+ $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
+ $app.NavigateTo($id, '', $false)
+ $app.Publish($id, $pdfPath, 3, '')
+ if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
+ }
+ [xml]$page = $pages[$id]
+ foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
+ $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
+ $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
+ [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
+ # A paragraph's file is named by its paragraph, one on the page by itself.
+ $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
+ $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
+ kind = $file.LocalName; name = $file.GetAttribute('preferredName');
+ sha256 = $hash; bytes = $bytes.Length }
+ }
+ $index++
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
+ $all = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
+ [xml]$finalTree = $all
+ $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
+ if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
+ throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
+ Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
+} catch {
+ $failure = $_
+ [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ throw
+} finally {
+ try {
+ try {
+ if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
+ if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
+ $app.CloseNotebook($notebookId, $false)
+ }
+ } catch {
+ if ($null -eq $failure) { throw }
+ [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ }
+ } finally {
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ }
+ $app = $null
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+ if (-not $UseCurrentCache -and -not $CloneHost) {
+ Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
+ }
+}
diff --git a/corpus/printout/native/source.json b/corpus/printout/native/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..c3065458f8ecbf8705e5956b2ccda2448434d036
--- /dev/null
+++ b/corpus/printout/native/source.json
@@ -0,0 +1,14 @@
+[
+ {
+ "path": "Open Notebook.onetoc2",
+ "bytes": 4816,
+ "sha256": "fb4c3df57bb164e4a104f3ec15d65b00a4faf85325a2079096a577a1ff304b45",
+ "mtime_ns": 1790739438446243335
+ },
+ {
+ "path": "Printouts.one",
+ "bytes": 249304,
+ "sha256": "2d75e3cfce89a2e0a18dd97b9fbaab9636a68336c3c0493b1fd5249670f7568d",
+ "mtime_ns": 1790739438418795212
+ }
+]
diff --git a/corpus/printout/native/teardown.json b/corpus/printout/native/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/printout/native/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/corpus/recording/README.md b/corpus/recording/README.md
index 768e35f66f67827d5826547d8e0c88b972c9e540..bd403090aa5675ecf49a803cd7683f2026315252 100644
--- a/corpus/recording/README.md
+++ b/corpus/recording/README.md
@@ -32,6 +32,30 @@ was not observed):
click plays from the linked moment.
- **Deleting** the recording removes AudioRecordingGuids from the page; the
notes keep their links (`native/deleted`).
+- **Pause** while recording (the Pause button again resumes) leaves the pause
+ out of the clock; a note written while paused is not linked
+ (`native/read/paused-while-recording.xml`).
+- **See Playback** (on by default) highlights, as a selection, the note
+ linked last at or before the moment playing; the line saying when, linked at
+ 0, is not highlighted (`native/read/onenote-see-playback.png`). When the
+ note playing is off screen OneNote scrolls just far enough to show it
+ (`native/read/onenote-see-playback-scrolls.png`: 60 lines between two
+ notes, played from the top of the page). A linked
+ note plays from five seconds before its moment, the default of Options,
+ Audio & Video (`native/read/onenote-audio-video-options.png`), which also
+ shows the default video profile, Windows Media Video 8 for Local Area Network
+ (256 Kbps). Control-Alt-P plays and Control-Alt-S stops; Control-Alt-A does
+ not record.
+- **Record Video** without a camera says "No camera available. Make sure the
+ camera is properly installed and connected." (`native/read/onenote-no-camera.png`).
+ Installing a virtual camera in the clone was refused, so OneNote's own video
+ recording was not observed.
+- **0x1c001cc8** is a run list of {u32 character position, u8 state} on text
+ objects, OneNote's proofing state: its page date and time and the line saying
+ when recording started always carry `00 00 00 00 03` (three recordings, all
+ pages of `native/`), typed text none or 0, 1 or 9 (a misspelling), equations
+ 5. OneNote writes and rereads it itself and reads text without it, so
+ Snowbound writes it only on new pages' dates, as before.
- **Attach File** of a .wav makes it a recording as well: identity,
IRecordMedia 1 and its length (1,000 ms), listed on the page
(`native/attached`). MS-ONE requires this of .wma, .mp3, .wav, .wmv, .avi and
@@ -62,4 +86,18 @@ link. Regenerate with `ONESTORE_RECORDING_EDIT_EXPORT`, then
`tools/native_runner.py DIR COLD --expected-pages 2 --collect-notebook
--screenshots`.
+`video/` is Record Video. `native/read/attached-video.xml` is OneNote
+attaching one file of each kind: .avi, .mpg and .wmv become recordings (kind 2,
+with their length), .mp4 and .mov plain files. OneNote played Motion JPEG AVI
+with PCM or IMA ADPCM sound, MPEG-1 and WMV 8 with pictures
+(`onenote-plays-attached-*.png`), but H.264 in AVI without them. `video/candidate`
+is `a_video_recording_stores_its_file_line_and_linked_notes_for_onenote` in
+`crates/snowbound/src/recording.rs`: two seconds of Motion JPEG at 320 by 240,
+15 a second, with 16 kHz PCM, as Snowbound records video. `video/cold` is a
+fresh OneNote 2010 read; OneNote played it from a linked note with See Playback
+highlighting it (`video/played-from-note.png`), and played what the macOS
+conversion makes of `video/camera.mov` (H.264 and AAC, as a capture session
+records) and what GStreamer recorded in a Debian 13 VM from its test sources
+(`video/onenote-plays-*.png`). Regenerate with `SNOWBOUND_VIDEO_EXPORT`.
+
`tools/test_recording.py` checks the rows without a VM.
diff --git a/corpus/recording/native/read/attached-video.xml b/corpus/recording/native/read/attached-video.xml
new file mode 100644
index 0000000000000000000000000000000000000000..84ed590489c50103f81c9e55b5244ae1d03b10aa
--- /dev/null
+++ b/corpus/recording/native/read/attached-video.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/corpus/recording/native/read/onenote-audio-video-options.png b/corpus/recording/native/read/onenote-audio-video-options.png
new file mode 100644
index 0000000000000000000000000000000000000000..394a7dd072c0fe86c0228648cc7d2a5e9a6927c5
Binary files /dev/null and b/corpus/recording/native/read/onenote-audio-video-options.png differ
diff --git a/corpus/recording/native/read/onenote-h264-avi-no-picture.png b/corpus/recording/native/read/onenote-h264-avi-no-picture.png
new file mode 100644
index 0000000000000000000000000000000000000000..8b249ebde1e5af6b99263e7b43ea9b6e32dbe182
Binary files /dev/null and b/corpus/recording/native/read/onenote-h264-avi-no-picture.png differ
diff --git a/corpus/recording/native/read/onenote-no-camera.png b/corpus/recording/native/read/onenote-no-camera.png
new file mode 100644
index 0000000000000000000000000000000000000000..932b4c8b0d25d7c99b8775197aa2298f8dcd7af1
Binary files /dev/null and b/corpus/recording/native/read/onenote-no-camera.png differ
diff --git a/corpus/recording/native/read/onenote-plays-attached-mjpeg-avi.png b/corpus/recording/native/read/onenote-plays-attached-mjpeg-avi.png
new file mode 100644
index 0000000000000000000000000000000000000000..f4bec1789413b3bbc49f91ac7145a502b53f0bd4
Binary files /dev/null and b/corpus/recording/native/read/onenote-plays-attached-mjpeg-avi.png differ
diff --git a/corpus/recording/native/read/onenote-plays-attached-wmv.png b/corpus/recording/native/read/onenote-plays-attached-wmv.png
new file mode 100644
index 0000000000000000000000000000000000000000..8d057b265109731458b005f48b7a4bbe249caa19
Binary files /dev/null and b/corpus/recording/native/read/onenote-plays-attached-wmv.png differ
diff --git a/corpus/recording/native/read/onenote-see-playback-scrolls.png b/corpus/recording/native/read/onenote-see-playback-scrolls.png
new file mode 100644
index 0000000000000000000000000000000000000000..62f0a51a4087c2350e5e23066d60aa5daf9c9d8b
Binary files /dev/null and b/corpus/recording/native/read/onenote-see-playback-scrolls.png differ
diff --git a/corpus/recording/native/read/onenote-see-playback.png b/corpus/recording/native/read/onenote-see-playback.png
new file mode 100644
index 0000000000000000000000000000000000000000..5c83faed227532c8b7b6848ef9818456bd1691da
Binary files /dev/null and b/corpus/recording/native/read/onenote-see-playback.png differ
diff --git a/corpus/recording/native/read/paused-while-recording.xml b/corpus/recording/native/read/paused-while-recording.xml
new file mode 100644
index 0000000000000000000000000000000000000000..007671afd64075c61228bc252fe958a85d93c78f
--- /dev/null
+++ b/corpus/recording/native/read/paused-while-recording.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/corpus/recording/video/candidate/Open Notebook.onetoc2 b/corpus/recording/video/candidate/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..aa0819c3179b20ea52f6361460bd3003d814f5c9
Binary files /dev/null and b/corpus/recording/video/candidate/Open Notebook.onetoc2 differ
diff --git a/corpus/recording/video/candidate/Recorded.one b/corpus/recording/video/candidate/Recorded.one
new file mode 100644
index 0000000000000000000000000000000000000000..723f53fdd4193b7005eab60cd7be67f3f82b370b
Binary files /dev/null and b/corpus/recording/video/candidate/Recorded.one differ
diff --git a/corpus/recording/video/cold/commands.jsonl b/corpus/recording/video/cold/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..da0dbf647198a007373f73ceb7c0a26d21e22e4b
--- /dev/null
+++ b/corpus/recording/video/cold/commands.jsonl
@@ -0,0 +1,3 @@
+{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-63505C69 -ExpectedPages 1 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
diff --git a/corpus/recording/video/cold/machine.json b/corpus/recording/video/cold/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..f8040c3d4b13fd890caac0bc38de094e461139d9
--- /dev/null
+++ b/corpus/recording/video/cold/machine.json
@@ -0,0 +1 @@
+{"name": "m6-63505c69", "hostname": "ONE-M6-63505C69"}
diff --git a/corpus/recording/video/cold/notebook/Open Notebook.onetoc2 b/corpus/recording/video/cold/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..4e9240932d07eb8a9464f8495c37a1f42090ff2c
Binary files /dev/null and b/corpus/recording/video/cold/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/recording/video/cold/notebook/Recorded.one b/corpus/recording/video/cold/notebook/Recorded.one
new file mode 100644
index 0000000000000000000000000000000000000000..fcf998026cf62ca0b776b5a79c5e637d76f530fd
Binary files /dev/null and b/corpus/recording/video/cold/notebook/Recorded.one differ
diff --git a/corpus/recording/video/cold/read/35256fff9b37a188c746f569cb7b12611d9718c9c4db3737f89a7e1fa11f2b94.attachment b/corpus/recording/video/cold/read/35256fff9b37a188c746f569cb7b12611d9718c9c4db3737f89a7e1fa11f2b94.attachment
new file mode 100644
index 0000000000000000000000000000000000000000..0d89f8eb6e6f46a821a2c46ee2ceb628532fb4de
Binary files /dev/null and b/corpus/recording/video/cold/read/35256fff9b37a188c746f569cb7b12611d9718c9c4db3737f89a7e1fa11f2b94.attachment differ
diff --git a/corpus/recording/video/cold/read/environment.json b/corpus/recording/video/cold/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..bba07c73bd6436f0a600c99f4883d440e6c93e04
--- /dev/null
+++ b/corpus/recording/video/cold/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-63505C69",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/recording/video/cold/read/hierarchy.xml b/corpus/recording/video/cold/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..e4068f4d99c1146ee46e7aa88c19f2def2db16df
--- /dev/null
+++ b/corpus/recording/video/cold/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/recording/video/cold/read/page-000.navigation.json b/corpus/recording/video/cold/read/page-000.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..c9ab685397f07722917e45eaa7f4bdb4cfa4d227
--- /dev/null
+++ b/corpus/recording/video/cold/read/page-000.navigation.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Before recording - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/recording/video/cold/read/page-000.png b/corpus/recording/video/cold/read/page-000.png
new file mode 100644
index 0000000000000000000000000000000000000000..5e2d270f84ab5c285cc4a86b39e315fb0ce5d6f2
Binary files /dev/null and b/corpus/recording/video/cold/read/page-000.png differ
diff --git a/corpus/recording/video/cold/read/page-000.xml b/corpus/recording/video/cold/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..97ebe77587bde2d84fdc6e8c9a1411dbe037d479
--- /dev/null
+++ b/corpus/recording/video/cold/read/page-000.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/recording/video/cold/read/payloads.json b/corpus/recording/video/cold/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..7227f0249a82a6fb2b74f7f920605c500f3f2f31
--- /dev/null
+++ b/corpus/recording/video/cold/read/payloads.json
@@ -0,0 +1,10 @@
+[
+ {
+ "sha256": "35256fff9b37a188c746f569cb7b12611d9718c9c4db3737f89a7e1fa11f2b94",
+ "name": "Recorded.avi",
+ "object": "{BC2E9618-4A21-03A7-3CB4-FDCB55D334AB}{20}{B0}",
+ "kind": "MediaFile",
+ "page": "{BA8D3BEF-1250-0DF7-044C-ACACE947896E}{1}{B0}",
+ "bytes": 266580
+ }
+]
\ No newline at end of file
diff --git a/corpus/recording/video/cold/run.json b/corpus/recording/video/cold/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..b708808d744ffe37bee07175be098acb1b270ef4
--- /dev/null
+++ b/corpus/recording/video/cold/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/private/tmp/snowbound-recording/video-candidate",
+ "expected_pages": 1,
+ "author": null,
+ "author_timeout_seconds": 600,
+ "inspect": false,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
+ }
+}
diff --git a/corpus/recording/video/cold/scripts/cold.ps1 b/corpus/recording/video/cold/scripts/cold.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f
--- /dev/null
+++ b/corpus/recording/video/cold/scripts/cold.ps1
@@ -0,0 +1,27 @@
+param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
+$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
+if ($CloneHost) {
+ if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
+ throw 'The disposable clone hostname does not match this machine.'
+ }
+ New-Item "$key\Options\Paths" -Force | Out-Null
+ New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
+} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
+ -not (Test-Path 'C:\one-tests\profile-original-cache')) {
+ throw 'Park the personal OneNote profile before resetting the test cache.'
+}
+$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
+$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
+if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
+New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
+if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
+if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
+New-Item "$key\OpenNotebooks" | Out-Null
+New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
diff --git a/corpus/recording/video/cold/scripts/read.ps1 b/corpus/recording/video/cold/scripts/read.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a42366b32e8baaf13dae92e9472cae7a071220cc
--- /dev/null
+++ b/corpus/recording/video/cold/scripts/read.ps1
@@ -0,0 +1,144 @@
+param(
+ [Parameter(Mandatory=$true)][string]$Root,
+ [int]$ExpectedPages = -1,
+ [switch]$UseCurrentCache,
+ [switch]$Pdf,
+ [switch]$KeepOpen,
+ [string]$CloneHost = ''
+)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+$notebook = Join-Path $root 'notebook'
+$output = Join-Path $root 'read'
+if (Test-Path $output) { throw 'Choose a new read destination.' }
+if ($UseCurrentCache) {
+ if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
+ throw 'Park the personal OneNote profile before reading test notebooks.'
+ }
+} else {
+ & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
+}
+New-Item -ItemType Directory -Path $output | Out-Null
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+$failure = $null
+try {
+ $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
+ $process = Get-Process ONENOTE
+ @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
+ powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
+ ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
+ $sections = @()
+ foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
+ $id = ''
+ $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
+ $sections += $id
+ }
+ $deadline = [DateTime]::UtcNow.AddSeconds(300)
+ $previous = ''
+ $lastChange = ''
+ $stableSince = [DateTime]::UtcNow
+ $settled = $false
+ do {
+ $pages = @{}
+ foreach ($section in $sections) {
+ $hierarchy = ''
+ $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
+ [xml]$xml = $hierarchy
+ foreach ($node in $xml.SelectNodes('//*[@path]')) {
+ if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'OneNote opened a section outside the copied notebook.'
+ }
+ }
+ foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
+ $id = $node.GetAttribute('ID')
+ $content = ''
+ $app.GetPageContent($id, [ref]$content, 1, 1)
+ $pages[$id] = $content
+ }
+ }
+ $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
+ if ($signature -ne $previous) {
+ $lastChange = $previous
+ $previous = $signature
+ $stableSince = [DateTime]::UtcNow
+ }
+ if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
+ ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
+ ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
+ Start-Sleep -Milliseconds 250
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
+ [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
+ $index = 0
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ $index++
+ }
+ $hierarchy = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
+ [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
+ throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
+ }
+ $index = 0
+ $payloads = @()
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ if ($Pdf) {
+ $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
+ $app.NavigateTo($id, '', $false)
+ $app.Publish($id, $pdfPath, 3, '')
+ if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
+ }
+ [xml]$page = $pages[$id]
+ foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
+ $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
+ $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
+ [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
+ # A paragraph's file is named by its paragraph, one on the page by itself.
+ $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
+ $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
+ kind = $file.LocalName; name = $file.GetAttribute('preferredName');
+ sha256 = $hash; bytes = $bytes.Length }
+ }
+ $index++
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
+ $all = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
+ [xml]$finalTree = $all
+ $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
+ if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
+ throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
+ Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
+} catch {
+ $failure = $_
+ [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ throw
+} finally {
+ try {
+ try {
+ if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
+ if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
+ $app.CloseNotebook($notebookId, $false)
+ }
+ } catch {
+ if ($null -eq $failure) { throw }
+ [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ }
+ } finally {
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ }
+ $app = $null
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+ if (-not $UseCurrentCache -and -not $CloneHost) {
+ Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
+ }
+}
diff --git a/corpus/recording/video/cold/source.json b/corpus/recording/video/cold/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..689e9fb8904f2bea1692877ca41dca98fcdb76ea
--- /dev/null
+++ b/corpus/recording/video/cold/source.json
@@ -0,0 +1,14 @@
+[
+ {
+ "path": "Open Notebook.onetoc2",
+ "bytes": 1604,
+ "sha256": "c38d816f868de6acaadc7f3cd6e6f6f6b8322c73cbed1ece005a260d94574c04",
+ "mtime_ns": 1790735554335753866
+ },
+ {
+ "path": "Recorded.one",
+ "bytes": 276984,
+ "sha256": "773042e6ff29189dbb1e20b5ff22cfd40078aefce5ed706d1f2f9fddf41d52fe",
+ "mtime_ns": 1790735554327711884
+ }
+]
diff --git a/corpus/recording/video/cold/teardown.json b/corpus/recording/video/cold/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/recording/video/cold/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/corpus/recording/video/onenote-plays-gstreamer.png b/corpus/recording/video/onenote-plays-gstreamer.png
new file mode 100644
index 0000000000000000000000000000000000000000..8216fe6deb05c8eca1beb7da7935bfe31c80f804
Binary files /dev/null and b/corpus/recording/video/onenote-plays-gstreamer.png differ
diff --git a/corpus/recording/video/onenote-plays-macos-conversion.png b/corpus/recording/video/onenote-plays-macos-conversion.png
new file mode 100644
index 0000000000000000000000000000000000000000..49cdf9d55c2cb2ebf160715705129e71920f18d6
Binary files /dev/null and b/corpus/recording/video/onenote-plays-macos-conversion.png differ
diff --git a/corpus/recording/video/played-from-note.png b/corpus/recording/video/played-from-note.png
new file mode 100644
index 0000000000000000000000000000000000000000..e9b09b08c6353535b52659790e37b4b39ca5c03a
Binary files /dev/null and b/corpus/recording/video/played-from-note.png differ
diff --git a/corpus/table-tags/README.md b/corpus/table-tags/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..ab3d742c786dafa9d2b1fca8c2eaa110f58ff71c
--- /dev/null
+++ b/corpus/table-tags/README.md
@@ -0,0 +1,19 @@
+# Note tags on tables through the editor
+
+`native/` is OneNote 2010 authoring `tools/native/pages.ps1` input: three pages in
+`Tables.one`, each a table between two paragraphs, tagged To Do (unchecked) or
+Important, the last also holding a To Do-tagged table in a cell. OneNote stores a
+table's tags on the table object, and draws them in the tag column centred on it.
+
+`candidate/` is the output of `tagged_tables_draw_check_and_survive_edits` in
+`crates/canvas/tests/table_tags.rs`: on "Check a table" a click checks the table's
+box; on "Edit a tagged table" text is typed and Enter pressed in a cell; "Delete and
+undo" has all its content deleted and the deletion undone, each published as its own
+revision.
+
+`cold/` is a fresh OneNote 2010 read: the first table's To Do is completed, the edited
+table keeps its tag, and the restored tables read as OneNote authored them.
+`tools/test_table_tags.py` checks this without a VM. Regenerate with
+`SNOWBOUND_TABLE_TAGS_EXPORT` set to a new absolute directory while running the test,
+then cold-open it with
+`tools/native_runner.py OUTPUT COLD --expected-pages 3 --collect-notebook --screenshots`.
diff --git a/corpus/table-tags/candidate/Open Notebook.onetoc2 b/corpus/table-tags/candidate/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..39f970ae66fc1d29760cee79cea2700673bba7e7
Binary files /dev/null and b/corpus/table-tags/candidate/Open Notebook.onetoc2 differ
diff --git a/corpus/table-tags/candidate/Tables.one b/corpus/table-tags/candidate/Tables.one
new file mode 100644
index 0000000000000000000000000000000000000000..8c3627f8c8fe1106c5d32b7a17c391aa01714e3e
Binary files /dev/null and b/corpus/table-tags/candidate/Tables.one differ
diff --git a/corpus/table-tags/cold/commands.jsonl b/corpus/table-tags/cold/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..bbf1e00f7199d0b0b5bc2267593c23998beb5b86
--- /dev/null
+++ b/corpus/table-tags/cold/commands.jsonl
@@ -0,0 +1,3 @@
+{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-9A109FC4 -ExpectedPages 3 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 3 pages.\r\n", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
diff --git a/corpus/table-tags/cold/machine.json b/corpus/table-tags/cold/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..e1085660bf10cd8edf653473961bf1a106fa63ef
--- /dev/null
+++ b/corpus/table-tags/cold/machine.json
@@ -0,0 +1 @@
+{"name": "m6-9a109fc4", "hostname": "ONE-M6-9A109FC4"}
diff --git a/corpus/table-tags/cold/notebook/Open Notebook.onetoc2 b/corpus/table-tags/cold/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..40a6f9d09a3c86b6a4c09fe9b0d8c81c54a87a68
Binary files /dev/null and b/corpus/table-tags/cold/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/table-tags/cold/notebook/Tables.one b/corpus/table-tags/cold/notebook/Tables.one
new file mode 100644
index 0000000000000000000000000000000000000000..af87f7f9e254e3b2008eed3f307fbf1b9c56db19
Binary files /dev/null and b/corpus/table-tags/cold/notebook/Tables.one differ
diff --git a/corpus/table-tags/cold/read/environment.json b/corpus/table-tags/cold/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..c01f13df85eac7604bdb97fb2107805e74b9d6e7
--- /dev/null
+++ b/corpus/table-tags/cold/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-9A109FC4",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/table-tags/cold/read/hierarchy.xml b/corpus/table-tags/cold/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..3da0df5f723fe638f2e6251875fb569a2eda9a38
--- /dev/null
+++ b/corpus/table-tags/cold/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/table-tags/cold/read/page-000.navigation.json b/corpus/table-tags/cold/read/page-000.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..0a7b9e0ec6b6277b956d5177ea958a524786fb8b
--- /dev/null
+++ b/corpus/table-tags/cold/read/page-000.navigation.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Check a table - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/table-tags/cold/read/page-000.png b/corpus/table-tags/cold/read/page-000.png
new file mode 100644
index 0000000000000000000000000000000000000000..0626d0ab6fc7086414a7c1685e3cb6d9e18cee95
Binary files /dev/null and b/corpus/table-tags/cold/read/page-000.png differ
diff --git a/corpus/table-tags/cold/read/page-000.xml b/corpus/table-tags/cold/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..c5ff97d9a2bfd89f10a9a301690d78ca14febd06
--- /dev/null
+++ b/corpus/table-tags/cold/read/page-000.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/table-tags/cold/read/page-001.navigation.json b/corpus/table-tags/cold/read/page-001.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..f96bf51a7b355a67f40c9bb4d1d7f1cf04129a47
--- /dev/null
+++ b/corpus/table-tags/cold/read/page-001.navigation.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Delete and undo - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/table-tags/cold/read/page-001.png b/corpus/table-tags/cold/read/page-001.png
new file mode 100644
index 0000000000000000000000000000000000000000..0055c717996876b848a660af2af3f19e8f2ceb70
Binary files /dev/null and b/corpus/table-tags/cold/read/page-001.png differ
diff --git a/corpus/table-tags/cold/read/page-001.xml b/corpus/table-tags/cold/read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..e765cf72eb634d20a49f0ad3df4cae8169081a9c
--- /dev/null
+++ b/corpus/table-tags/cold/read/page-001.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/table-tags/cold/read/page-002.navigation.json b/corpus/table-tags/cold/read/page-002.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..c40a308d9c3c9026ea7bafe144254a80248bacb1
--- /dev/null
+++ b/corpus/table-tags/cold/read/page-002.navigation.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Edit a tagged table - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/table-tags/cold/read/page-002.png b/corpus/table-tags/cold/read/page-002.png
new file mode 100644
index 0000000000000000000000000000000000000000..168059c761f2754d8c3fa29fe81863dc1b5789eb
Binary files /dev/null and b/corpus/table-tags/cold/read/page-002.png differ
diff --git a/corpus/table-tags/cold/read/page-002.xml b/corpus/table-tags/cold/read/page-002.xml
new file mode 100644
index 0000000000000000000000000000000000000000..c81287dffb2e3c1025bc7d924d8f7e0b54a226c6
--- /dev/null
+++ b/corpus/table-tags/cold/read/page-002.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/table-tags/cold/read/payloads.json b/corpus/table-tags/cold/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843
--- /dev/null
+++ b/corpus/table-tags/cold/read/payloads.json
@@ -0,0 +1,3 @@
+[
+
+]
\ No newline at end of file
diff --git a/corpus/table-tags/cold/run.json b/corpus/table-tags/cold/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..d183ba83737d72b30679229476b610e2744485f3
--- /dev/null
+++ b/corpus/table-tags/cold/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/private/tmp/unsup-lab/table-tags-candidate",
+ "expected_pages": 3,
+ "author": null,
+ "author_timeout_seconds": 600,
+ "inspect": false,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
+ }
+}
diff --git a/corpus/table-tags/cold/scripts/cold.ps1 b/corpus/table-tags/cold/scripts/cold.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f
--- /dev/null
+++ b/corpus/table-tags/cold/scripts/cold.ps1
@@ -0,0 +1,27 @@
+param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
+$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
+if ($CloneHost) {
+ if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
+ throw 'The disposable clone hostname does not match this machine.'
+ }
+ New-Item "$key\Options\Paths" -Force | Out-Null
+ New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
+} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
+ -not (Test-Path 'C:\one-tests\profile-original-cache')) {
+ throw 'Park the personal OneNote profile before resetting the test cache.'
+}
+$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
+$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
+if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
+New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
+if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
+if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
+New-Item "$key\OpenNotebooks" | Out-Null
+New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
diff --git a/corpus/table-tags/cold/scripts/read.ps1 b/corpus/table-tags/cold/scripts/read.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a42366b32e8baaf13dae92e9472cae7a071220cc
--- /dev/null
+++ b/corpus/table-tags/cold/scripts/read.ps1
@@ -0,0 +1,144 @@
+param(
+ [Parameter(Mandatory=$true)][string]$Root,
+ [int]$ExpectedPages = -1,
+ [switch]$UseCurrentCache,
+ [switch]$Pdf,
+ [switch]$KeepOpen,
+ [string]$CloneHost = ''
+)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+$notebook = Join-Path $root 'notebook'
+$output = Join-Path $root 'read'
+if (Test-Path $output) { throw 'Choose a new read destination.' }
+if ($UseCurrentCache) {
+ if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
+ throw 'Park the personal OneNote profile before reading test notebooks.'
+ }
+} else {
+ & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
+}
+New-Item -ItemType Directory -Path $output | Out-Null
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+$failure = $null
+try {
+ $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
+ $process = Get-Process ONENOTE
+ @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
+ powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
+ ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
+ $sections = @()
+ foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
+ $id = ''
+ $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
+ $sections += $id
+ }
+ $deadline = [DateTime]::UtcNow.AddSeconds(300)
+ $previous = ''
+ $lastChange = ''
+ $stableSince = [DateTime]::UtcNow
+ $settled = $false
+ do {
+ $pages = @{}
+ foreach ($section in $sections) {
+ $hierarchy = ''
+ $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
+ [xml]$xml = $hierarchy
+ foreach ($node in $xml.SelectNodes('//*[@path]')) {
+ if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'OneNote opened a section outside the copied notebook.'
+ }
+ }
+ foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
+ $id = $node.GetAttribute('ID')
+ $content = ''
+ $app.GetPageContent($id, [ref]$content, 1, 1)
+ $pages[$id] = $content
+ }
+ }
+ $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
+ if ($signature -ne $previous) {
+ $lastChange = $previous
+ $previous = $signature
+ $stableSince = [DateTime]::UtcNow
+ }
+ if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
+ ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
+ ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
+ Start-Sleep -Milliseconds 250
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
+ [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
+ $index = 0
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ $index++
+ }
+ $hierarchy = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
+ [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
+ throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
+ }
+ $index = 0
+ $payloads = @()
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ if ($Pdf) {
+ $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
+ $app.NavigateTo($id, '', $false)
+ $app.Publish($id, $pdfPath, 3, '')
+ if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
+ }
+ [xml]$page = $pages[$id]
+ foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
+ $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
+ $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
+ [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
+ # A paragraph's file is named by its paragraph, one on the page by itself.
+ $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
+ $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
+ kind = $file.LocalName; name = $file.GetAttribute('preferredName');
+ sha256 = $hash; bytes = $bytes.Length }
+ }
+ $index++
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
+ $all = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
+ [xml]$finalTree = $all
+ $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
+ if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
+ throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
+ Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
+} catch {
+ $failure = $_
+ [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ throw
+} finally {
+ try {
+ try {
+ if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
+ if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
+ $app.CloseNotebook($notebookId, $false)
+ }
+ } catch {
+ if ($null -eq $failure) { throw }
+ [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ }
+ } finally {
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ }
+ $app = $null
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+ if (-not $UseCurrentCache -and -not $CloneHost) {
+ Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
+ }
+}
diff --git a/corpus/table-tags/cold/source.json b/corpus/table-tags/cold/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..3346fcfa112bef3f0244b9b101fe7847b76b01af
--- /dev/null
+++ b/corpus/table-tags/cold/source.json
@@ -0,0 +1,14 @@
+[
+ {
+ "path": "Open Notebook.onetoc2",
+ "bytes": 1604,
+ "sha256": "a69d74aabd379aaa9db37c38daa4f2fe20ae1fecc7a35d11415e5068b893f9b1",
+ "mtime_ns": 1790740524265850413
+ },
+ {
+ "path": "Tables.one",
+ "bytes": 31808,
+ "sha256": "6a2acc111e28655e60b76266e8fc76f7bb5d7adcf576863a574d64412e3400cd",
+ "mtime_ns": 1790740524265655662
+ }
+]
diff --git a/corpus/table-tags/cold/teardown.json b/corpus/table-tags/cold/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/table-tags/cold/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/corpus/table-tags/native/commands.jsonl b/corpus/table-tags/native/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..ac30865971d30550217d9b64502a119132fae4df
--- /dev/null
+++ b/corpus/table-tags/native/commands.jsonl
@@ -0,0 +1,4 @@
+{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\author.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-2203CF5A", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-2203CF5A -ExpectedPages 4 -UseCurrentCache -KeepOpen", "exit": 0, "stdout": "Read 2 sections and 4 pages.\r\n", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
diff --git a/corpus/table-tags/native/machine.json b/corpus/table-tags/native/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..1efbbe21cc973c27aba4bb06ffbc4a5af5000371
--- /dev/null
+++ b/corpus/table-tags/native/machine.json
@@ -0,0 +1 @@
+{"name": "m6-2203cf5a", "hostname": "ONE-M6-2203CF5A"}
diff --git a/corpus/table-tags/native/notebook/Open Notebook.onetoc2 b/corpus/table-tags/native/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..f78d19f62c90d9fd742b915ceecd506e4bfede4a
Binary files /dev/null and b/corpus/table-tags/native/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/table-tags/native/notebook/Tables.one b/corpus/table-tags/native/notebook/Tables.one
new file mode 100644
index 0000000000000000000000000000000000000000..c34b859773a3332f7d7b538d4a5661de16bc4e9b
Binary files /dev/null and b/corpus/table-tags/native/notebook/Tables.one differ
diff --git a/corpus/table-tags/native/notebook/fixture.json b/corpus/table-tags/native/notebook/fixture.json
new file mode 100644
index 0000000000000000000000000000000000000000..eacd680aa690d042c4eee051549c48d274f921f1
--- /dev/null
+++ b/corpus/table-tags/native/notebook/fixture.json
@@ -0,0 +1,19 @@
+{
+ "sections": [
+ "Tables.one"
+ ],
+ "pages": [
+ {
+ "section": "Tables.one",
+ "xml": ""
+ },
+ {
+ "section": "Tables.one",
+ "xml": ""
+ },
+ {
+ "section": "Tables.one",
+ "xml": ""
+ }
+ ]
+}
\ No newline at end of file
diff --git a/corpus/table-tags/native/notebook/synthetic.one b/corpus/table-tags/native/notebook/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..eeaf2e4894fcb7ebc8b5181352f9506e3c7703e1
Binary files /dev/null and b/corpus/table-tags/native/notebook/synthetic.one differ
diff --git a/corpus/table-tags/native/read/environment.json b/corpus/table-tags/native/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..74214c6b725249b7357bd1fce27b8854875f3e86
--- /dev/null
+++ b/corpus/table-tags/native/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-2203CF5A",
+ "cold": false,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/table-tags/native/read/hierarchy.xml b/corpus/table-tags/native/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..a94292b95fd752c43c19fbafbc57aeca18194a5c
--- /dev/null
+++ b/corpus/table-tags/native/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/table-tags/native/read/page-000.navigation.json b/corpus/table-tags/native/read/page-000.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..c40a308d9c3c9026ea7bafe144254a80248bacb1
--- /dev/null
+++ b/corpus/table-tags/native/read/page-000.navigation.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Edit a tagged table - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/table-tags/native/read/page-000.png b/corpus/table-tags/native/read/page-000.png
new file mode 100644
index 0000000000000000000000000000000000000000..de130a5b02c3c1d8a3c55b73ae989c24710d726d
Binary files /dev/null and b/corpus/table-tags/native/read/page-000.png differ
diff --git a/corpus/table-tags/native/read/page-000.xml b/corpus/table-tags/native/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..370f276a50553fe4a193d8effdd68f83713836da
--- /dev/null
+++ b/corpus/table-tags/native/read/page-000.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/table-tags/native/read/page-001.navigation.json b/corpus/table-tags/native/read/page-001.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..f96bf51a7b355a67f40c9bb4d1d7f1cf04129a47
--- /dev/null
+++ b/corpus/table-tags/native/read/page-001.navigation.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Delete and undo - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/table-tags/native/read/page-001.png b/corpus/table-tags/native/read/page-001.png
new file mode 100644
index 0000000000000000000000000000000000000000..e131fad0b5d5ebaf1ab324430c439adc93ae91c1
Binary files /dev/null and b/corpus/table-tags/native/read/page-001.png differ
diff --git a/corpus/table-tags/native/read/page-001.xml b/corpus/table-tags/native/read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..ddf48d5afec2e1bdf9614b2002f3af1fb68bb68b
--- /dev/null
+++ b/corpus/table-tags/native/read/page-001.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/table-tags/native/read/page-002.navigation.json b/corpus/table-tags/native/read/page-002.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..0a7b9e0ec6b6277b956d5177ea958a524786fb8b
--- /dev/null
+++ b/corpus/table-tags/native/read/page-002.navigation.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Check a table - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/table-tags/native/read/page-002.png b/corpus/table-tags/native/read/page-002.png
new file mode 100644
index 0000000000000000000000000000000000000000..bc19d2d6515b0a08180a8f902f1d2da59e623966
Binary files /dev/null and b/corpus/table-tags/native/read/page-002.png differ
diff --git a/corpus/table-tags/native/read/page-002.xml b/corpus/table-tags/native/read/page-002.xml
new file mode 100644
index 0000000000000000000000000000000000000000..575d498db93d21fc43d347ce4ac363495422b246
--- /dev/null
+++ b/corpus/table-tags/native/read/page-002.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/table-tags/native/read/page-003.navigation.json b/corpus/table-tags/native/read/page-003.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..002a1c45a5ba0121a28f35c11466c61b8a2aac13
--- /dev/null
+++ b/corpus/table-tags/native/read/page-003.navigation.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "A notebook created in Rust. - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/table-tags/native/read/page-003.png b/corpus/table-tags/native/read/page-003.png
new file mode 100644
index 0000000000000000000000000000000000000000..9893dede6ca83e855d42298d5833dc9075e60d19
Binary files /dev/null and b/corpus/table-tags/native/read/page-003.png differ
diff --git a/corpus/table-tags/native/read/page-003.xml b/corpus/table-tags/native/read/page-003.xml
new file mode 100644
index 0000000000000000000000000000000000000000..5128765679fdac202610d20d8787a415bfbcb630
--- /dev/null
+++ b/corpus/table-tags/native/read/page-003.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/table-tags/native/read/payloads.json b/corpus/table-tags/native/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843
--- /dev/null
+++ b/corpus/table-tags/native/read/payloads.json
@@ -0,0 +1,3 @@
+[
+
+]
\ No newline at end of file
diff --git a/corpus/table-tags/native/run.json b/corpus/table-tags/native/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..c9f245f243af74dba43adb93c313d215676bb746
--- /dev/null
+++ b/corpus/table-tags/native/run.json
@@ -0,0 +1,19 @@
+{
+ "notebook": "/private/tmp/unsup-lab/table-tags-input/notebook",
+ "expected_pages": 4,
+ "author": "tools/native/pages.ps1",
+ "author_timeout_seconds": 600,
+ "inspect": false,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "author.ps1": "dfbf0e016aa4c8486ecea9f39768c633de05ac0db8214139052ca2e9c43bbd02",
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
+ }
+}
diff --git a/corpus/table-tags/native/scripts/author.ps1 b/corpus/table-tags/native/scripts/author.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..8e428618e3452caa35da88687293b520c273b6c6
--- /dev/null
+++ b/corpus/table-tags/native/scripts/author.ps1
@@ -0,0 +1,42 @@
+param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost
+$fixture = Get-Content (Join-Path $Root 'notebook\fixture.json') -Raw -Encoding UTF8 | ConvertFrom-Json
+$inputs = Join-Path $Root 'inputs'
+New-Item -ItemType Directory -Path $inputs | Out-Null
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+try {
+ $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0)
+ $sections = @{}
+ foreach ($path in $fixture.sections) {
+ $parts = $path.Split('/')
+ if (@($parts | Where-Object { $_ -in @('', '.', '..') -or $_.Contains('\') -or $_.Contains(':') }).Count) { throw 'Invalid fixture section path.' }
+ $parent = $notebookId
+ for ($index = 0; $index -lt $parts.Count; $index++) {
+ $id = ''
+ $app.OpenHierarchy($parts[$index], $parent, [ref]$id, $(if ($index -eq $parts.Count - 1) {3} else {2}))
+ $parent = $id
+ }
+ $sections[$path] = $id
+ }
+ $index = 0
+ foreach ($item in $fixture.pages) {
+ $pageId = ''
+ $app.CreateNewPage($sections[$item.section], [ref]$pageId, 0)
+ [xml]$xml = $item.xml.Replace('__ASSETS__', [Security.SecurityElement]::Escape((Join-Path $Root 'notebook\fixture-data')))
+ $xml.DocumentElement.SetAttribute('ID', $pageId)
+ [IO.File]::WriteAllText((Join-Path $inputs ('page-{0:d3}.xml' -f $index)), $xml.OuterXml, [Text.Encoding]::UTF8)
+ $app.UpdatePageContent($xml.OuterXml, [DateTime]::MinValue, 1, $true)
+ $index++
+ }
+ $app.SyncHierarchy($notebookId)
+ $app.CloseNotebook($notebookId, $false)
+ $notebookId = ''
+} finally {
+ if ($notebookId) { $app.CloseNotebook($notebookId, $false) }
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+}
diff --git a/corpus/table-tags/native/scripts/cold.ps1 b/corpus/table-tags/native/scripts/cold.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f
--- /dev/null
+++ b/corpus/table-tags/native/scripts/cold.ps1
@@ -0,0 +1,27 @@
+param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
+$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
+if ($CloneHost) {
+ if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
+ throw 'The disposable clone hostname does not match this machine.'
+ }
+ New-Item "$key\Options\Paths" -Force | Out-Null
+ New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
+} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
+ -not (Test-Path 'C:\one-tests\profile-original-cache')) {
+ throw 'Park the personal OneNote profile before resetting the test cache.'
+}
+$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
+$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
+if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
+New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
+if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
+if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
+New-Item "$key\OpenNotebooks" | Out-Null
+New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
diff --git a/corpus/table-tags/native/scripts/read.ps1 b/corpus/table-tags/native/scripts/read.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a42366b32e8baaf13dae92e9472cae7a071220cc
--- /dev/null
+++ b/corpus/table-tags/native/scripts/read.ps1
@@ -0,0 +1,144 @@
+param(
+ [Parameter(Mandatory=$true)][string]$Root,
+ [int]$ExpectedPages = -1,
+ [switch]$UseCurrentCache,
+ [switch]$Pdf,
+ [switch]$KeepOpen,
+ [string]$CloneHost = ''
+)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+$notebook = Join-Path $root 'notebook'
+$output = Join-Path $root 'read'
+if (Test-Path $output) { throw 'Choose a new read destination.' }
+if ($UseCurrentCache) {
+ if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
+ throw 'Park the personal OneNote profile before reading test notebooks.'
+ }
+} else {
+ & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
+}
+New-Item -ItemType Directory -Path $output | Out-Null
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+$failure = $null
+try {
+ $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
+ $process = Get-Process ONENOTE
+ @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
+ powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
+ ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
+ $sections = @()
+ foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
+ $id = ''
+ $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
+ $sections += $id
+ }
+ $deadline = [DateTime]::UtcNow.AddSeconds(300)
+ $previous = ''
+ $lastChange = ''
+ $stableSince = [DateTime]::UtcNow
+ $settled = $false
+ do {
+ $pages = @{}
+ foreach ($section in $sections) {
+ $hierarchy = ''
+ $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
+ [xml]$xml = $hierarchy
+ foreach ($node in $xml.SelectNodes('//*[@path]')) {
+ if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'OneNote opened a section outside the copied notebook.'
+ }
+ }
+ foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
+ $id = $node.GetAttribute('ID')
+ $content = ''
+ $app.GetPageContent($id, [ref]$content, 1, 1)
+ $pages[$id] = $content
+ }
+ }
+ $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
+ if ($signature -ne $previous) {
+ $lastChange = $previous
+ $previous = $signature
+ $stableSince = [DateTime]::UtcNow
+ }
+ if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
+ ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
+ ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
+ Start-Sleep -Milliseconds 250
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
+ [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
+ $index = 0
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ $index++
+ }
+ $hierarchy = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
+ [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
+ throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
+ }
+ $index = 0
+ $payloads = @()
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ if ($Pdf) {
+ $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
+ $app.NavigateTo($id, '', $false)
+ $app.Publish($id, $pdfPath, 3, '')
+ if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
+ }
+ [xml]$page = $pages[$id]
+ foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
+ $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
+ $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
+ [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
+ # A paragraph's file is named by its paragraph, one on the page by itself.
+ $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
+ $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
+ kind = $file.LocalName; name = $file.GetAttribute('preferredName');
+ sha256 = $hash; bytes = $bytes.Length }
+ }
+ $index++
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
+ $all = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
+ [xml]$finalTree = $all
+ $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
+ if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
+ throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
+ Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
+} catch {
+ $failure = $_
+ [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ throw
+} finally {
+ try {
+ try {
+ if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
+ if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
+ $app.CloseNotebook($notebookId, $false)
+ }
+ } catch {
+ if ($null -eq $failure) { throw }
+ [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ }
+ } finally {
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ }
+ $app = $null
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+ if (-not $UseCurrentCache -and -not $CloneHost) {
+ Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
+ }
+}
diff --git a/corpus/table-tags/native/source.json b/corpus/table-tags/native/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..e9c597a824f4214dbe4893218db38d80f7527653
--- /dev/null
+++ b/corpus/table-tags/native/source.json
@@ -0,0 +1,20 @@
+[
+ {
+ "path": "Open Notebook.onetoc2",
+ "bytes": 1612,
+ "sha256": "fafa5dcb3cd827c7edd30c8ea2fbc957a59fb4d2a8b28b667a3c551c8261ab86",
+ "mtime_ns": 1790739021434250039
+ },
+ {
+ "path": "fixture.json",
+ "bytes": 4203,
+ "sha256": "5fef6d7a199e9f4ac5ffa07adc829a24bb9127b0d5e952c5129d7b3346a84c52",
+ "mtime_ns": 1790739021485254100
+ },
+ {
+ "path": "synthetic.one",
+ "bytes": 3348,
+ "sha256": "b0662387c3fdc256d8dbe809e3b8f73b3586559696467fb1fff8f589095fa797",
+ "mtime_ns": 1790739021434686911
+ }
+]
diff --git a/corpus/table-tags/native/teardown.json b/corpus/table-tags/native/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/table-tags/native/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/crates/canvas/Cargo.toml b/crates/canvas/Cargo.toml
index d70907696ad77a2c5a070402e11fa642ff0ec26a..d83605e92f3122f8d2820759c5d89655f6a83ae7 100644
--- a/crates/canvas/Cargo.toml
+++ b/crates/canvas/Cargo.toml
@@ -20,7 +20,7 @@ skrifa = "0.44.0"
accesskit = { version = "0.25.0", optional = true }
color_quant = { version = "1.1", optional = true }
-image = { version = "0.25", default-features = false, features = ["jpeg"], optional = true }
+image = { version = "0.25", default-features = false, features = ["jpeg", "png"], optional = true }
png = { version = "0.18", optional = true }
resvg = { version = "0.48.1", default-features = false, optional = true }
sha2 = { version = "0.11", optional = true }
diff --git a/crates/canvas/examples/unsupported_inventory.rs b/crates/canvas/examples/unsupported_inventory.rs
new file mode 100644
index 0000000000000000000000000000000000000000..2443ce8f43c091f1c86fdeedb903eaddf342e558
--- /dev/null
+++ b/crates/canvas/examples/unsupported_inventory.rs
@@ -0,0 +1,659 @@
+//! Every place the canvas shows a page's content as a placeholder, draws it read-only, or
+//! refuses an ordinary edit as content it cannot edit, across the active pages of every
+//! section under the given directories, grouped by kind as Markdown. Identical sections
+//! count once.
+//!
+//! `cargo run --release -p canvas --features gpu --example unsupported_inventory -- corpus`
+
+use canvas::{
+ document::TextPosition,
+ editor::{CanvasEditor, EditorError, TextOutline},
+ layout::{LayoutError, TextEngine},
+ outline::Arrange,
+};
+use onestore::{
+ Arena, ExGuid, Section,
+ page::{
+ Image, Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent,
+ text::{Affinity, EditError},
+ },
+};
+use std::{
+ collections::{BTreeMap, BTreeSet, hash_map::DefaultHasher},
+ hash::{Hash, Hasher},
+ path::{Path, PathBuf},
+};
+
+#[derive(Default)]
+struct Inventory {
+ /// Kind to the places it occurs, as `section — page`.
+ kinds: BTreeMap<(Stage, String), Vec>,
+ sections: usize,
+ pages: usize,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
+enum Stage {
+ /// Drawn as an "Unsupported content" (or "Image unavailable") box.
+ Placeholder,
+ /// Drawn as stored, but the editor cannot hold it.
+ ReadOnly,
+ /// An ordinary edit the editor refuses as content it cannot edit.
+ Refused,
+ /// The page or section does not open at all.
+ Failure,
+}
+
+impl Inventory {
+ fn add(&mut self, stage: Stage, kind: impl Into, place: &str) {
+ self.kinds
+ .entry((stage, kind.into()))
+ .or_default()
+ .push(place.to_owned());
+ }
+}
+
+/// MS-ONE's names for the object kinds it documents, and the observed undocumented ones.
+fn jcid_name(jcid: u32) -> String {
+ let name = match jcid {
+ 0x60007 => "section",
+ 0x60008 => "page series",
+ 0x6000b => "page",
+ 0x6000c => "outline",
+ 0x6000d => "outline element",
+ 0x6000e => "rich text",
+ 0x60011 => "picture",
+ 0x60012 => "number list",
+ 0x60014 => "ink container",
+ 0x60019 => "outline group",
+ 0x60022 => "table",
+ 0x60023 => "table row",
+ 0x60024 => "table cell",
+ 0x6002c => "title",
+ 0x60035 => "embedded file",
+ 0x60037 => "page manifest",
+ 0x6003c => "version history",
+ 0x6003d => "version proxy",
+ _ => "undocumented",
+ };
+ format!("jcid 0x{jcid:05x} ({name})")
+}
+
+fn unsupported_error(error: &EditorError) -> bool {
+ matches!(
+ error,
+ EditorError::Layout(LayoutError::UnsupportedContent)
+ | EditorError::Edit(EditError::UnsupportedContent)
+ )
+}
+
+/// What a text paragraph holds that bears on editing it.
+fn traits(text: &Paragraph) -> Vec<&'static str> {
+ let spans = text.spans();
+ let mut traits = Vec::new();
+ if text.text().contains('\u{fffc}')
+ || spans.iter().any(|s| s.format.embedded_object == Some(true))
+ {
+ traits.push("embedded object in text");
+ }
+ if spans.iter().any(|s| s.format.math == Some(true)) {
+ traits.push("equation");
+ }
+ if spans.iter().any(|s| s.format.hyperlink == Some(true)) {
+ traits.push("link");
+ }
+ if spans.iter().any(|s| s.format.hidden == Some(true)) {
+ traits.push("hidden text");
+ }
+ if text.text().is_empty() {
+ traits.push("empty");
+ }
+ traits
+}
+
+/// Why `node`, alone in an outline, does not lay out as content the canvas understands.
+fn culprit(
+ engine: &mut TextEngine,
+ outline: &Outline,
+ node: &PageParagraph,
+ page: &Page,
+) -> Option {
+ let alone = Outline {
+ paragraphs: vec![PageParagraph {
+ parent: None,
+ level: 1,
+ ..node.clone()
+ }],
+ unsupported: Vec::new(),
+ ..outline.clone()
+ };
+ match alone.layout(engine, &page.definitions) {
+ Err(LayoutError::UnsupportedContent) => {}
+ _ => return None,
+ }
+ Some(match &node.content {
+ ParagraphContent::Unsupported(u) => format!("holds {}", jcid_name(u.jcid)),
+ ParagraphContent::Table(table) if !table.tags.is_empty() => "table with note tags".into(),
+ ParagraphContent::Table(table) => {
+ let cells = || table.rows.iter().flat_map(|row| &row.cells);
+ if let Some(cell) = cells().find(|cell| !cell.unsupported.is_empty()) {
+ format!(
+ "table cell holding {}",
+ cell.unsupported
+ .iter()
+ .map(|u| jcid_name(u.jcid))
+ .collect::>()
+ .join(", ")
+ )
+ } else {
+ let inner = cells()
+ .flat_map(|cell| &cell.paragraphs)
+ .find_map(|inner| culprit(engine, outline, inner, page));
+ format!(
+ "table cell: {}",
+ inner.unwrap_or_else(|| "nested content".into())
+ )
+ }
+ }
+ ParagraphContent::Image(_) => "picture without a size".into(),
+ ParagraphContent::Ink(_) => "handwriting without strokes".into(),
+ ParagraphContent::Attachment(_) => "file".into(),
+ ParagraphContent::Text(text) => {
+ let lists: Vec = node
+ .lists
+ .iter()
+ .filter_map(|id| page.definitions.get(id))
+ .map(|definition| format!("{:?}", definition.kind))
+ .collect();
+ if !lists.is_empty() {
+ format!("list marker {}", lists.join(", "))
+ } else if text
+ .text
+ .spans()
+ .iter()
+ .any(|s| s.format.math == Some(true))
+ {
+ "equation the math layout does not parse".into()
+ } else {
+ format!("text ({})", traits(&text.text).join(", "))
+ }
+ }
+ })
+}
+
+/// Why an outline the page shows is not editable.
+fn diagnose(engine: &mut TextEngine, outline: &Outline, page: &Page) -> Vec {
+ let mut reasons: Vec = outline
+ .unsupported
+ .iter()
+ .map(|u| format!("outline child {}", jcid_name(u.jcid)))
+ .collect();
+ reasons.extend(
+ outline
+ .paragraphs
+ .iter()
+ .filter_map(|node| culprit(engine, outline, node, page)),
+ );
+ if reasons.is_empty() {
+ let text = outline.paragraphs.iter().any(|node| {
+ node.text().is_some()
+ || matches!(&node.content, ParagraphContent::Table(t)
+ if t.rows.iter().flat_map(|r| &r.cells).any(|c| !c.paragraphs.is_empty()))
+ });
+ reasons.push(if text {
+ match TextOutline::from_outline(engine, outline, &page.definitions) {
+ Err(error) => format!("editor: {error}"),
+ Ok(_) => "outline on its own is editable".into(),
+ }
+ } else {
+ let kinds: BTreeSet<&str> = outline
+ .paragraphs
+ .iter()
+ .map(|node| match node.content {
+ ParagraphContent::Image(_) => "pictures",
+ ParagraphContent::Attachment(_) => "files",
+ ParagraphContent::Ink(_) => "handwriting",
+ _ => "other",
+ })
+ .collect();
+ format!(
+ "no paragraph of text (only {})",
+ kinds.into_iter().collect::>().join(", ")
+ )
+ });
+ }
+ reasons
+}
+
+/// A picture that draws as "Image unavailable": no data, or (with `--features gpu`) data
+/// the renderer does not decode, named by its leading bytes.
+fn picture(image: &Image, at: &str, inventory: &mut Inventory, place: &str) {
+ let Some(bytes) = &image.bytes else {
+ return inventory.add(
+ Stage::Placeholder,
+ format!("{at} picture without data"),
+ place,
+ );
+ };
+ #[cfg(feature = "gpu")]
+ if [Some(bytes), image.display.as_ref()]
+ .into_iter()
+ .flatten()
+ .all(|bytes| draw::RasterImage::measure(bytes).is_err())
+ {
+ let format = match &bytes[..bytes.len().min(4)] {
+ [0x50, 0x4b, 3, 4] => "a zip package (XPS printout)",
+ [1, 0, 0, 0] => "EMF",
+ [0xd7, 0xcd, 0xc6, 0x9a] | [1, 0, 9, 0] => "WMF",
+ [b'B', b'M', ..] => "BMP",
+ _ => "an unknown format",
+ };
+ inventory.add(
+ Stage::Placeholder,
+ format!("{at} picture stored as {format}"),
+ place,
+ );
+ }
+ #[cfg(not(feature = "gpu"))]
+ let _ = bytes;
+}
+
+/// Pictures that draw as "Image unavailable" and objects the model keeps aside.
+fn scan_paragraphs(nodes: &[PageParagraph], at: &str, inventory: &mut Inventory, place: &str) {
+ for node in nodes {
+ match &node.content {
+ ParagraphContent::Unsupported(u) => inventory.add(
+ Stage::Placeholder,
+ format!("{at} paragraph holding {}", jcid_name(u.jcid)),
+ place,
+ ),
+ ParagraphContent::Image(image) => picture(image, at, inventory, place),
+ ParagraphContent::Table(table) => {
+ for cell in table.rows.iter().flat_map(|row| &row.cells) {
+ for u in &cell.unsupported {
+ inventory.add(
+ Stage::Placeholder,
+ format!("table cell child {}", jcid_name(u.jcid)),
+ place,
+ );
+ }
+ scan_paragraphs(&cell.paragraphs, "table cell", inventory, place);
+ }
+ }
+ _ => {}
+ }
+ }
+}
+
+/// Ordinary edits at every text paragraph of every editable outline: typing at its start,
+/// middle and end, Enter, Backspace at its start, Delete at its end, and deleting from its
+/// middle into the next.
+fn probe(
+ editor: &mut CanvasEditor,
+ engine: &mut TextEngine,
+ inventory: &mut Inventory,
+ place: &str,
+) {
+ let ids: Vec = editor.outlines().iter().map(|o| o.id).collect();
+ for id in ids {
+ if editor.focus_outline(id).is_err() {
+ continue;
+ }
+ let document = editor.active_outline().document();
+ let containers = containers(document.nodes());
+ let leaves: Vec<(Option, Paragraph)> = document
+ .text_nodes()
+ .map(|node| {
+ (
+ containers.get(&node.id).copied().flatten(),
+ node.text().unwrap().text.clone(),
+ )
+ })
+ .collect();
+ for (index, (container, text)) in leaves.iter().enumerate() {
+ let length = text.text().encode_utf16().count() as u32;
+ // The middle of what shows, where a click could put the caret.
+ let Ok(projection) = text.project() else {
+ continue;
+ };
+ let shown = projection.text();
+ let middle = shown
+ .text()
+ .char_indices()
+ .map(|(at, _)| at)
+ .nth(shown.text().chars().count() / 2)
+ .and_then(|at| shown.utf16_offset(at).ok())
+ .and_then(|at| projection.source_offset(at, Affinity::Downstream).ok())
+ .unwrap_or(length);
+ let at = |offset| TextPosition {
+ paragraph: index,
+ offset,
+ };
+ let next = leaves.get(index + 1);
+ let mut probes: Vec<(&str, [TextPosition; 2], Probe)> = vec![
+ ("typing at a paragraph's start", [at(0); 2], Probe::Type),
+ ("typing inside a paragraph", [at(middle); 2], Probe::Type),
+ ("typing at a paragraph's end", [at(length); 2], Probe::Type),
+ ("Enter inside a paragraph", [at(middle); 2], Probe::Enter),
+ (
+ "Delete at a paragraph's end",
+ [at(length); 2],
+ Probe::Delete,
+ ),
+ ];
+ if index > 0 {
+ probes.push((
+ "Backspace at a paragraph's start",
+ [at(0); 2],
+ Probe::Backspace,
+ ));
+ }
+ if let Some((_, next_text)) = next {
+ let end = next_text.text().encode_utf16().count() as u32 / 2;
+ probes.push((
+ "deleting a selection into the next paragraph",
+ [
+ at(middle),
+ TextPosition {
+ paragraph: index + 1,
+ offset: end,
+ },
+ ],
+ Probe::Backspace,
+ ));
+ }
+ for (name, positions, kind) in probes {
+ if editor.select(positions.into()).is_err() {
+ continue;
+ }
+ let result = match kind {
+ Probe::Type => editor.insert(engine, "x").map(|()| true),
+ Probe::Enter => editor.enter(engine, false).map(|()| true),
+ Probe::Delete => editor.delete(engine, false),
+ Probe::Backspace => editor.delete(engine, true),
+ };
+ match result {
+ Ok(true) => {
+ editor.undo(engine).ok();
+ }
+ Ok(false) => {}
+ Err(error) if unsupported_error(&error) => {
+ let crossing = positions[0].paragraph != positions[1].paragraph
+ || name.starts_with("Backspace")
+ || name.starts_with("Delete");
+ let neighbour = match name {
+ n if n.starts_with("Backspace") => leaves.get(index - 1),
+ _ if crossing => next,
+ _ => None,
+ };
+ let mut why: Vec<&str> = traits(text);
+ if let Some((other, other_text)) = neighbour {
+ if other != container {
+ why.push("across a table cell boundary");
+ }
+ why.extend(traits(other_text).into_iter().map(|t| match t {
+ "embedded object in text" => "next to an embedded object",
+ "equation" => "next to an equation",
+ "link" => "next to a link",
+ t => t,
+ }));
+ }
+ if container.is_some() {
+ why.push("in a table cell");
+ }
+ why.sort();
+ why.dedup();
+ inventory.add(
+ Stage::Refused,
+ format!("{name} [{}]", why.join(", ")),
+ place,
+ );
+ }
+ Err(_) => {}
+ }
+ }
+ }
+ }
+}
+
+#[derive(Clone, Copy)]
+enum Probe {
+ Type,
+ Enter,
+ Delete,
+ Backspace,
+}
+
+/// Each paragraph's table cell, `None` at the outline's top.
+fn containers(nodes: &[PageParagraph]) -> BTreeMap> {
+ let mut map = BTreeMap::new();
+ let mut pending = vec![(None, nodes)];
+ while let Some((container, nodes)) = pending.pop() {
+ for node in nodes {
+ map.insert(node.id, container);
+ if let ParagraphContent::Table(table) = &node.content {
+ for cell in table.rows.iter().flat_map(|row| &row.cells) {
+ pending.push((Some(cell.id), cell.paragraphs.as_slice()));
+ }
+ }
+ }
+ }
+ map
+}
+
+fn page(engine: &mut TextEngine, page: Page, place: &str, inventory: &mut Inventory) {
+ for object in &page.objects {
+ match object {
+ PageObject::Unsupported(u) => inventory.add(
+ Stage::Placeholder,
+ format!("page object {}", jcid_name(u.jcid)),
+ place,
+ ),
+ PageObject::Image(image) => picture(image, "page", inventory, place),
+ PageObject::Outline(outline) => {
+ for u in &outline.unsupported {
+ inventory.add(
+ Stage::Placeholder,
+ format!("outline child {}", jcid_name(u.jcid)),
+ place,
+ );
+ }
+ scan_paragraphs(&outline.paragraphs, "outline", inventory, place);
+ }
+ PageObject::Title(title) => {
+ for outline in &title.outlines {
+ scan_paragraphs(&outline.paragraphs, "title", inventory, place);
+ }
+ }
+ _ => {}
+ }
+ }
+ let mut editor = match CanvasEditor::from_page(page.clone(), engine) {
+ Ok(editor) => editor,
+ Err(error) => {
+ inventory.add(
+ Stage::Failure,
+ format!("page does not open: {error}"),
+ place,
+ );
+ return;
+ }
+ };
+ for object in &page.objects {
+ let (outlines, title) = match object {
+ PageObject::Outline(outline) => (std::slice::from_ref(outline), None),
+ PageObject::Title(title) => (title.outlines.as_slice(), Some(title)),
+ _ => continue,
+ };
+ for outline in outlines {
+ if editor.has_page_outline(outline.id)
+ || editor.outlines().iter().any(|o| o.id == outline.id)
+ {
+ continue;
+ }
+ if let Some(title) = title {
+ if title.date == Some(outline.id) {
+ if editor.date().is_none() {
+ inventory.add(Stage::ReadOnly, "page date the date editor declines", place);
+ }
+ continue;
+ }
+ if !outline.title {
+ continue;
+ }
+ }
+ let placeholder = matches!(
+ outline.layout(engine, &page.definitions),
+ Err(LayoutError::UnsupportedContent)
+ );
+ let at = if title.is_some() { "title" } else { "outline" };
+ for reason in diagnose(engine, outline, &page) {
+ let stage = if placeholder {
+ Stage::Placeholder
+ } else {
+ Stage::ReadOnly
+ };
+ let whole = if placeholder {
+ format!("whole {at} as a box: {reason}")
+ } else {
+ format!("{at} read-only: {reason}")
+ };
+ inventory.add(stage, whole, place);
+ }
+ }
+ }
+ probe(&mut editor, engine, inventory, place);
+}
+
+fn section(engine: &mut TextEngine, path: &Path, root: &Path, inventory: &mut Inventory) {
+ let Ok(image) = std::fs::read(path) else {
+ return;
+ };
+ let name = path
+ .strip_prefix(root)
+ .unwrap_or(path)
+ .display()
+ .to_string();
+ let arena = Arena::default();
+ let mut section = match Section::open(&arena, image) {
+ Ok(section) => section,
+ Err(error) => {
+ inventory.add(
+ Stage::Failure,
+ format!("section does not open: {}", error.message),
+ &name,
+ );
+ return;
+ }
+ };
+ inventory.sections += 1;
+ let pages = match section.pages() {
+ Ok(pages) => pages,
+ Err(error) => {
+ inventory.add(
+ Stage::Failure,
+ format!("page list does not read: {}", error.message),
+ &name,
+ );
+ return;
+ }
+ };
+ for (space, title, _) in pages {
+ inventory.pages += 1;
+ let place = format!("{name} — {title}");
+ match section.page(space) {
+ Ok(model) => page(engine, model, &place, inventory),
+ Err(error) => inventory.add(
+ Stage::Failure,
+ format!("page does not read: {}", error.message),
+ &place,
+ ),
+ }
+ }
+}
+
+fn sections(directory: &Path, out: &mut Vec) {
+ let Ok(entries) = std::fs::read_dir(directory) else {
+ return;
+ };
+ let mut entries: Vec = entries.flatten().map(|entry| entry.path()).collect();
+ entries.sort();
+ for path in entries {
+ if path.is_dir() {
+ sections(&path, out);
+ } else if path.extension().is_some_and(|e| e == "one") {
+ out.push(path);
+ }
+ }
+}
+
+fn main() {
+ let roots: Vec = std::env::args().skip(1).map(PathBuf::from).collect();
+ let mut engine = TextEngine::default();
+ let mut inventory = Inventory::default();
+ let mut seen = BTreeSet::new();
+ let mut duplicates = 0;
+ for root in &roots {
+ let mut paths = Vec::new();
+ sections(root, &mut paths);
+ for path in paths {
+ let Ok(bytes) = std::fs::read(&path) else {
+ continue;
+ };
+ let mut hasher = DefaultHasher::new();
+ bytes.hash(&mut hasher);
+ if !seen.insert(hasher.finish()) {
+ duplicates += 1;
+ continue;
+ }
+ section(&mut engine, &path, root, &mut inventory);
+ }
+ }
+ println!("# Unsupported content inventory\n");
+ println!(
+ "{} distinct sections ({duplicates} identical copies skipped), {} pages, under {}.\n",
+ inventory.sections,
+ inventory.pages,
+ roots
+ .iter()
+ .map(|r| format!("`{}`", r.display()))
+ .collect::>()
+ .join(", ")
+ );
+ let headings = [
+ (Stage::Placeholder, "Drawn as a placeholder box"),
+ (Stage::ReadOnly, "Drawn as stored, read-only"),
+ (
+ Stage::Refused,
+ "Edits refused as content the editor cannot edit",
+ ),
+ (Stage::Failure, "Does not open"),
+ ];
+ for (stage, heading) in headings {
+ let mut rows: Vec<(&String, &Vec)> = inventory
+ .kinds
+ .iter()
+ .filter(|((s, _), _)| *s == stage)
+ .map(|((_, kind), places)| (kind, places))
+ .collect();
+ rows.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then(a.0.cmp(b.0)));
+ println!("## {heading}\n");
+ if rows.is_empty() {
+ println!("None.\n");
+ continue;
+ }
+ println!("| Kind | Count | Pages | Examples |\n| --- | ---: | ---: | --- |");
+ for (kind, places) in rows {
+ let pages: BTreeSet<&String> = places.iter().collect();
+ let examples: Vec = pages.iter().take(3).map(|p| format!("`{p}`")).collect();
+ println!(
+ "| {} | {} | {} | {} |",
+ kind.replace('|', "\\|"),
+ places.len(),
+ pages.len(),
+ examples.join("
").replace('|', "\\|")
+ );
+ }
+ println!();
+ }
+}
diff --git a/crates/canvas/src/document.rs b/crates/canvas/src/document.rs
index b8c418d7570b901ebacc4afed19f0e3825a02955..cb9899ddfe5856f2f80e0590618fc548843499f2 100644
--- a/crates/canvas/src/document.rs
+++ b/crates/canvas/src/document.rs
@@ -1,5 +1,5 @@
use onestore::page::text::{EditError, Paragraph, new_id};
-use onestore::page::{PageParagraph, ParagraphContent, TextObject};
+use onestore::page::{PageParagraph, ParagraphContent, TableCell, TextObject};
use onestore::{ExGuid, document::Format};
use std::{
collections::{BTreeMap, BTreeSet},
@@ -247,10 +247,110 @@ fn divides_link(text: &Paragraph, offset: u32) -> Result {
Ok(at > 0 && link(at - 1) && link(at) && !text.text()[at..].starts_with('\u{fddf}'))
}
+/// What deleting `range`, which crosses a table's edge, leaves of `nodes`, whose
+/// first text leaf is paragraph `*next`, and whether all of them lay inside it. As OneNote 2010
+/// deletes such a selection, each container keeps what lies outside it and nothing joins
+/// across a container's edge: the end paragraphs keep their outer text, a cell inside keeps
+/// one empty paragraph, except that where the selection runs on past a table its rows with
+/// every cell inside go, and the table when all of them do. A paragraph that goes leaves its
+/// children to its parent.
+fn cut(
+ nodes: &[PageParagraph],
+ next: &mut usize,
+ range: &Range,
+) -> Result<(Vec, bool), EditError> {
+ let (start, end) = (range.start, range.end);
+ let mut kept = Vec::new();
+ let mut gone = BTreeMap::new();
+ for node in nodes {
+ let inside = match &node.content {
+ ParagraphContent::Text(text) => {
+ let at = *next;
+ *next += 1;
+ let text = &text.text;
+ let length = text.utf16_offset(text.text().len())?;
+ let shown = match at {
+ at if at == start.paragraph => Some(text.slice(0..start.offset)?),
+ at if at == end.paragraph => Some(text.slice(end.offset..length)?),
+ _ => None,
+ };
+ if let Some(shown) = shown {
+ let mut node = node.clone();
+ node.text_mut().unwrap().text = shown;
+ kept.push(node);
+ continue;
+ }
+ start.paragraph < at && at < end.paragraph
+ }
+ ParagraphContent::Table(table) => {
+ let mut table = table.clone();
+ let mut whole_rows = Vec::new();
+ for row in &mut table.rows {
+ let mut cells = Vec::new();
+ for cell in &row.cells {
+ let (paragraphs, inside) = cut(&cell.paragraphs, next, range)?;
+ cells.push((cell, paragraphs, inside));
+ }
+ let whole = cells.iter().all(|(_, _, inside)| *inside);
+ row.cells = cells
+ .into_iter()
+ .map(|(cell, paragraphs, inside)| {
+ let paragraphs = if inside {
+ let (_, _, leaf) = leaves(&cell.paragraphs, None)
+ .next()
+ .ok_or(EditError::InvalidStructure)?;
+ let mut leaf = leaf.clone();
+ let text = &mut leaf.text_mut().unwrap().text;
+ *text = text.slice(0..0)?;
+ leaf.parent = None;
+ leaf.level = 1;
+ vec![leaf]
+ } else {
+ paragraphs
+ };
+ Ok(TableCell {
+ paragraphs,
+ ..cell.clone()
+ })
+ })
+ .collect::>()?;
+ whole_rows.push(whole);
+ }
+ // Rows go only where the selection runs on past the table.
+ if end.paragraph >= *next {
+ let mut whole = whole_rows.into_iter();
+ table.rows.retain(|_| !whole.next().unwrap_or(false));
+ }
+ if table.rows.is_empty() {
+ true
+ } else {
+ let mut node = node.clone();
+ node.content = ParagraphContent::Table(table);
+ kept.push(node);
+ continue;
+ }
+ }
+ _ => start.paragraph < *next && *next <= end.paragraph,
+ };
+ if inside {
+ gone.insert(node.id, node.parent);
+ } else {
+ kept.push(node.clone());
+ }
+ }
+ let whole = kept.is_empty();
+ for node in &mut kept {
+ while let Some(parent) = node.parent.and_then(|parent| gone.get(&parent)) {
+ node.parent = *parent;
+ }
+ }
+ Ok((kept, whole))
+}
+
/// Whether a split or join keeping `first` up to UTF-16 `start` and `last` from `end` on meets
/// an embedded object at the seam or carries one to another paragraph, or joins inside an
-/// equation: the object's run data belongs to its paragraph, and an equation divides only at
-/// its edges (Enter inside one breaks its line instead, `CanvasEditor::enter`).
+/// equation's objects: the object's run data belongs to its paragraph, and an equation divides
+/// only at its edges (Enter inside one breaks its line instead, `CanvasEditor::enter`).
fn moves_object(
first: &Paragraph,
start: u32,
@@ -266,9 +366,21 @@ fn moves_object(
.chars()
.next()
.map(|c| end + c.len_utf16() as u32);
+ // An equation joins where both seams lie outside its objects (fractions, scripts and the
+ // like), as OneNote 2010 joins two equations a deletion meets.
+ let nested = |text: &str| {
+ text.chars().fold(0_i32, |depth, c| match c {
+ '\u{fdd0}' => depth + 1,
+ '\u{fdef}' => depth - 1,
+ _ => depth,
+ }) > 0
+ };
Ok(first.text()[..before].ends_with('\u{fffc}')
|| last.text()[after..].contains('\u{fffc}')
- || start > 0 && math(first, start) && following.is_some_and(|next| math(last, next)))
+ || start > 0
+ && math(first, start)
+ && following.is_some_and(|next| math(last, next))
+ && (nested(&first.text()[..before]) || nested(&last.text()[..after])))
}
/// Paragraph positions of each node's first text leaf, counting from `first`.
@@ -689,15 +801,15 @@ impl TextDocument {
if range.start > range.end {
return Err(EditError::InvalidRange);
}
+ if self.crosses_table(range.clone())? {
+ return self.replace_across(range, replacement);
+ }
let (container, start, first) = self
.leaf(range.start.paragraph)
.ok_or(EditError::InvalidRange)?;
- let (end_container, end, last) = self
+ let (_, end, last) = self
.leaf(range.end.paragraph)
.ok_or(EditError::InvalidRange)?;
- if container != end_container {
- return Err(EditError::UnsupportedContent);
- }
let nodes = self.container(container)?;
let first_text = &first.text().unwrap().text;
let last_text = &last.text().unwrap().text;
@@ -763,6 +875,66 @@ impl TextDocument {
})
}
+ /// Whether `range` crosses a table's edge: its ends lie in different containers, or a table
+ /// lies between them.
+ pub(crate) fn crosses_table(&self, range: Range) -> Result {
+ let (container, start, _) = self
+ .leaf(range.start.paragraph)
+ .ok_or(EditError::InvalidRange)?;
+ let (end_container, end, _) = self
+ .leaf(range.end.paragraph)
+ .ok_or(EditError::InvalidRange)?;
+ Ok(container != end_container
+ || self.container(container)?[start..end]
+ .iter()
+ .any(|node| matches!(node.content, ParagraphContent::Table(_))))
+ }
+
+ /// `replace` of a range crossing a table's edge, as OneNote 2010 deletes such a selection
+ /// before inserting at its start: see [`cut`].
+ fn replace_across(
+ &self,
+ range: Range,
+ replacement: Vec,
+ ) -> Result {
+ // A caret never stands inside hidden text, such as a link's field code.
+ for end in [range.start, range.end] {
+ let text = self
+ .paragraph(end.paragraph)
+ .ok_or(EditError::InvalidRange)?;
+ let at = text.byte_offset(end.offset)?;
+ let hidden = |byte: usize| {
+ text.spans()
+ .iter()
+ .find(|span| byte < span.end)
+ .is_some_and(|span| span.format.hidden == Some(true))
+ };
+ if at > 0 && hidden(at - 1) && hidden(at) {
+ return Err(EditError::InvalidRange);
+ }
+ }
+ let root = |paragraph: usize| self.starts.partition_point(|start| *start <= paragraph) - 1;
+ let (first, last) = (root(range.start.paragraph), root(range.end.paragraph));
+ let (kept, _) = cut(
+ &self.nodes[first..=last],
+ &mut self.starts[first].clone(),
+ &range,
+ )?;
+ let mut nodes = self.nodes[..first].to_vec();
+ nodes.extend(kept);
+ nodes.extend_from_slice(&self.nodes[last + 1..]);
+ let mut cut = Self::from_nodes(nodes)?;
+ let insertion = cut.replace(range.start..range.start, replacement)?;
+ cut.apply(insertion)?;
+ let end = cut.nodes.len() + last + 1 - self.nodes.len();
+ Ok(DocumentEdit {
+ columns: BTreeMap::new(),
+ container: None,
+ range: first..last + 1,
+ replacement: cut.nodes.drain(first..end).collect(),
+ })
+ }
+
/// Appends text leaf `lower`'s text to `upper`'s, keeping the upper paragraph's properties
/// and giving it the lower one's children; None unless nothing but `upper`'s hidden subtree
/// lies between them in one container. The lower text keeps its look under `base`, the
@@ -1245,9 +1417,9 @@ mod tests {
.paragraphs()
.map(Paragraph::text)
.collect::>(),
- ["after"]
+ ["", "after"]
);
- assert_eq!(document.nodes().len(), 1);
+ assert_eq!(document.nodes().len(), 2);
}
#[test]
@@ -1259,9 +1431,17 @@ mod tests {
document.replace(position(1, 2)..position(1, 3), text()),
Err(EditError::InvalidRange)
);
+ let across = document
+ .replace(position(1, 1)..position(3, 1), text())
+ .unwrap();
+ let mut deleted = document.clone();
+ deleted.apply(across).unwrap();
assert_eq!(
- document.replace(position(1, 0)..position(3, 1), text()),
- Err(EditError::UnsupportedContent)
+ deleted
+ .paragraphs()
+ .map(Paragraph::text)
+ .collect::>(),
+ ["before", "aX", "ight", "after"],
);
let ParagraphContent::Table(table) = &document.nodes()[1].content else {
panic!()
@@ -1778,9 +1958,43 @@ mod tests {
for replacement in &replacements {
let mut document = original.clone();
let planned = document.replace(start..end, replacement.clone());
- if regions[start.paragraph] != regions[end.paragraph] {
- assert_eq!(planned, Err(EditError::UnsupportedContent));
+ if regions[start.paragraph..=end.paragraph]
+ .iter()
+ .any(|region| *region != regions[start.paragraph])
+ {
+ // Across a container's edge nothing joins: the start keeps what
+ // precedes it, followed by the replacement, and the end what
+ // follows it.
+ let planned = planned.unwrap();
assert_eq!(document, original);
+ let undo = document.apply(planned).unwrap();
+ let texts: Vec<_> =
+ document.paragraphs().map(Paragraph::text).collect();
+ let before: Vec<_> =
+ original.paragraphs().map(Paragraph::text).collect();
+ assert_eq!(texts[..start.paragraph], before[..start.paragraph]);
+ let text = |position: TextPosition| before[position.paragraph];
+ let byte = |position: TextPosition| {
+ original
+ .paragraphs()
+ .nth(position.paragraph)
+ .unwrap()
+ .byte_offset(position.offset)
+ .unwrap()
+ };
+ let mut inserted: Vec =
+ replacement.iter().map(|p| p.text().to_owned()).collect();
+ inserted[0].insert_str(0, &text(start)[..byte(start)]);
+ let at = start.paragraph;
+ assert_eq!(texts[at..at + inserted.len()], inserted);
+ assert!(
+ texts[at + inserted.len()..].contains(&&text(end)[byte(end)..])
+ );
+ let after = document.clone();
+ let redo = document.apply(undo).unwrap();
+ assert_eq!(document, original, "{start:?}..{end:?}");
+ document.apply(redo).unwrap();
+ assert_eq!(document, after);
continue;
}
let planned = planned.unwrap();
diff --git a/crates/canvas/src/editor.rs b/crates/canvas/src/editor.rs
index fe67bb1d3b7542ceb716765017bf37fa2f669de1..e58c4651dbde740ddb90c48f358d46910c3811d5 100644
--- a/crates/canvas/src/editor.rs
+++ b/crates/canvas/src/editor.rs
@@ -126,6 +126,10 @@ pub struct CanvasEditor {
recording: Option,
/// Drawings being dragged and how far, shown there until dropped.
ink_drag: Option<(Vec, [f32; 2])>,
+ /// The paragraph each outline holding only pictures or files shows after them, by outline,
+ /// and whether it is stored: the first op that reaches it stores it, and undoing back to it
+ /// empty and alone removes it again, as OneNote 2010's undo does.
+ provisional: BTreeMap,
}
/// Imported page state the editable content does not carry.
@@ -280,9 +284,10 @@ impl TextOutline {
outline.layout(engine, definitions)?
};
let document = TextDocument::from_nodes(outline.paragraphs.clone())?;
- // The caret needs a paragraph of text to stand in.
+ // The caret needs a paragraph of text to stand in; a page gives an outline holding
+ // none a provisional one (`page::build`).
if document.text_nodes().next().is_none() {
- return Err(EditError::UnsupportedContent.into());
+ return Err(EditError::InvalidStructure.into());
}
Ok(Self {
id: outline.id,
@@ -532,6 +537,23 @@ impl TextOutline {
Ok(rectangles)
}
+ /// Where a mark under UTF-16 `range` of paragraph `index` runs, outline-local: each
+ /// line's left, right and baseline.
+ pub fn underlines(&self, index: usize, range: Range) -> Result, EditError> {
+ let paragraph = self.paragraph_layout(index)?;
+ let selection = ParagraphSelection::new(
+ paragraph.cursor(range.start, Affinity::Downstream)?,
+ paragraph.cursor(range.end, Affinity::Upstream)?,
+ );
+ let [x, y] = paragraph.origin;
+ Ok(paragraph
+ .text
+ .underlines(selection)
+ .into_iter()
+ .map(|[left, right, baseline]| [left + x, right + x, baseline + y])
+ .collect())
+ }
+
/// Where `position` falls in `shown_text`; a hidden paragraph falls at the start of the
/// next shown one.
pub fn utf16_offset(&self, position: TextPosition) -> Result {
@@ -889,6 +911,7 @@ impl CanvasEditor {
typing: None,
recording: None,
ink_drag: None,
+ provisional: BTreeMap::new(),
})
}
@@ -899,6 +922,7 @@ impl CanvasEditor {
mut outlines,
date,
areas,
+ provisional,
} = page::build(&mut page, engine, true)?;
let needs_caret = outlines.is_empty();
if needs_caret {
@@ -926,6 +950,10 @@ impl CanvasEditor {
};
}
editor.objects = objects;
+ editor.provisional = provisional
+ .into_iter()
+ .map(|(outline, blank)| (outline, (blank, false)))
+ .collect();
editor.header = PageHeader {
title: page.title,
identity: page.identity,
@@ -1030,6 +1058,10 @@ impl CanvasEditor {
&& let Some(kept) = own.remove(&outline.id)
{
*outline = kept;
+ match self.provisional.remove(&outline.id) {
+ Some(provisional) => fresh.provisional.insert(outline.id, provisional),
+ None => fresh.provisional.remove(&outline.id),
+ };
}
}
let position = fresh.outlines.iter().position(|outline| outline.id == id);
@@ -1132,7 +1164,13 @@ impl CanvasEditor {
let mut outline = match content {
page::Content::Editable(id) => {
match self.outlines.iter().find(|outline| outline.id == *id) {
- Some(outline) => outline.snapshot(),
+ Some(outline) => {
+ let mut outline = outline.snapshot();
+ if let Some((blank, false)) = self.provisional.get(id) {
+ outline.paragraphs.retain(|node| node.id != blank.id);
+ }
+ outline
+ }
None => continue,
}
}
@@ -1469,6 +1507,7 @@ impl CanvasEditor {
typing: None,
recording: None,
ink_drag: None,
+ provisional: BTreeMap::new(),
})
}
@@ -1647,6 +1686,16 @@ impl CanvasEditor {
}
/// Provisional input geometry outside the document's outline collection.
+ /// The caret a run of typing left while typing there still joins the run: the word
+ /// before it is still being written.
+ pub fn typing(&self) -> Option<(ExGuid, TextPosition)> {
+ let (outline, at, depth) = self.typing?;
+ (outline == self.active_outline().id
+ && self.selection().positions == [at; 2]
+ && depth == self.undo.len())
+ .then_some((outline, at))
+ }
+
pub fn caret_outline(&self) -> Option<&TextOutline> {
match &self.active {
Focus::Caret { outline, .. } => Some(outline),
@@ -2192,6 +2241,12 @@ impl CanvasEditor {
self.redo.clear();
return Ok(());
}
+ let file = match self.across(engine, |editor, engine| {
+ editor.insert_attachment(engine, file.clone())
+ })? {
+ Some(()) => return Ok(()),
+ None => file,
+ };
let [anchor, focus] = self.selection().positions;
let (start, end) = (anchor.min(focus), anchor.max(focus));
let mut format = self.typing_format(start)?;
@@ -2261,7 +2316,19 @@ impl CanvasEditor {
})
.ok_or(EditError::InvalidRange)?;
self.finish_composition();
- let image = self.remove_picture(index);
+ let mut image = self.remove_picture(index);
+ // A printout page's XPS package cannot be written back, so undoing its deletion
+ // restores the picture OneNote showed of it.
+ if image.printout.is_some()
+ && image
+ .bytes
+ .as_deref()
+ .is_some_and(|bytes| bytes.starts_with(b"PK\x03\x04"))
+ && let Some(shown) = image.display.take()
+ {
+ image.bytes = Some(shown);
+ image.printout = None;
+ }
self.undo.push(History::Picture {
index,
image: Some(Box::new(image)),
@@ -2484,6 +2551,34 @@ impl CanvasEditor {
result
}
+ /// Runs `edit` once a selection crossing a table's edge is deleted, as OneNote 2010 deletes
+ /// one before what replaces it goes in at its start, in one undo step; `None` for any
+ /// other selection.
+ fn across(
+ &mut self,
+ engine: &mut TextEngine,
+ edit: impl FnOnce(&mut Self, &mut TextEngine) -> Result,
+ ) -> Result