authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-30 15:37:45-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-30 20:53:47-07:00
log9da2660e0386b663c7472838e6e827c8d5ed5845
tree5fafaf3ad7dcd5e5186e1489837ebfcaaaf43840
parent24e89cb0f4fbe22a7b2d49f29ddfb7ec10755b4c
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: styles and themes, settings redesign with search, keyboard customization, undo across pages, faster tab switching, sync popup redesign

Also: palette recents and actions, table columns that fit their text, every OneNote content type editable, HTML paste with pictures, default font, snap to grid, page tabs on the left, recent servers, recycle bin purge, section and notebook colour, notebook properties, and .one file associations. Assisted-by: claude-opus-5.5

446 files changed, 21304 insertions(+), 3056 deletions(-)

apps/ios/Info.plist+37
...@@ -4,6 +4,21 @@...@@ -4,6 +4,21 @@
4<dict>4<dict>
5 <key>CADisableMinimumFrameDurationOnPhone</key>5 <key>CADisableMinimumFrameDurationOnPhone</key>
6 <true/>6 <true/>
7 <key>CFBundleDocumentTypes</key>
8 <array>
9 <dict>
10 <key>CFBundleTypeName</key>
11 <string>OneNote Section</string>
12 <key>CFBundleTypeRole</key>
13 <string>Editor</string>
14 <key>LSHandlerRank</key>
15 <string>Alternate</string>
16 <key>LSItemContentTypes</key>
17 <array>
18 <string>com.microsoft.onenote.section</string>
19 </array>
20 </dict>
21 </array>
7 <key>LSSupportsOpeningDocumentsInPlace</key>22 <key>LSSupportsOpeningDocumentsInPlace</key>
8 <true/>23 <true/>
9 <key>NSCameraUsageDescription</key>24 <key>NSCameraUsageDescription</key>
...@@ -38,5 +53,27 @@...@@ -38,5 +53,27 @@
38 <string>UIInterfaceOrientationLandscapeLeft</string>53 <string>UIInterfaceOrientationLandscapeLeft</string>
39 <string>UIInterfaceOrientationLandscapeRight</string>54 <string>UIInterfaceOrientationLandscapeRight</string>
40 </array>55 </array>
56 <key>UTImportedTypeDeclarations</key>
57 <array>
58 <dict>
59 <key>UTTypeConformsTo</key>
60 <array>
61 <string>public.data</string>
62 </array>
63 <key>UTTypeDescription</key>
64 <string>OneNote Section</string>
65 <key>UTTypeIdentifier</key>
66 <string>com.microsoft.onenote.section</string>
67 <key>UTTypeTagSpecification</key>
68 <dict>
69 <key>public.filename-extension</key>
70 <array>
71 <string>one</string>
72 </array>
73 <key>public.mime-type</key>
74 <string>application/onenote</string>
75 </dict>
76 </dict>
77 </array>
41</dict>78</dict>
42</plist>79</plist>
apps/ios/Snowbound/App.swift+64
...@@ -19,6 +19,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {...@@ -19,6 +19,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
19 _ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]?19 _ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]?
20 ) -> Bool {20 ) -> Bool {
21 sb_set_coordinator(coordinate)21 sb_set_coordinator(coordinate)
22 Editing.apply()
22 ICloud.start()23 ICloud.start()
23 sb_set_sync_wake { DispatchQueue.main.async { NotificationCenter.default.post(name: Sync.changed, object: nil) } }24 sb_set_sync_wake { DispatchQueue.main.async { NotificationCenter.default.post(name: Sync.changed, object: nil) } }
24 #if DEBUG25 #if DEBUG
...@@ -70,6 +71,63 @@ enum Appearance {...@@ -70,6 +71,63 @@ enum Appearance {
70 }71 }
71}72}
7273
74/// OneNote's Snap To Grid and Default font, kept between launches, which the pages opened
75/// from then on take.
76enum Editing {
77 /// OneNote 2010's Default font sizes, in points.
78 static let sizes: [Float] = [8, 9, 9.5, 10, 10.5, 11, 11.5, 12, 14, 16, 18, 20, 22, 24, 26, 28, 36, 48, 72]
79 static let faces = ["Calibri", "Arial", "Times New Roman", "Courier New", "Georgia", "Verdana"]
80
81 static var snapToGrid: Bool {
82 get { !UserDefaults.standard.bool(forKey: "ignoreGrid") }
83 set {
84 UserDefaults.standard.set(!newValue, forKey: "ignoreGrid")
85 apply()
86 }
87 }
88
89 static var face: String {
90 get { UserDefaults.standard.string(forKey: "defaultFontFace") ?? "Calibri" }
91 set {
92 UserDefaults.standard.set(newValue, forKey: "defaultFontFace")
93 apply()
94 }
95 }
96
97 static var size: Float {
98 get { UserDefaults.standard.object(forKey: "defaultFontSize") as? Float ?? 11 }
99 set {
100 UserDefaults.standard.set(newValue, forKey: "defaultFontSize")
101 apply()
102 }
103 }
104
105 static func apply() {
106 sb_set_snap_to_grid(snapToGrid)
107 sb_set_default_font(face, size)
108 }
109
110 /// Options' Default font: the face and size new text takes.
111 static func fontMenu() -> UIMenu {
112 let faces = faces.map { name in
113 let action = UIAction(title: name) { _ in face = name }
114 action.state = face == name ? .on : .off
115 return action
116 }
117 let sizes = sizes.map { points in
118 let action = UIAction(title: points.formatted()) { _ in size = points }
119 action.state = size == points ? .on : .off
120 return action
121 }
122 return UIMenu(
123 title: "Default Font", image: UIImage(systemName: "textformat"),
124 children: [
125 UIMenu(options: .displayInline, children: faces),
126 UIMenu(title: "Size", children: sizes),
127 ])
128 }
129}
130
73/// Where the reader was, reopened on the next launch as Notes reopens its last note.131/// Where the reader was, reopened on the next launch as Notes reopens its last note.
74private struct Place: Codable {132private struct Place: Codable {
75 let notebook: String133 let notebook: String
...@@ -123,6 +181,12 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro...@@ -123,6 +181,12 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro
123 NotificationCenter.default.addObserver(forName: ICloud.changed, object: nil, queue: .main) { [weak self] _ in181 NotificationCenter.default.addObserver(forName: ICloud.changed, object: nil, queue: .main) { [weak self] _ in
124 self?.notebooks.rescan { self?.restore($0) }182 self?.notebooks.rescan { self?.restore($0) }
125 }183 }
184 self.scene(scene, openURLContexts: options.urlContexts)
185 }
186
187 /// A section file Files opens in Snowbound, in place.
188 func scene(_ scene: UIScene, openURLContexts contexts: Set<UIOpenURLContext>) {
189 for context in contexts where context.url.isFileURL { notebooks.open(document: context.url) }
126 }190 }
127191
128 /// Reopens the section and page shown last, or the scripted one.192 /// Reopens the section and page shown last, or the scripted one.
apps/ios/Snowbound/CanvasView.swift+23
...@@ -193,6 +193,8 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -193,6 +193,8 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
193 object: nil)193 object: nil)
194 registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: CanvasView, _) in view.paper() }194 registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: CanvasView, _) in view.paper() }
195 formatBar.onApply = { [weak self] command in self?.apply(command) }195 formatBar.onApply = { [weak self] command in self?.apply(command) }
196 formatBar.onStyle = { [weak self] place in self?.applyStyle(place) }
197 formatBar.stylePlace = { [weak self] in self?.stylePlace ?? -1 }
196 formatBar.onDismiss = { [weak self] in _ = self?.resignFirstResponder() }198 formatBar.onDismiss = { [weak self] in _ = self?.resignFirstResponder() }
197 }199 }
198200
...@@ -879,6 +881,18 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -879,6 +881,18 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
879881
880 var pageText: String { handle.flatMap { take(sb_view_page_text($0)) } ?? "" }882 var pageText: String { handle.flatMap { take(sb_view_page_text($0)) } ?? "" }
881883
884 /// The page on `paper`, in points, as OneNote 2010 prints it, each sheet's footer naming
885 /// `section`.
886 func pdf(paper: CGSize, section: String) -> Data? {
887 guard let handle else { return nil }
888 var length = 0
889 guard let bytes = sb_view_pdf(handle, Float(paper.width), Float(paper.height), section, &length) else {
890 return nil
891 }
892 defer { sb_bytes_free(bytes, length) }
893 return Data(bytes: bytes, count: length)
894 }
895
882 /// The selection's formatting, from `sb_view_format`.896 /// The selection's formatting, from `sb_view_format`.
883 var format: UInt64 { handle.map(sb_view_format) ?? 0 }897 var format: UInt64 { handle.map(sb_view_format) ?? 0 }
884898
...@@ -934,6 +948,15 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -934,6 +948,15 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
934 edit(external: true) { sb_view_apply(handle, command) }948 edit(external: true) { sb_view_apply(handle, command) }
935 }949 }
936950
951 /// Gives the selected paragraphs style `place` of OneNote's gallery, in the page's theme.
952 func applyStyle(_ place: UInt8) {
953 guard let handle else { return }
954 edit(external: true) { sb_view_style(handle, place) }
955 }
956
957 /// The selection's style's place in the gallery, or -1.
958 var stylePlace: Int32 { handle.map(sb_view_style_place) ?? -1 }
959
937 /// Shows the page as stored; with `discard`, after an edit was refused.960 /// Shows the page as stored; with `discard`, after an edit was refused.
938 func reload(discard: Bool) {961 func reload(discard: Bool) {
939 guard let handle else { return }962 guard let handle else { return }
apps/ios/Snowbound/FormatBar.swift+21
...@@ -19,6 +19,14 @@ final class FormatBar: UIInputView {...@@ -19,6 +19,14 @@ final class FormatBar: UIInputView {
19 private var toggles: [(UIButton, UInt8)] = []19 private var toggles: [(UIButton, UInt8)] = []
20 private var bits: UInt64 = 020 private var bits: UInt64 = 0
21 var onApply: ((UInt8) -> Void)?21 var onApply: ((UInt8) -> Void)?
22 /// Applies a style of OneNote's gallery, by place; `stylePlace` names the selection's.
23 var onStyle: ((UInt8) -> Void)?
24 var stylePlace: (() -> Int32)?
25 /// OneNote 2010's Styles gallery, in its order.
26 private static let styles = [
27 "Heading 1", "Heading 2", "Heading 3", "Heading 4", "Heading 5", "Heading 6",
28 "Page Title", "Citation", "Quote", "Code", "Normal",
29 ]
22 /// Asks for a picture from the camera (true) or the photo library.30 /// Asks for a picture from the camera (true) or the photo library.
23 var onPicture: ((Bool) -> Void)?31 var onPicture: ((Bool) -> Void)?
24 var onDismiss: (() -> Void)?32 var onDismiss: (() -> Void)?
...@@ -33,6 +41,19 @@ final class FormatBar: UIInputView {...@@ -33,6 +41,19 @@ final class FormatBar: UIInputView {
33 addSubview(backdrop)41 addSubview(backdrop)
34 let row = UIStackView()42 let row = UIStackView()
35 row.spacing = 243 row.spacing = 2
44 let styles = Self.button("textformat", "Styles")
45 styles.menu = UIMenu(children: [
46 UIDeferredMenuElement.uncached { [weak self] done in
47 let current = self?.stylePlace?() ?? -1
48 done(Self.styles.enumerated().map { place, name in
49 UIAction(title: name, state: Int32(place) == current ? .on : .off) { _ in
50 self?.onStyle?(UInt8(place))
51 }
52 })
53 }
54 ])
55 styles.showsMenuAsPrimaryAction = true
56 row.addArrangedSubview(styles)
36 for (symbol, label, command) in Self.buttons {57 for (symbol, label, command) in Self.buttons {
37 let button = Self.button(symbol, label)58 let button = Self.button(symbol, label)
38 button.addAction(UIAction { [weak self] _ in self?.onApply?(command) }, for: .primaryActionTriggered)59 button.addAction(UIAction { [weak self] _ in self?.onApply?(command) }, for: .primaryActionTriggered)
apps/ios/Snowbound/ICloud.swift+14-1
...@@ -54,15 +54,27 @@ enum ICloud {...@@ -54,15 +54,27 @@ enum ICloud {
54 /// Whether iCloud is signed in with iCloud Drive on, container or not.54 /// Whether iCloud is signed in with iCloud Drive on, container or not.
55 static var signedIn: Bool { FileManager.default.ubiquityIdentityToken != nil }55 static var signedIn: Bool { FileManager.default.ubiquityIdentityToken != nil }
5656
57 /// Has the library merge conflict versions, and follows the account; call once at launch.57 /// Has the library merge conflict versions, follows the account, and hears of notebooks
58 /// another device adds or removes; call once at launch.
58 static func start() {59 static func start() {
59 sb_set_versions(listVersions, retireVersion)60 sb_set_versions(listVersions, retireVersion)
60 NotificationCenter.default.addObserver(61 NotificationCenter.default.addObserver(
61 forName: .NSUbiquityIdentityDidChange, object: nil, queue: .main62 forName: .NSUbiquityIdentityDidChange, object: nil, queue: .main
62 ) { _ in lookUp() }63 ) { _ in lookUp() }
63 lookUp()64 lookUp()
65 query.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope]
66 query.predicate = NSPredicate(format: "%K LIKE '*'", NSMetadataItemFSNameKey)
67 for name in [Notification.Name.NSMetadataQueryDidFinishGathering, .NSMetadataQueryDidUpdate] {
68 NotificationCenter.default.addObserver(forName: name, object: query, queue: .main) { _ in
69 if Notebooks.inCloudChanged { NotificationCenter.default.post(name: changed, object: nil) }
70 }
71 }
72 query.start()
64 }73 }
6574
75 /// Reports what iCloud Drive syncs of the container, as files come from other devices.
76 private static let query = NSMetadataQuery()
77
66 /// Looks the folder up off the main thread, as asking iCloud can take a while.78 /// Looks the folder up off the main thread, as asking iCloud can take a while.
67 static func lookUp() {79 static func lookUp() {
68 background({ () -> URL? in80 background({ () -> URL? in
...@@ -77,4 +89,5 @@ enum ICloud {...@@ -77,4 +89,5 @@ enum ICloud {
77 NotificationCenter.default.post(name: changed, object: nil)89 NotificationCenter.default.post(name: changed, object: nil)
78 }90 }
79 }91 }
92
80}93}
apps/ios/Snowbound/Ink.swift+7-1
...@@ -240,8 +240,9 @@ final class InkPicker: UIView {...@@ -240,8 +240,9 @@ final class InkPicker: UIView {
240 color.accessibilityLabel = "Colour, \(Self.name(shown.color))"240 color.accessibilityLabel = "Colour, \(Self.name(shown.color))"
241 }241 }
242242
243 /// The shapes, then OneNote's Snap To Grid.
243 private func shapeMenu() -> [UIMenuElement] {244 private func shapeMenu() -> [UIMenuElement] {
244 Self.shapes.enumerated().map { place, shape in245 let shapes = Self.shapes.enumerated().map { place, shape in
245 let kind = UInt8(place)246 let kind = UInt8(place)
246 let action = UIAction(title: shape.1, image: UIImage(systemName: shape.0)) { [weak self] _ in247 let action = UIAction(title: shape.1, image: UIImage(systemName: shape.0)) { [weak self] _ in
247 self?.onPick?(.shape(kind))248 self?.onPick?(.shape(kind))
...@@ -249,6 +250,11 @@ final class InkPicker: UIView {...@@ -249,6 +250,11 @@ final class InkPicker: UIView {
249 action.state = tool == .shape(kind) ? .on : .off250 action.state = tool == .shape(kind) ? .on : .off
250 return action251 return action
251 }252 }
253 let snap = UIAction(title: "Snap To Grid", image: UIImage(systemName: "grid")) { _ in
254 Editing.snapToGrid.toggle()
255 }
256 snap.state = Editing.snapToGrid ? .on : .off
257 return [UIMenu(options: .displayInline, children: shapes), UIMenu(options: .displayInline, children: [snap])]
252 }258 }
253259
254 /// The colours of the kind of pen shown, and for a pen its two widths.260 /// The colours of the kind of pen shown, and for a pen its two widths.
apps/ios/Snowbound/Lists.swift+31-4
...@@ -104,6 +104,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -104,6 +104,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
104 Author.ask(from: self) {}104 Author.ask(from: self) {}
105 })105 })
106 more.append(Appearance.menu())106 more.append(Appearance.menu())
107 more.append(Editing.fontMenu())
107 provide(actions + [UIMenu(options: .displayInline, children: more)])108 provide(actions + [UIMenu(options: .displayInline, children: more)])
108 }109 }
109 ])110 ])
...@@ -535,10 +536,13 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -535,10 +536,13 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
535536
536 private func add(_ notebook: Notebook) {537 private func add(_ notebook: Notebook) {
537 Notebooks.add(notebook)538 Notebooks.add(notebook)
539 openFirstSection(of: notebook)
540 }
541
542 private func openFirstSection(of notebook: Notebook) {
538 reload()543 reload()
539 notebook.open { [weak self] in544 notebook.open { [weak self] in
540 self?.reload()545 self?.reload()
541 // A notebook just added opens at its first section.
542 if let tab = notebook.tabs.first(where: \.readable) { self?.onOpen?(tab, notebook) }546 if let tab = notebook.tabs.first(where: \.readable) { self?.onOpen?(tab, notebook) }
543 }547 }
544 }548 }
...@@ -549,14 +553,21 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -549,14 +553,21 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
549 guard let author = Author.name else {553 guard let author = Author.name else {
550 return Author.ask(from: self) { [weak self] in self?.newNotebook(inCloud: inCloud) }554 return Author.ask(from: self) { [weak self] in self?.newNotebook(inCloud: inCloud) }
551 }555 }
552 let alert = UIAlertController(title: "New Notebook", message: nil, preferredStyle: .alert)556 let alert = UIAlertController(
557 title: inCloud ? "New iCloud Notebook" : "New Notebook", message: nil, preferredStyle: .alert)
553 let create = UIAlertAction(title: "Create", style: .default) { [weak self, weak alert] _ in558 let create = UIAlertAction(title: "Create", style: .default) { [weak self, weak alert] _ in
554 let name = alert?.textFields?.first?.text?.trimmingCharacters(in: .whitespaces) ?? ""559 let name = alert?.textFields?.first?.text?.trimmingCharacters(in: .whitespaces) ?? ""
555 self?.create(name, by: author, inCloud: inCloud)560 self?.create(name, by: author, inCloud: inCloud)
556 }561 }
557 create.isEnabled = false
558 alert.addTextField { field in562 alert.addTextField { field in
559 field.placeholder = "Name"563 field.placeholder = "Name"
564 // A notebook in iCloud Drive starts named for it, so the list tells it apart.
565 if inCloud {
566 let taken = Set(Notebooks.inCloud.map(\.name))
567 field.text = (1...).lazy.map { $0 == 1 ? "iCloud Notebook" : "iCloud Notebook \($0)" }
568 .first { !taken.contains($0) }
569 }
570 create.isEnabled = field.text?.isEmpty == false
560 field.autocapitalizationType = .words571 field.autocapitalizationType = .words
561 NotificationCenter.default.addObserver(572 NotificationCenter.default.addObserver(
562 forName: UITextField.textDidChangeNotification, object: field, queue: .main573 forName: UITextField.textDidChangeNotification, object: field, queue: .main
...@@ -634,9 +645,25 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -634,9 +645,25 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
634 }645 }
635646
636 func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {647 func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
637 guard let url = urls.first else { return }648 if let url = urls.first { open(document: url) }
649 }
650
651 /// Opens the folder or section file at `url`, chosen here or opened from Files, at its first
652 /// section, listing it unless it is listed.
653 func open(document url: URL) {
638 let accessing = url.startAccessingSecurityScopedResource()654 let accessing = url.startAccessingSecurityScopedResource()
639 defer { if accessing { url.stopAccessingSecurityScopedResource() } }655 defer { if accessing { url.stopAccessingSecurityScopedResource() } }
656 let listed = Notebooks.all.first { notebook in
657 guard case .files(let bookmark) = notebook.source else { return false }
658 var stale = false
659 let location = try? URL(resolvingBookmarkData: bookmark, bookmarkDataIsStale: &stale)
660 return location?.standardizedFileURL == url.standardizedFileURL
661 }
662 if let listed {
663 // A notebook still opening at launch lists no sections yet.
664 if let tab = listed.tabs.first(where: \.readable) { onOpen?(tab, listed) } else { openFirstSection(of: listed) }
665 return
666 }
640 guard let bookmark = try? url.bookmarkData() else { return }667 guard let bookmark = try? url.bookmarkData() else { return }
641 add(Notebook(name: url.deletingPathExtension().lastPathComponent, source: .files(bookmark: bookmark)))668 add(Notebook(name: url.deletingPathExtension().lastPathComponent, source: .files(bookmark: bookmark)))
642 }669 }
apps/ios/Snowbound/Notebooks.swift+39-6
...@@ -277,7 +277,8 @@ final class Notebook {...@@ -277,7 +277,8 @@ final class Notebook {
277 }277 }
278278
279 /// Asks iCloud Drive for the notebook files it keeps elsewhere, which iOS lists as279 /// Asks iCloud Drive for the notebook files it keeps elsewhere, which iOS lists as
280 /// `.Name.icloud`, and lists the sections again until they arrive.280 /// `.Name.icloud` or dataless under their names, and lists the sections again until they
281 /// arrive.
281 private func download() {282 private func download() {
282 let folder: URL? =283 let folder: URL? =
283 switch source {284 switch source {
...@@ -288,13 +289,17 @@ final class Notebook {...@@ -288,13 +289,17 @@ final class Notebook {
288 case .server: nil289 case .server: nil
289 }290 }
290 guard let folder, recheck == nil,291 guard let folder, recheck == nil,
291 let files = FileManager.default.enumerator(at: folder, includingPropertiesForKeys: nil)292 let files = FileManager.default.enumerator(
293 at: folder, includingPropertiesForKeys: [.ubiquitousItemDownloadingStatusKey])
292 else { return }294 else { return }
293 var waiting = false295 var waiting = false
294 for case let stub as URL in files where stub.lastPathComponent.hasPrefix(".") && stub.pathExtension == "icloud" {296 for case let file as URL in files {
295 let name = String(stub.deletingPathExtension().lastPathComponent.dropFirst())297 let stub = file.lastPathComponent.hasPrefix(".") && file.pathExtension == "icloud"
296 let file = stub.deletingLastPathComponent().appendingPathComponent(name)298 let status = try? file.resourceValues(forKeys: [.ubiquitousItemDownloadingStatusKey])
297 try? FileManager.default.startDownloadingUbiquitousItem(at: file)299 guard stub || status?.ubiquitousItemDownloadingStatus == .notDownloaded else { continue }
300 let real = file.deletingLastPathComponent().appendingPathComponent(
301 String(file.deletingPathExtension().lastPathComponent.dropFirst()))
302 try? FileManager.default.startDownloadingUbiquitousItem(at: stub ? real : file)
298 waiting = true303 waiting = true
299 }304 }
300 guard waiting else { return }305 guard waiting else { return }
...@@ -485,6 +490,13 @@ enum Notebooks {...@@ -485,6 +490,13 @@ enum Notebooks {
485 return added490 return added
486 }491 }
487492
493 /// Whether Snowbound's folder in iCloud Drive holds other notebooks than the list shows, as
494 /// when another device added or removed one.
495 static var inCloudChanged: Bool {
496 guard let folder = ICloud.documents, scripted == nil else { return false }
497 return notebooks(in: folder).map { "iCloud/" + $0 } != inCloud.map(\.id)
498 }
499
488 /// The notebook folders and section files in `folder`, by name, as Files orders them.500 /// The notebook folders and section files in `folder`, by name, as Files orders them.
489 private static func notebooks(in folder: URL) -> [String] {501 private static func notebooks(in folder: URL) -> [String] {
490 let listed = try? FileManager.default.contentsOfDirectory(502 let listed = try? FileManager.default.contentsOfDirectory(
...@@ -648,6 +660,27 @@ final class Section {...@@ -648,6 +660,27 @@ final class Section {
648 }660 }
649 }661 }
650662
663 /// The notebook's themes, and the one page `id`, its section and its notebook each name.
664 struct Themes: Decodable {
665 struct Listed: Decodable {
666 let id: String
667 let name: String
668 }
669 let themes: [Listed]
670 let page: String?
671 let section: String?
672 let notebook: String?
673 }
674
675 func themes(of id: String) -> Themes? { decode(Themes.self, sb_section_themes(handle, id)) }
676
677 /// Gives `theme`, or none of its own, to page `id` (scope 0), its section (1) or its
678 /// notebook (2).
679 func setTheme(_ theme: String?, scope: UInt8, of id: String, done: @escaping (Bool) -> Void) {
680 let pointer = Int(bitPattern: handle)
681 background({ sb_section_set_theme(OpaquePointer(bitPattern: pointer), id, scope, theme) }, done: done)
682 }
683
651 /// Stores every edit and publishes them within `seconds`; call off the main thread.684 /// Stores every edit and publishes them within `seconds`; call off the main thread.
652 func flush(_ seconds: Double) -> Bool { sb_section_flush(handle, seconds) }685 func flush(_ seconds: Double) -> Bool { sb_section_flush(handle, seconds) }
653686
apps/ios/Snowbound/PageViewController.swift+72
...@@ -169,10 +169,19 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate...@@ -169,10 +169,19 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
169 UIAction(title: "Page Background…", image: UIImage(systemName: "paintpalette"), attributes: editable) {169 UIAction(title: "Page Background…", image: UIImage(systemName: "paintpalette"), attributes: editable) {
170 [weak self] _ in self?.showPaper()170 [weak self] _ in self?.showPaper()
171 },171 },
172 themeMenu(),
172 UIAction(title: "Tags Summary", image: UIImage(systemName: "tag")) { [weak self] _ in173 UIAction(title: "Tags Summary", image: UIImage(systemName: "tag")) { [weak self] _ in
173 guard let self else { return }174 guard let self else { return }
174 scene?.showTags(of: section.notebook, from: self)175 scene?.showTags(of: section.notebook, from: self)
175 },176 },
177 UIMenu(options: .displayInline, children: [
178 UIAction(title: "Print…", image: UIImage(systemName: "printer")) { [weak self] _ in
179 self?.printPage(export: false)
180 },
181 UIAction(title: "Export as PDF…", image: UIImage(systemName: "doc.richtext")) { [weak self] _ in
182 self?.printPage(export: true)
183 },
184 ]),
176 UIMenu(options: .displayInline, children: [185 UIMenu(options: .displayInline, children: [
177 UIAction(title: "New Subpage", image: UIImage(systemName: "text.badge.plus")) { _ in186 UIAction(title: "New Subpage", image: UIImage(systemName: "text.badge.plus")) { _ in
178 scene?.newPage(subpage: true)187 scene?.newPage(subpage: true)
...@@ -186,6 +195,69 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate...@@ -186,6 +195,69 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
186 ]195 ]
187 }196 }
188197
198 /// The page's theme, its section's and its notebook's, each pickable; the page wears a
199 /// new one when it opens again.
200 private func themeMenu() -> UIMenu {
201 guard let themes = section.themes(of: page) else {
202 return UIMenu(title: "Theme", image: UIImage(systemName: "textformat"), children: [])
203 }
204 let scopes: [(String, String?)] = [
205 ("This Page", themes.page), ("This Section", themes.section), ("This Notebook", themes.notebook),
206 ]
207 let page = page
208 let menus = scopes.enumerated().map { scope, entry in
209 let (title, current) = entry
210 let choices = [(nil as String?, "None")] + themes.themes.map { ($0.id, $0.name) }
211 return UIMenu(title: title, children: choices.map { id, name in
212 UIAction(title: name, state: id == current ? .on : .off) { [weak self] _ in
213 guard let self else { return }
214 section.setTheme(id, scope: UInt8(scope), of: page) { [weak self] _ in
215 self?.onOpen?(page)
216 }
217 }
218 })
219 }
220 return UIMenu(title: "Theme", image: UIImage(systemName: "textformat"), children: menus)
221 }
222
223 /// Prints the page through the system's print sheet, or with `export` offers its PDF in
224 /// the share sheet, laid out as OneNote 2010 prints it on the region's paper.
225 private func printPage(export: Bool) {
226 let letter = ["US", "CA", "MX", "PR", "PH", "CL", "CO", "VE", "GT", "CR", "PA", "DO", "SV", "NI", "HN", "BZ"]
227 .contains(Locale.current.region?.identifier ?? "US")
228 let paper = letter ? CGSize(width: 612, height: 792) : CGSize(width: 595.276, height: 841.89)
229 let title = canvas.pageTitle ?? section.row(of: page)?.row.title ?? ""
230 guard let pdf = canvas.pdf(paper: paper, section: section.tab.name) else {
231 let alert = UIAlertController(
232 title: export ? "Couldn't Export the PDF" : "Couldn't Print",
233 message: "The page could not be laid out on paper.", preferredStyle: .alert)
234 alert.addAction(UIAlertAction(title: "OK", style: .default))
235 present(alert, animated: true)
236 return
237 }
238 if export {
239 let name = title.components(separatedBy: CharacterSet(charactersIn: "/\\:")).joined(separator: "-")
240 .trimmingCharacters(in: .whitespaces)
241 let file = FileManager.default.temporaryDirectory
242 .appendingPathComponent((name.isEmpty ? "Untitled" : name) + ".pdf")
243 do {
244 try pdf.write(to: file)
245 } catch {
246 return
247 }
248 let share = UIActivityViewController(activityItems: [file], applicationActivities: nil)
249 share.popoverPresentationController?.barButtonItem = more
250 present(share, animated: true)
251 } else {
252 let info = UIPrintInfo.printInfo()
253 info.jobName = title
254 let printer = UIPrintInteractionController.shared
255 printer.printInfo = info
256 printer.printingItem = pdf
257 printer.present(from: more, animated: true)
258 }
259 }
260
189 private func showPaper() {261 private func showPaper() {
190 let navigation = UINavigationController(rootViewController: PaperViewController(canvas: canvas))262 let navigation = UINavigationController(rootViewController: PaperViewController(canvas: canvas))
191 navigation.sheetPresentationController?.detents = [.medium(), .large()]263 navigation.sheetPresentationController?.detents = [.medium(), .large()]
arc/canvas.md+67-11
...@@ -48,6 +48,17 @@ ops still reach storage as it is typed. Undoing a deletion...@@ -48,6 +48,17 @@ ops still reach storage as it is typed. Undoing a deletion
48brings back the original identities, so internal links to those paragraphs48brings back the original identities, so internal links to those paragraphs
49survive an undo.49survive an undo.
5050
51Each page keeps its history while the window is open, as in OneNote 2010: the
52desktop app parks a page's editor when it is left and takes it up again, through
53the same reload in place, when the page opens. Between those edits the app keeps
54what was done to pages and sections (new, deleted, moved and indented pages, page
55and section names, sections moved), and Undo takes back whichever came last: the
56open page's own step, or the last such action in its section or notebook,
57showing the page it changes. Unlike OneNote, an edit doesn't end what actions Undo
58can reach, so New Page, a typed title, Undo, Undo takes the title and then the
59page. Each step back is computed against the section as it is then, one edit of
60its own; a step whose page is gone, or holds work it didn't make, is passed over.
61
51Input-method composition (marked text) stays inside the editor until it62Input-method composition (marked text) stays inside the editor until it
52commits, and only then becomes ops. When another client changes the open page,63commits, and only then becomes ops. When another client changes the open page,
53the editor compares the stored page with what it last read plus the ops it has64the editor compares the stored page with what it last read plus the ops it has
...@@ -69,7 +80,8 @@ same line heights, same places. That took measuring, not guessing....@@ -69,7 +80,8 @@ same line heights, same places. That took measuring, not guessing.
69 New Roman or Courier New, bundled metric-compatible substitutes (Carlito,80 New Roman or Courier New, bundled metric-compatible substitutes (Carlito,
70 Arimo, Tinos, Cousine) stand in, under the stored font name.81 Arimo, Tinos, Cousine) stand in, under the stored font name.
71- **OneNote's constants are OneNote's.** New outlines take OneNote's default82- **OneNote's constants are OneNote's.** New outlines take OneNote's default
72 width. Dragging snaps to its grid, anchored at the page's margin origin. Tags83 width. Dragging snaps to its grid, anchored at the page's margin origin, unless
84 Snap To Grid (the shape gallery's last item, kept between launches) is off. Tags
73 sit in a column to the left of the text with OneNote's spacing, and a tag's85 sit in a column to the left of the text with OneNote's spacing, and a tag's
74 colour paints the whole paragraph as it does there.86 colour paints the whole paragraph as it does there.
7587
...@@ -106,6 +118,12 @@ on the page itself, as OneNote 2010 places it: the same column at the caret's...@@ -106,6 +118,12 @@ on the page itself, as OneNote 2010 places it: the same column at the caret's
106grid point, outside any outline. It selects, drags on the grid, deletes and118grid point, outside any outline. It selects, drags on the grid, deletes and
107moves with Insert Space as a picture does, and opens and saves as above.119moves with Insert Space as a picture does, and opens and saves as above.
108120
121A tag on a picture or file is stored on the object itself, as OneNote 2010 stores
122the tag Ctrl+1 gives a selected one, and drawn in a column left of it, centred on
123it; a click checks its box (`corpus/object-tags`). A picture's link follows on
124Ctrl+click, Command+click on macOS, while a click selects it, as OneNote's
125tooltip says (`corpus/picture-link`).
126
109An outline holding only pictures or files takes a paragraph after them where a127An outline holding only pictures or files takes a paragraph after them where a
110click beside them lands, as OneNote 2010 adds one when typing there. It is128click beside them lands, as OneNote 2010 adds one when typing there. It is
111stored with the first edit that reaches it, and undoing back to it empty takes it129stored with the first edit that reaches it, and undoing back to it empty takes it
...@@ -114,16 +132,20 @@ out again (`corpus/object-outline`)....@@ -114,16 +132,20 @@ out again (`corpus/object-outline`).
114A file printout's pages are pictures whose stored data is the printout's XPS132A file printout's pages are pictures whose stored data is the printout's XPS
115package. The page draws the PNG OneNote rendered of each133package. The page draws the PNG OneNote rendered of each
116(WebPictureContainer14), framed in grey as OneNote frames them, and moves them as134(WebPictureContainer14), framed in grey as OneNote frames them, and moves them as
117pictures. Snowbound does not write XPS, so a printout page deleted and brought135pictures. A printout page deleted and brought back by undo returns as the page it
118back by undo returns as that picture (`corpus/printout`).136was: its XPS package, the PNG and the properties tying it to the printout, written
137back as read (`corpus/printout`).
119138
120Pictures go in as OneNote 2010 pastes and inserts them. At a caret in text the139Pictures go in as OneNote 2010 pastes and inserts them. At a caret in text the
121paragraph splits around the picture, as around a file. On blank page the picture140paragraph splits around the picture, as around a file. On blank page the picture
122lies on the page at the caret, and the caret moves to the grid row below it. A141lies on the page at the caret, and the caret moves to the grid row below it.
123picture takes the size its resolution gives it, or 96 dpi without one. Paste142From the title it joins the outline where the body starts, or lies two grid rows
124takes files first (a picture file as its picture), then text, then a picture:143below the page's content when none starts there. A picture takes the size its
125Finder offers a copied file's name and icon beside it, and other apps a picture144resolution gives it, or 96 dpi without one. Paste takes files first (a picture
126of copied text.145file as its picture, as a dropped one goes in), then a web page holding
146pictures, its text and pictures in order as one undo step, then text, then a
147picture: Finder offers a copied file's name and icon beside it, and other apps a
148picture of copied text.
127149
128## Recordings150## Recordings
129151
...@@ -176,6 +198,14 @@ tablet pressure on Linux....@@ -176,6 +198,14 @@ tablet pressure on Linux.
176198
177## Tables and selections across them199## Tables and selections across them
178200
201Columns size as OneNote 2010 sizes them (`corpus/table-widths`). An unlocked
202column fits its widest line plus 4.347 pt, never under a new column's 37.11 pt,
203widening and narrowing with each edit, which stores the width in the same
204revision as its text. A table stops at the outline's width and its cells wrap
205from there. Dragging a column's right border resizes that column alone, the
206columns after it moving with it, down to 37.11 pt; on release it is one edit
207and one undo step, and the column is locked, so typing no longer fits it.
208
179Note tags on a table sit in the tag column centred on it, and a click checks its209Note tags on a table sit in the tag column centred on it, and a click checks its
180box (`corpus/table-tags`). A selection crossing a table's edge deletes as210box (`corpus/table-tags`). A selection crossing a table's edge deletes as
181OneNote 2010 deletes one: nothing joins across the edge, cells inside are211OneNote 2010 deletes one: nothing joins across the edge, cells inside are
...@@ -185,6 +215,28 @@ table with them when all do. What replaces the selection goes in at its start...@@ -185,6 +215,28 @@ table with them when all do. What replaces the selection goes in at its start
185Shift+Enter a caret inside a link, as OneNote's do; Alt+= across paragraphs215Shift+Enter a caret inside a link, as OneNote's do; Alt+= across paragraphs
186makes each paragraph's part an equation, and a deletion between two equations216makes each paragraph's part an equation, and a deletion between two equations
187joins them where neither seam lies inside an object (`corpus/equation-join`).217joins them where neither seam lies inside an object (`corpus/equation-join`).
218An edit replacing a selection with an end inside an equation's object (a
219fraction, a script, a root) takes the whole object first, as OneNote's equation
220editor selects, and across paragraphs an object so taken at its paragraph's end
221takes that end too; a placeholder ("Type equation here.") is taken whole
222(`corpus/equation-select`).
223
224## Styles and themes
225
226The Styles gallery is OneNote 2010's: Heading 1 to 6, Page Title, Citation, Quote, Code
227and Normal, stored under OneNote's names (`h1`, `PageTitle`, `cite`, `blockquote`, `code`,
228`p`). Applying one gives the paragraph the page's style object of that definition and
229clears its character formatting but links, fields and language, as OneNote's does; Enter
230at a paragraph's end takes its style's NextStyle, so a heading is followed by Normal.
231Ctrl+Alt+1 to 6 apply the headings, and Clear Formatting at a caret applies Normal.
232
233A theme gives the eleven styles a look, and a page wears it as its style objects: OneNote
234draws a page by its own style objects, so OneNote users see the theme under the same
235names. Changing a theme moves each styled paragraph to a new style object of the same name
236(style objects are read-only) in one revision per page, and a page is brought to its
237theme when it opens, so a notebook-wide change costs each page only when someone looks at
238it. Which theme a notebook, section or page wears lives in the notebook's `.snowbound`
239folder, beside the tags' art (`corpus/styles`).
188240
189## Content the editor doesn't understand241## Content the editor doesn't understand
190242
...@@ -240,13 +292,17 @@ template art print across the paper; the page colour does not. The footer names...@@ -240,13 +292,17 @@ template art print across the paper; the page colour does not. The footer names
240section and numbers the sheets. `draw::pdf` writes the same primitives the screen paints292section and numbers the sheets. `draw::pdf` writes the same primitives the screen paints
241as PDF: text in subset fonts whose ToUnicode maps come from the laid-out text, so it293as PDF: text in subset fonts whose ToUnicode maps come from the laid-out text, so it
242selects and searches, ligatures included; ink and shapes as paths; pictures and tag294selects and searches, ligatures included; ink and shapes as paths; pictures and tag
243icons as images. The desktop's Print hands that PDF to AppKit's print panel, the XDG print295icons as images. On the desktop, Print and Export as PDF first show OneNote's Print Preview
244portal or the shell's print verb for PDFs; Export as PDF saves it.296and Settings, less the preview: the range (page, page group, section, and for a PDF the
297notebook), paper, orientation, fitting to the paper's width and the footer. Print then hands
298the PDF to AppKit's print panel, the XDG print portal or the shell's print verb for PDFs.
299On iOS the page menu prints the page through the print sheet or shares its PDF.
245300
246## Search, dates, conflicts301## Search, dates, conflicts
247302
248Smaller modules follow the same pattern of reproducing OneNote's behaviour303Smaller modules follow the same pattern of reproducing OneNote's behaviour
249precisely. `search` matches the way OneNote 2010 searches: word prefixes,304precisely. `search` matches the way OneNote 2010 searches: word prefixes,
250ignoring case and diacritics, title matches first. `date` edits the title's305ignoring case and diacritics, title matches first, and the text OneNote recognised
306in pictures, which it stores in them, as OneNote's search finds it. `date` edits the title's
251date and time fields the way OneNote stores a changed page date. `conflict`307date and time fields the way OneNote stores a changed page date. `conflict`
252shows conflict pages with OneNote's highlight.308shows conflict pages with OneNote's highlight.
arc/sync.md+11-3
...@@ -285,8 +285,13 @@ version at once; nothing waits for the device that wrote it. A version of anothe...@@ -285,8 +285,13 @@ version at once; nothing waits for the device that wrote it. A version of anothe
285one that cannot be read, is kept beside the file as `Name (Device).one` instead.285one that cannot be read, is kept beside the file as `Name (Device).one` instead.
286286
287On a cloud drive every read and write of a section file runs under `NSFileCoordinator`, so the287On a cloud drive every read and write of a section file runs under `NSFileCoordinator`, so the
288iCloud daemon never swaps a file between the stamp check and the append. A file iCloud evicted is288iCloud daemon never swaps a file between the stamp check and the append. A file iCloud evicted
289asked for and read later; the replica serves it meanwhile. Each publication is an upload and a289(a `.Name.icloud` placeholder, or on macOS 14 and later a dataless file under its own name) lists
290as last read, or as downloading; the host asks for it, and reads the notebook again as files
291arrive. The sync thread never waits for one: the replica serves it meanwhile. Opening a section
292with no replica yet waits for its file. macOS and iOS offer no public way to keep a file
293downloaded, as the Finder's Keep Downloaded does; evicted files are asked for again whenever
294seen. Each publication is an upload and a
290chance to conflict, so edits wait for a 3 second pause in typing (at most 30 seconds,295chance to conflict, so edits wait for a 3 second pause in typing (at most 30 seconds,
291`Section::set_pause`), and publish at once when the app leaves the foreground, the screen locks,296`Section::set_pause`), and publish at once when the app leaves the foreground, the screen locks,
292the Mac's window loses focus or the app quits. A folder presenter reports other devices'297the Mac's window loses focus or the app quits. A folder presenter reports other devices'
...@@ -305,7 +310,10 @@ which are revision stores edited through the same transaction path. Moving or...@@ -305,7 +310,10 @@ which are revision stores edited through the same transaction path. Moving or
305renaming a file also rewrites two header fields OneNote checks on open (the310renaming a file also rewrites two header fields OneNote checks on open (the
306parent TOC's identity and a CRC of the file's name). Without them OneNote311parent TOC's identity and a CRC of the file's name). Without them OneNote
307treats the file as a stranger and re-identifies it. Deleting sends sections and312treats the file as a stranger and re-identifies it. Deleting sends sections and
308pages to `OneNote_RecycleBin`, as OneNote does.313pages to `OneNote_RecycleBin`, as OneNote does, and opening a notebook empties
314what the bin has held unchanged for 60 days, as OneNote prunes it: judged by the
315content's last change, the pages in one revision, binned section files deleted
316with their TOC entries left (`corpus/recycle-purge`).
309317
310What only Snowbound reads lives in the notebook's `.snowbound` folder, which carries the318What only Snowbound reads lives in the notebook's `.snowbound` folder, which carries the
311Windows hidden attribute so that OneNote never makes a section group of it. Its files are319Windows hidden attribute so that OneNote never makes a section group of it. Its files are
arc/ui.md+5-2
...@@ -13,7 +13,8 @@ with a faint highlight along their tops. The open tab lies on top, in its...@@ -13,7 +13,8 @@ with a faint highlight along their tops. The open tab lies on top, in its
13section's colour, and frames the page. That colour shades gently down the13section's colour, and frames the page. That colour shades gently down the
14window and becomes the accent for the whole interface, easing to the next14window and becomes the accent for the whole interface, easing to the next
15section's colour when you switch. Page tabs sit on the right, and the open15section's colour when you switch. Page tabs sit on the right, and the open
16one joins the page with rounded inside corners. It is OneNote 2010's layout,16one joins the page with rounded inside corners; OneNote's Display options move
17them to the left and the notebooks to the right. It is OneNote 2010's layout,
17drawn with soft shadows, concentric corner radii and a dark appearance. Call18drawn with soft shadows, concentric corner radii and a dark appearance. Call
18it half-skeuomorphic: the shapes that made OneNote's notebook metaphor19it half-skeuomorphic: the shapes that made OneNote's notebook metaphor
19legible, without the 2010 chrome.20legible, without the 2010 chrome.
...@@ -81,7 +82,9 @@ frame N...@@ -81,7 +82,9 @@ frame N
81 *folds* its groups, boxes with a full and a folded form (`Spec::fold`), by82 *folds* its groups, boxes with a full and a folded form (`Spec::fold`), by
82 priority. Only then do boxes give up room by their *strictness*, the least83 priority. Only then do boxes give up room by their *strictness*, the least
83 strict first. That one knob covers most of what flexbox is usually needed84 strict first. That one knob covers most of what flexbox is usually needed
84 for.85 for. A box filling across a parent sized by its children stretches to what
86 its siblings make it, and a popup that isn't strict gives way to the window,
87 so a dialog is as tall as its contents up to the window and scrolls within.
85- **The toolbar is one row that never overflows.** Both forms of every group88- **The toolbar is one row that never overflows.** Both forms of every group
86 are built each frame and the solver picks, so a group folds in the frame the89 are built each frame and the solver picks, so a group folds in the frame the
87 window narrows, with no widths remembered or worked out by the app. A folded90 window narrows, with no widths remembered or worked out by the app. A folded
corpus/default-font/README.md created+21
...@@ -0,0 +1,21 @@
1# Default font
2
3OneNote 2010 keeps Options > General > Default font (Font, Size, Font color) in
4`HKCU\Software\Microsoft\Office\14.0\OneNote\Options\Editing` as `DefaultFontFace`,
5`DefaultFontSize` and `DefaultFontColor` (a COLORREF DWORD; its
6picker is Office's theme and standard colours, `options-font-color.png`). Observed in a lab clone on 2026-09-30, it never writes run
7formatting for it: a new page's `PageTitle` quick style takes the face at 17 pt
8whatever the size, and new text takes a `p` quick style in the face and size
9(plain, in the font colour, no highlight or paragraph spacing). On a page whose `p` is in
10another font, new text gets a second `p` beside it, the old text keeping its own.
11`native/Lab.one` is that section: "Title text" (Georgia 14), "T8" (Arial 8),
12"C14B14" (Calibri 14), then "Later" typed on "Title text" with Calibri 14, its
13second `p`. `native/Color.one` is a page made after picking Blue: `PageTitle` and `p`
14both take `#0070C0`, runs no colour of their own (`color.png`).
15
16`candidate/` is `crates/canvas/tests/default_font.rs`'s section, exported with
17`CANVAS_DEFAULT_FONT_EXPORT`: a page created titled in Georgia and Blue, and two outlines
18the editor started with the Default font Georgia 14 in Blue, each its own appended
19revision. `cold/page.xml` and `cold/page.png` are a fresh OneNote 2010's read:
20`PageTitle` Georgia 17 and one `p` Georgia 14, both `#0070C0`, as OneNote writes them.
21`tools/test_default_font.py` checks it without a VM.
corpus/default-font/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/default-font/candidate/Open Notebook.onetoc2 differ
corpus/default-font/candidate/fonts.one created
Binary files /dev/null and b/corpus/default-font/candidate/fonts.one differ
corpus/default-font/cold/page.png created
Binary files /dev/null and b/corpus/default-font/cold/page.png differ
corpus/default-font/cold/page.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{CA3DA07C-E834-0B2F-3765-868A9639EF79}{1}{B0}" name="Georgia title" dateTime="2026-10-01T03:29:02.000Z" lastModifiedTime="2026-10-01T03:37:06.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="PageTitle" fontColor="#0070C0" font="Georgia" fontSize="17.0"/><one:QuickStyleDef index="1" name="p" fontColor="#0070C0" highlightColor="automatic" font="Georgia" fontSize="14.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:02.000Z" lastModifiedTime="2026-10-01T03:29:02.000Z" objectID="{CA3DA07C-E834-0B2F-3765-868A9639EF79}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Georgia;font-size:17.0pt;color:#0070C0"><one:T><![CDATA[Georgia title]]></one:T></one:OE></one:Title><one:Outline author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" lastModifiedTime="2022-06-18T04:26:41.000Z" objectID="{61E3A761-D683-0F0B-1845-580EC263A955}{4}{B0}"><one:Position x="72.0" y="120.0" z="0"/><one:Size width="121.9315719604492" height="15.90724182128906"/><one:OEChildren><one:OE creationTime="2022-06-18T04:26:41.000Z" lastModifiedTime="2022-06-18T04:26:41.000Z" objectID="{61E3A761-D683-0F0B-1845-580EC263A955}{2}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[Georgia body]]></one:T></one:OE></one:OEChildren></one:Outline><one:Outline author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" lastModifiedTime="2022-06-18T04:26:42.000Z" objectID="{61E3A761-D683-0F0B-1845-580EC263A955}{7}{B0}"><one:Position x="72.0" y="199.9999847412109" z="1"/><one:Size width="125.2266845703125" height="15.90724182128906"/><one:OEChildren><one:OE creationTime="2022-06-18T04:26:42.000Z" lastModifiedTime="2022-06-18T04:26:42.000Z" objectID="{61E3A761-D683-0F0B-1845-580EC263A955}{5}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[Georgia again]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/default-font/native/Color.one created
Binary files /dev/null and b/corpus/default-font/native/Color.one differ
corpus/default-font/native/Lab.one created
Binary files /dev/null and b/corpus/default-font/native/Lab.one differ
corpus/default-font/native/color.png created
Binary files /dev/null and b/corpus/default-font/native/color.png differ
corpus/default-font/native/options-font-color.png created
Binary files /dev/null and b/corpus/default-font/native/options-font-color.png differ
corpus/object-tags/README.md created+27
...@@ -0,0 +1,27 @@
1# Tags on pictures and files
2
3`native/` is a notebook OneNote 2010 made in the lab: `tools/native/picture-objects.ps1`
4put a picture and a file in an outline, and a picture, a file and a picture of text on the
5page; each picture and file was then selected and tagged To Do with Ctrl+1 by hand.
6OneNote stores each tag on the picture or file itself (NoteTagStates on jcidImageNode and
7jcidEmbeddedFileNode), in an outline as on the page, though its COM read shows an outline
8object's tag on its paragraph. It draws the check box in a column left of the object,
9centred on it: 20.25 points out from an outline's picture, as from text, and 24.75 points
10from one on the page. It recognised "GLACIER HARBOR / Invoice 4471" in the picture of text
11and stored it as the picture's RichEditTextUnicode, which its search finds (the read's
12`OCRText`). A Ctrl+K on a selected picture there opened no dialog; the address typed
13became a link to a new page, which the section keeps.
14
15`candidate/` is the output of `tags_on_pictures_and_files_draw_and_check` in
16`crates/canvas/tests/pictures.rs`: the page picture's, the page file's and the outline
17picture's check boxes clicked, one revision each. `cold/` is a fresh OneNote 2010 read of
18it: those three checked, the outline file's not, and the recognised text kept.
19`tools/test_object_tags.py` checks this without a VM. Regenerate with
20`CANVAS_OBJECT_TAGS_EXPORT` set to a new absolute directory while running the test, then
21cold-open it with `tools/native_runner.py OUTPUT COLD --expected-pages 4 --collect-notebook --screenshots`.
22
23`restored/` is the output of `a_restored_picture_keeps_its_recognised_text`: the picture of
24text deleted and brought back by undo, written with its text (RichEditTextUnicode), its
25language and the undocumented layout of its words (0x1C001D61) as read, and its cold read,
26whose OCRData equals the native read's, words and positions alike
27(`CANVAS_RECOGNIZED_EXPORT`).
corpus/object-tags/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/object-tags/candidate/Open Notebook.onetoc2 differ
corpus/object-tags/candidate/files.one created
Binary files /dev/null and b/corpus/object-tags/candidate/files.one differ
corpus/object-tags/cold/commands.jsonl created+3
...@@ -0,0 +1,3 @@
1{"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}
2{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-A6B430A7 -ExpectedPages 4 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 4 pages.\r\n", "stderr": "", "error": null}
3{"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}
corpus/object-tags/cold/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-a6b430a7", "hostname": "ONE-M6-A6B430A7"}
corpus/object-tags/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/object-tags/cold/notebook/Open Notebook.onetoc2 differ
corpus/object-tags/cold/notebook/files.one created
Binary files /dev/null and b/corpus/object-tags/cold/notebook/files.one differ
corpus/object-tags/cold/read/32a01b6d0a94deebc53c688868b14b6fccc4e18b3b64ea052823e3856cf35b89.attachment created+1
...@@ -0,0 +1 @@
1Floating file, stored at page level.
corpus/object-tags/cold/read/33fdf6e43c05ed910d335eda3fa269ccbd1415336c17a4a5357d009ee495b3a6.attachment created+1
...@@ -0,0 +1 @@
1notes for tagging
corpus/object-tags/cold/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-A6B430A7",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/object-tags/cold/read/hierarchy.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{33250676-A4CC-4357-8960-70D030F905FB}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T22:53:33.000Z" color="#B49EDE"><one:Section name="files" ID="{C2AB6DDC-A4F5-02B9-0766-02154E90DD63}{1}{B0}" path="C:\one-tests\runs\capture\notebook\files.one" lastModifiedTime="2026-09-30T22:53:33.000Z" color="#B49EDE"><one:Page ID="{EEC6BEC4-3919-0FF3-2AC5-676DFE8292E8}{1}{B0}" name="Untitled page" dateTime="2026-09-29T18:04:32.000Z" lastModifiedTime="2026-09-30T22:53:32.000Z" pageLevel="1"/><one:Page ID="{62908E22-534B-06F1-04E8-8159D324438A}{1}{B0}" name="Untitled page" dateTime="2026-09-29T18:04:52.000Z" lastModifiedTime="2026-09-29T18:05:53.000Z" pageLevel="1"/><one:Page ID="{5BFA1C0D-BE94-4F85-9BBD-51672E337B30}{1}{B0}" name="Pictures and files" dateTime="2026-09-30T21:57:30.000Z" lastModifiedTime="2026-09-30T22:19:49.000Z" pageLevel="1"/><one:Page ID="{9CBC06CF-5AC0-42B4-A259-F2C670FFE9F1}{1}{B0}" name="https://example.invalid/picture" dateTime="2026-09-30T22:19:47.000Z" lastModifiedTime="2026-09-30T22:53:33.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/object-tags/cold/read/page-000.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Pictures and files - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/object-tags/cold/read/page-000.png created
Binary files /dev/null and b/corpus/object-tags/cold/read/page-000.png differ
corpus/object-tags/cold/read/page-000.xml created+194
...@@ -0,0 +1,194 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{5BFA1C0D-BE94-4F85-9BBD-51672E337B30}{1}{B0}" name="Pictures and files" dateTime="2026-09-30T21:57:30.000Z" lastModifiedTime="2026-09-30T22:19:49.000Z" pageLevel="1" lang="en-US"><one:TagDef index="0" type="0" symbol="3" fontColor="automatic" highlightColor="none" name="To Do"/><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T21:57:31.000Z" lastModifiedTime="2026-09-30T22:03:29.000Z" objectID="{FCE9E77A-D017-4FFB-945E-0F0A0257D459}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Pictures and files]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T22:05:57.000Z" objectID="{FCE9E77A-D017-4FFB-945E-0F0A0257D459}{39}{B0}"><one:Position x="36.0" y="86.0" z="0"/><one:Size width="119.9999847412109" height="179.8554229736328"/><one:OEChildren><one:OE creationTime="2026-09-30T21:57:31.000Z" lastModifiedTime="2026-09-30T22:03:29.000Z" objectID="{FCE9E77A-D017-4FFB-945E-0F0A0257D459}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Picture below]]></one:T></one:OE><one:OE creationTime="2026-09-30T21:57:31.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{FCE9E77A-D017-4FFB-945E-0F0A0257D459}{45}{B0}" alignment="left"><one:Tag index="0" completed="true" disabled="false" creationDate="2026-09-30T22:05:56.000Z" completionDate="2026-09-30T22:50:46.000Z"/><one:Image format="png" originalPageNumber="0"><one:Size width="120.0" height="90.0"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAKAAAAB4CAIAAAD6wG44AAACWklEQVR4nO3dy1EkQRAE0WYMsRCh
3DoiAGIiDCBwQYsVaFcCgKyK93M8cJvNZ9vAzeHp5/3cZt0f6Bdi9CQxPYHgCwxMYnsDwBIYnMDyB
44QkMT2B4AsMTGJ7A8ASGJzA8geEJDE9geM8Xt6/Xt+9/8Pr8uIg9YX7p7kec55DPBv5zVB72SOBt
5rgDpScBB17nSM4CraGcxtwPX0k5h7gUeQdvPXPqNjnG6V+trrrvgzjXNPeWuCwboXmVTtFxw1VJI
6p1xxwUjdq2OuPHDDFsDTPQ6fHz9jEvgE3fikMeBzdLPzZoBP0w1OHQA+Uzc1+27gk3UjG8h/mWQc
7YM93/x72Aasb2YaPaHibgD3f1E68YHg7gD3f4Ga8YHi3A3u+2f14wfAEhncvsM/n+Ja8YHgCwxMY
83o3AvgE37MoLhicwPIHhCQxPYHgCwxMYnsDwBIYnMLwH+w8YTOm+XXnB8ASGJzC8e4F9G45vyQuG
9JzC824F9Smf34wXD2wHsEQc34wXD2wTsEad24gXD2wfsEUe2sfWCNd6/Bx/R8HYDe8Rr74/JAxd8
10svHaPnvmEX2m8UpMHXsPPs14heZNfpJ1jvHKTRr+LPoE4xWdMf9lEtt4pafLAzdsATxXy7+2g/3V
11h1VAW3TBhXvBTNF1wdNPeTXRNl5w86aGvubSC551yquSdgZwOfMqpp0EXMi86mnnATdIryGus4H3
12S69prhDgW7HXWFQm8C/JF4LzLGDr/UaH/VUCwxMYnsDwBIYnMDyB4QkMT2B4AsMTGJ7A8ASGJzA8
13geEJDE9geAJf7P4Dry+9j2KmlKMAAAAASUVORK5CYII=</one:Data></one:Image></one:OE><one:OE creationTime="2026-09-30T21:57:31.000Z" lastModifiedTime="2026-09-30T22:05:57.000Z" objectID="{FCE9E77A-D017-4FFB-945E-0F0A0257D459}{48}{B0}" alignment="left"><one:Tag index="0" completed="false" disabled="false" creationDate="2026-09-30T22:05:57.000Z"/><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\5e789b39-27a8-4562-8c92-f2887ca85b9f.txt" pathSource="C:\one-tests\runs\capture\notebook\notes.txt" preferredName="notes.txt"/></one:OE><one:OE creationTime="2026-09-30T21:57:31.000Z" lastModifiedTime="2026-09-30T22:03:29.000Z" objectID="{FCE9E77A-D017-4FFB-945E-0F0A0257D459}{52}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Text after]]></one:T></one:OE></one:OEChildren></one:Outline><one:Image format="png" originalPageNumber="0" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{FCE9E77A-D017-4FFB-945E-0F0A0257D459}{57}{B0}"><one:Position x="400.0000305175781" y="86.0" z="1"/><one:Size width="119.9999847412109" height="90.0"/><one:Tag index="0" completed="true" disabled="false" creationDate="2026-09-30T22:19:42.000Z" completionDate="2026-09-30T22:50:46.000Z"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAKAAAAB4CAIAAAD6wG44AAACWklEQVR4nO3dy1EkQRAE0WYMsRCh
14DoiAGIiDCBwQYsVaFcCgKyK93M8cJvNZ9vAzeHp5/3cZt0f6Bdi9CQxPYHgCwxMYnsDwBIYnMDyB
154QkMT2B4AsMTGJ7A8ASGJzA8geEJDE9geM8Xt6/Xt+9/8Pr8uIg9YX7p7kec55DPBv5zVB72SOBt
16rgDpScBB17nSM4CraGcxtwPX0k5h7gUeQdvPXPqNjnG6V+trrrvgzjXNPeWuCwboXmVTtFxw1VJI
17p1xxwUjdq2OuPHDDFsDTPQ6fHz9jEvgE3fikMeBzdLPzZoBP0w1OHQA+Uzc1+27gk3UjG8h/mWQc
18YM93/x72Aasb2YaPaHibgD3f1E68YHg7gD3f4Ga8YHi3A3u+2f14wfAEhncvsM/n+Ja8YHgCwxMY
193o3AvgE37MoLhicwPIHhCQxPYHgCwxMYnsDwBIYnMLwH+w8YTOm+XXnB8ASGJzC8e4F9G45vyQuG
20JzC824F9Smf34wXD2wHsEQc34wXD2wTsEad24gXD2wfsEUe2sfWCNd6/Bx/R8HYDe8Rr74/JAxd8
21svHaPnvmEX2m8UpMHXsPPs14heZNfpJ1jvHKTRr+LPoE4xWdMf9lEtt4pafLAzdsATxXy7+2g/3V
22h1VAW3TBhXvBTNF1wdNPeTXRNl5w86aGvubSC551yquSdgZwOfMqpp0EXMi86mnnATdIryGus4H3
23S69prhDgW7HXWFQm8C/JF4LzLGDr/UaH/VUCwxMYnsDwBIYnMDyB4QkMT2B4AsMTGJ7A8ASGJzA8
24geEJDE9geAJf7P4Dry+9j2KmlKMAAAAASUVORK5CYII=</one:Data></one:Image><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\5e789b39-27a8-4562-8c92-f2887ca85b9f.txt" pathSource="C:\one-tests\runs\capture\notebook\notes.txt" preferredName="notes.txt" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{FCE9E77A-D017-4FFB-945E-0F0A0257D459}{59}{B0}"><one:Position x="560.0" y="86.0" z="2"/><one:Size width="54.0" height="63.0"/><one:Tag index="0" completed="true" disabled="false" creationDate="2026-09-30T22:19:49.000Z" completionDate="2026-09-30T22:50:46.000Z"/></one:InsertedFile><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T22:19:47.000Z" objectID="{8CBBD351-0539-4F37-AF64-B4AC90454A1C}{15}{B0}"><one:Position x="468.0" y="122.400001525879" z="4"/><one:Size width="180.6122131347656" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-30T22:19:47.000Z" lastModifiedTime="2026-09-30T22:19:47.000Z" objectID="{8CBBD351-0539-4F37-AF64-B4AC90454A1C}{16}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[<a
25href="onenote:files.one#https//example.invalid/picture&amp;section-id={B771AD51-7810-4F70-BA52-95546B525FDD}&amp;page-id={76596C1B-90C9-4493-ACAF-9A0CBA6CC7FD}&amp;base-path=//C:/one-tests/runs/capture/notebook">https://example.invalid/picture</a>]]></one:T></one:OE></one:OEChildren></one:Outline><one:Image format="png" originalPageNumber="0" lastModifiedTime="2026-09-30T22:06:25.000Z" objectID="{FCE9E77A-D017-4FFB-945E-0F0A0257D459}{62}{B0}"><one:Position x="400.0000305175781" y="260.0" z="3"/><one:Size width="319.9999694824219" height="80.0" isSetByUser="true"/><one:Data>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
153mZkpvroWuRuuq6urpMm+y5cvZ6UkJSUVFBQwigoPD4+OjhZPcXFxoY6D46pAqq+++oqVQp3zBgDQ
154EtwHYOrTswj5JCqKwawUshc6PT09IyODlUguS9SrV6/Ro0ezEs+fP0+elNot3KR3795jxoxhJT54
1558KC4uJiR061bt8jESZMmScq/YcOGZ8+eXbx4cdeuXUuWLPHw8BAfZVZTU/PgwQPWIW5ubpJKs7Oz
156s7S0FE8RCoX3799nFPLy5UvWEHdjY+OwsDDyZoWrAqlu3779559/iqc4Ojq6uroq8RQAAGoRgKVM
157TjU3NycTGxoaWCmfffYZn8+X/hBcWlp68eJFVp6AgADyreft27dZKYaGhuR8JxZPT09WilAovHbt
158GiMnch6LqampjY0No5D79+/X1dWxEqn9DU26devGSklNTVXs7PPnz3/79q14yrZt2/r27atYaaoo
159kGrv3r2slMDAQOWeAgBALQIwazanuHbt2pGJ5KY3nTp1Ip8RRUtiNf31zJkzrFDE4/HmzJlDlp+Z
160mclKGTBgABngWRwcHMjEnJwcRk6vX79uNiLKLj8/n0xctmwZTzLyiTkrK0uBUx84cIB1xzN69Gjq
161gDWuCqQqLi4+e/aseIqhoSE55QkAQBsCMDkwqkmzYa8JuSBwTk6O+Pgs8q3wmDFjevTowUqsrKys
162ra1lJbLG3FJR8xQVFTFyevPmDStF4enI1NIUQL44b9bz589ZL01NTEyOHDlC9jdwVaAk4eHhrAYw
163efJkKU0UAECDA7C+vr6kj8iZqZJ4e3uTPasnTpygBmNJw6+o/duSHsRlyaNA6CLH8bbkBee7d++Y
164FquqqpIrv2huGOuL/Pjjj9Q3+pwUKAU5w3jGjBlKPwsAAPcBWEdHRykPMbq6umR/ckREhGgr1lOn
165TrE6rs3MzD755BMZ4yg57ItUUVFBJja7hQOJfN6VN/6Joy7oIS95LyA/P5/cC2jRokVkd7epqSl5
166+LRp05oyLF68WBUFSpKbm8t6B6+npzd27Fh5vj0AgIYEYCV2IZJjoYuKikSDiiMjI1kfzZw5U3yu
167ThM+n0/2N5aUlDR7duqA5/fee4+RU4cOHZT4FGtmZkYmRkZGyjVT7fr163KdlHxJ30JKL1CSiIgI
1681rlGjRpFDeoAANrQBa0sffv2JSfYREdH5+fnk0OLqf3PIv369WOlZGZmUrumxaWlpZGJvXv3Zlo8
169JC0vL498LS0jcuFr6jgvEImNjWVVBbmFEQCAsmhPAKYOxTp//nxMTAzrsWbgwIFSNtl1dHRkpdTU
1701FCn54r7448/WCl8Pt/d3Z2R07Bhw1gpAoGAGt1F8vPzLS0tR44cOW/evJCQkPPnzz958qTpdoH8
171LqJlsOS9qragvr6enIEmafUVAICW06oA7Ofnx+pAfvLkyY4dO2R//JX00HPw4EEph2RnZ5MvKYcP
172H67A6NmRI0eSiTExMZLy//HHHyUlJbdu3QoNDV27dq2vr6+trW1UVJTo044dO5JP4b///nt9fb28
173F6b1/vrrL9bLfn19fXItNgAAZdGqAGxsbEyOWX38+LH4X/l8vvStdr29vclXp8ePH79x4wY1v1Ao
174XLZsmWi0lzjFpqiOHTuWXAt63759kgZUHzp0iJXC5/PF7yGmTZvGyvD69WtJy2SGhoZ27drVw8Nj
1758eLFO3fujI2Nffr0aWNjI9MGJCUlsVIGDRpEHSgAAKAc3G7GoKurK6XYTZs2kcW+evVKyiGsRQRJ
176vr6+zX4d6q4Jcu2GZGtrS+7cLuNuSAsXLiSz+fj4kPsxULdDnjp1qniehw8fkiPdjI2Nr127xirt
1771atX5MRoGXcBUoxyt+9tYYGffvop68CAgACh8mAzBgBg0aonYFHHL3UrXxn7n0XWr19vZWXFSiwu
178Lv7www//85//ZGRk1NTUFBcXR0VFjRw5cs+ePaycurq6e/fuVXj+7qpVq8gHr/Pnzw8fPjwyMrKk
179pKSuri4zM3PlypXUFarXrVsn/tcBAwaQoaWqqsrLyys4OPjRo0e1tbX5+fnHjx8fNWoUuXTX2LFj
180Je3coGXIBb8GDhzI0bUAQNsg1K4nYKFQSL70bWJlZSW+R7oUcXFxsi8DwvLtt99Sy5TxCVjShsSy
181mD9/Pllabm4udT5SswwNDbOysoQqoz5PwAKBgJwCHhUVJVQePAEDgJY/AYu2+JW0upa/v7+My1uO
182Hz/+0KFDsq+F2SQoKCg4OJhpmWXLllHXqZbOwcFh586dZHq3bt1OnDhB3dBQCh6Pd+jQIVtbW6YN
183KCgoIJdbsba25uhyAKBN0MIAbGlp6ePjo3D/c5OAgIC4uDjZf4X5fP7u3bt37dqllNVFjhw5snbt
184WtnzOzs7JyQkSFo4esKECVFRUbI/BxsZGf3666+zZs1i2gbqOirUWdQAAMqihQGYOiFY9HpY3rd6
185H330UVZW1saNG1m75JKmTJny8OFDckN7hfF4vO3bt1+9etXJyUl6zo4dO4aEhCQlJZGraImbOHFi
186amrqv/71r2ZfTo8bNy45Odnf359pM6i7VsiyBjgAgMLk7mLVCF5eXt27d8/NzVX48beJiYnJ5s2b
187//Of/8THx8fGxqakpGRnZ5eVlbVr165Tp049e/YcN27c5MmTlb4rrYiHh0dycvLNmzcvXLiQmJj4
1888uXLoqIigUBgbm5ubW09dOhQT0/PKVOmyLjmc48ePU6dOrV9+/azZ88mJiY+evSooKCgurra2NjY
189wsJiwIABI0aMmD59up2dHdPGUAMw5iABgErxWm2hXQAAANDyLmgAAAA1hwAMAADAAQRgAAAADiAA
190AwAAcAABGAAAgAMIwAAAABxAAAYAAOAAAjAAAAAHEIABAAA4gAAMAADAAQRgAAAADiAAAwAAcAAB
191GAAAgAMIwAAAABxAAAYAAOAAAjAAAAAHEIABAAA4gAAMAADAAQRgAAAADiAAAwAAcAABGAAAgAMI
192wAAAABxAAAYAAOAAAjAAAAAHEIABAAA4gAAMAADAAQRgAAAADiAAAwAAcAABGAAAgAMIwAAAAEzr
193+38UfRlkE+m5vQAAAABJRU5ErkJggg==</one:Data><one:OCRData lang="en-US"><one:OCRText><![CDATA[GLACIER HARBOR
194Invoice 4471]]></one:OCRText><one:OCRToken startPos="0" region="0" line="0" x="16.44094467163086" y="20.69291496276855" width="159.8740234375" height="27.77952766418457"/><one:OCRToken startPos="8" region="0" line="0" x="189.3543395996094" y="20.97637939453125" width="155.6220550537109" height="27.49606323242187"/><one:OCRToken startPos="15" region="0" line="1" x="17.29133796691895" y="70.29920959472656" width="119.6220474243164" height="26.36220550537109"/><one:OCRToken startPos="23" region="0" line="1" x="149.1023559570312" y="70.29920959472656" width="74.26771545410156" height="26.36220550537109"/></one:OCRData></one:Image></one:Page>
corpus/object-tags/cold/read/page-001.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Untitled page - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/object-tags/cold/read/page-001.png created
Binary files /dev/null and b/corpus/object-tags/cold/read/page-001.png differ
corpus/object-tags/cold/read/page-001.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{62908E22-534B-06F1-04E8-8159D324438A}{1}{B0}" name="Untitled page" dateTime="2026-09-29T18:04:52.000Z" lastModifiedTime="2026-09-29T18:05:53.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T18:04:53.000Z" lastModifiedTime="2026-09-29T18:05:06.000Z" objectID="{C2A94E46-18C9-06D4-1EC8-8D5328B30769}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[]]></one:T></one:OE></one:Title><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\2141eff6-0714-43c5-9b45-2387696fe77f.txt" pathSource="C:\obs\float.txt" preferredName="float.txt" lastModifiedTime="2026-09-29T18:04:53.000Z" objectID="{C2A94E46-18C9-06D4-1EC8-8D5328B30769}{40}{B0}"><one:Position x="288.0" y="140.4000091552734" z="0"/><one:Size width="54.0" height="63.0"/></one:InsertedFile><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\2141eff6-0714-43c5-9b45-2387696fe77f.txt" pathSource="C:\obs\float.txt" preferredName="float.txt" lastModifiedTime="2026-09-29T18:05:53.000Z" objectID="{08585F32-BA6A-042A-288B-8FC98DC9C068}{18}{B0}"><one:Position x="342.0" y="284.3999938964844" z="1"/><one:Size width="54.0" height="62.99998092651367"/></one:InsertedFile></one:Page>
corpus/object-tags/cold/read/page-002.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "https://example.invalid/picture - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/object-tags/cold/read/page-002.png created
Binary files /dev/null and b/corpus/object-tags/cold/read/page-002.png differ
corpus/object-tags/cold/read/page-002.xml created+3
...@@ -0,0 +1,3 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{9CBC06CF-5AC0-42B4-A259-F2C670FFE9F1}{1}{B0}" name="https://example.invalid/picture" dateTime="2026-09-30T22:19:47.000Z" lastModifiedTime="2026-09-30T22:53:33.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T22:19:47.000Z" lastModifiedTime="2026-09-30T22:19:47.000Z" objectID="{3DC178EB-CB85-431A-A8B4-5DEF9CC7B9F8}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[<a
3href="https://example.invalid/picture">https://example.invalid/picture</a>]]></one:T></one:OE></one:Title></one:Page>
corpus/object-tags/cold/read/page-003.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Untitled page - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/object-tags/cold/read/page-003.png created
Binary files /dev/null and b/corpus/object-tags/cold/read/page-003.png differ
corpus/object-tags/cold/read/page-003.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{EEC6BEC4-3919-0FF3-2AC5-676DFE8292E8}{1}{B0}" name="Untitled page" dateTime="2026-09-29T18:04:32.000Z" lastModifiedTime="2026-09-30T22:53:32.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T18:04:33.000Z" lastModifiedTime="2026-09-29T18:04:33.000Z" objectID="{7FED4D4D-8D0A-0BE5-3E98-5C34BFBB9E66}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page>
corpus/object-tags/cold/read/payloads.json created+34
...@@ -0,0 +1,34 @@
1[
2 {
3 "sha256": "33fdf6e43c05ed910d335eda3fa269ccbd1415336c17a4a5357d009ee495b3a6",
4 "name": "notes.txt",
5 "object": "{FCE9E77A-D017-4FFB-945E-0F0A0257D459}{48}{B0}",
6 "kind": "InsertedFile",
7 "page": "{5BFA1C0D-BE94-4F85-9BBD-51672E337B30}{1}{B0}",
8 "bytes": 18
9 },
10 {
11 "sha256": "33fdf6e43c05ed910d335eda3fa269ccbd1415336c17a4a5357d009ee495b3a6",
12 "name": "notes.txt",
13 "object": "{FCE9E77A-D017-4FFB-945E-0F0A0257D459}{59}{B0}",
14 "kind": "InsertedFile",
15 "page": "{5BFA1C0D-BE94-4F85-9BBD-51672E337B30}{1}{B0}",
16 "bytes": 18
17 },
18 {
19 "sha256": "32a01b6d0a94deebc53c688868b14b6fccc4e18b3b64ea052823e3856cf35b89",
20 "name": "float.txt",
21 "object": "{C2A94E46-18C9-06D4-1EC8-8D5328B30769}{40}{B0}",
22 "kind": "InsertedFile",
23 "page": "{62908E22-534B-06F1-04E8-8159D324438A}{1}{B0}",
24 "bytes": 38
25 },
26 {
27 "sha256": "32a01b6d0a94deebc53c688868b14b6fccc4e18b3b64ea052823e3856cf35b89",
28 "name": "float.txt",
29 "object": "{08585F32-BA6A-042A-288B-8FC98DC9C068}{18}{B0}",
30 "kind": "InsertedFile",
31 "page": "{62908E22-534B-06F1-04E8-8159D324438A}{1}{B0}",
32 "bytes": 38
33 }
34]
\ No newline at end of file
corpus/object-tags/cold/run.json created+18
...@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/pictab/candidate",
3 "expected_pages": 4,
4 "author": null,
5 "author_timeout_seconds": 600,
6 "inspect": false,
7 "collect_notebook": true,
8 "base": {
9 "file": "win7-office-base.qcow2",
10 "format": "qcow2",
11 "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
12 "virtual_size": 68719476736
13 },
14 "scripts": {
15 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
16 "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
17 }
18}
corpus/object-tags/cold/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/object-tags/cold/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/object-tags/cold/source.json created+14
...@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1604,
5 "sha256": "f3a3df7f649fd730cc071103b4ab50fa7bdf3c4df0c4296ae0c3fb640ebee1ad",
6 "mtime_ns": 1790808646958031593
7 },
8 {
9 "path": "files.one",
10 "bytes": 59048,
11 "sha256": "7222f2c26057540dd2a537b337e5667d8140e4fdb2de41d091b09b39fdce5edc",
12 "mtime_ns": 1790808646957690287
13 }
14]
corpus/object-tags/cold/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
corpus/object-tags/native/commands.jsonl created+7
...@@ -0,0 +1,7 @@
1{"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}
2{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\author.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-F27645BE", "exit": 0, "stdout": "", "stderr": "", "error": null}
3{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-F27645BE -ExpectedPages -1 -UseCurrentCache -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 3 pages.\r\n", "stderr": "", "error": null}
4{"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}
5{"command": "powershell -NoProfile -Command \"Rename-Item C:\\one-tests\\runs\\capture\\read before-read\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
6{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-F27645BE -UseCurrentCache -ExpectedPages -1", "exit": 0, "stdout": "Read 1 sections and 4 pages.\r\n", "stderr": "", "error": null}
7{"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}
corpus/object-tags/native/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-f27645be", "hostname": "ONE-M6-F27645BE"}
corpus/object-tags/native/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/object-tags/native/notebook/Open Notebook.onetoc2 differ
corpus/object-tags/native/notebook/files.one created
Binary files /dev/null and b/corpus/object-tags/native/notebook/files.one differ
corpus/object-tags/native/notebook/notes.txt created+1
...@@ -0,0 +1 @@
1notes for tagging
corpus/object-tags/native/notebook/ocr.png created
Binary files /dev/null and b/corpus/object-tags/native/notebook/ocr.png differ
corpus/object-tags/native/notebook/photo.png created
Binary files /dev/null and b/corpus/object-tags/native/notebook/photo.png differ
corpus/object-tags/native/read/32a01b6d0a94deebc53c688868b14b6fccc4e18b3b64ea052823e3856cf35b89.attachment created+1
...@@ -0,0 +1 @@
1Floating file, stored at page level.
corpus/object-tags/native/read/33fdf6e43c05ed910d335eda3fa269ccbd1415336c17a4a5357d009ee495b3a6.attachment created+1
...@@ -0,0 +1 @@
1notes for tagging
corpus/object-tags/native/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-F27645BE",
5 "cold": false,
6 "onenote": "14.0.4763.1000"
7}
corpus/object-tags/native/read/hierarchy.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{735E8C7C-35DD-4DEE-88DA-DC3C8F253606}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T22:19:55.000Z" color="#B49EDE" isCurrentlyViewed="true"><one:Section name="files" ID="{EC855B5A-F06A-07E4-217A-2109E4E6B09D}{1}{B0}" path="C:\one-tests\runs\capture\notebook\files.one" lastModifiedTime="2026-09-30T22:19:55.000Z" color="#B49EDE" isCurrentlyViewed="true"><one:Page ID="{C0E88842-6D86-0AAE-0CD9-447154F4FF16}{1}{B0}" name="Untitled page" dateTime="2026-09-29T18:04:32.000Z" lastModifiedTime="2026-09-30T22:03:26.000Z" pageLevel="1"/><one:Page ID="{4CBEB8A4-07D4-03AC-22F4-A24579522E74}{1}{B0}" name="Untitled page" dateTime="2026-09-29T18:04:52.000Z" lastModifiedTime="2026-09-29T18:05:53.000Z" pageLevel="1"/><one:Page ID="{75D42A8B-EA0B-4AD8-BDA1-727B844516CE}{1}{B0}" name="Pictures and files" dateTime="2026-09-30T21:57:30.000Z" lastModifiedTime="2026-09-30T22:19:49.000Z" pageLevel="1" isCurrentlyViewed="true"/><one:Page ID="{B2923049-0E5F-47E9-8445-D1DADA89840F}{1}{B0}" name="https://example.invalid/picture" dateTime="2026-09-30T22:19:47.000Z" lastModifiedTime="2026-09-30T22:19:47.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/object-tags/native/read/page-000.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{4CBEB8A4-07D4-03AC-22F4-A24579522E74}{1}{B0}" name="Untitled page" dateTime="2026-09-29T18:04:52.000Z" lastModifiedTime="2026-09-29T18:05:53.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T18:04:53.000Z" lastModifiedTime="2026-09-29T18:05:06.000Z" objectID="{EC8778C0-4C56-0389-38D4-AE4F82C56A97}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[]]></one:T></one:OE></one:Title><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\2141eff6-0714-43c5-9b45-2387696fe77f.txt" pathSource="C:\obs\float.txt" preferredName="float.txt" lastModifiedTime="2026-09-29T18:04:53.000Z" objectID="{EC8778C0-4C56-0389-38D4-AE4F82C56A97}{40}{B0}"><one:Position x="288.0" y="140.4000091552734" z="0"/><one:Size width="54.0" height="63.0"/></one:InsertedFile><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\2141eff6-0714-43c5-9b45-2387696fe77f.txt" pathSource="C:\obs\float.txt" preferredName="float.txt" lastModifiedTime="2026-09-29T18:05:53.000Z" objectID="{267669B4-EEF5-0177-0E97-ACD527BFAD96}{18}{B0}"><one:Position x="342.0" y="284.3999938964844" z="1"/><one:Size width="54.0" height="62.99998092651367"/></one:InsertedFile></one:Page>
corpus/object-tags/native/read/page-001.xml created+194
...@@ -0,0 +1,194 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{75D42A8B-EA0B-4AD8-BDA1-727B844516CE}{1}{B0}" name="Pictures and files" dateTime="2026-09-30T21:57:30.000Z" lastModifiedTime="2026-09-30T22:19:50.000Z" pageLevel="1" isCurrentlyViewed="true" lang="en-US"><one:TagDef index="0" type="0" symbol="3" fontColor="automatic" highlightColor="none" name="To Do"/><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T21:57:31.000Z" lastModifiedTime="2026-09-30T22:03:29.000Z" objectID="{D2C7D1FC-8488-4AA6-B242-2C16A821B9A7}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Pictures and files]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T22:06:26.000Z" objectID="{D2C7D1FC-8488-4AA6-B242-2C16A821B9A7}{39}{B0}"><one:Position x="36.0" y="86.0" z="0"/><one:Size width="119.9999847412109" height="179.8554229736328"/><one:OEChildren><one:OE creationTime="2026-09-30T21:57:31.000Z" lastModifiedTime="2026-09-30T22:03:29.000Z" objectID="{D2C7D1FC-8488-4AA6-B242-2C16A821B9A7}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Picture below]]></one:T></one:OE><one:OE creationTime="2026-09-30T21:57:31.000Z" lastModifiedTime="2026-09-30T22:06:26.000Z" objectID="{D2C7D1FC-8488-4AA6-B242-2C16A821B9A7}{45}{B0}" alignment="left"><one:Tag index="0" completed="false" disabled="false" creationDate="2026-09-30T22:05:56.000Z"/><one:Image format="png" originalPageNumber="0"><one:Size width="120.0" height="90.0"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAKAAAAB4CAIAAAD6wG44AAACWklEQVR4nO3dy1EkQRAE0WYMsRCh
3DoiAGIiDCBwQYsVaFcCgKyK93M8cJvNZ9vAzeHp5/3cZt0f6Bdi9CQxPYHgCwxMYnsDwBIYnMDyB
44QkMT2B4AsMTGJ7A8ASGJzA8geEJDE9geM8Xt6/Xt+9/8Pr8uIg9YX7p7kec55DPBv5zVB72SOBt
5rgDpScBB17nSM4CraGcxtwPX0k5h7gUeQdvPXPqNjnG6V+trrrvgzjXNPeWuCwboXmVTtFxw1VJI
6p1xxwUjdq2OuPHDDFsDTPQ6fHz9jEvgE3fikMeBzdLPzZoBP0w1OHQA+Uzc1+27gk3UjG8h/mWQc
7YM93/x72Aasb2YaPaHibgD3f1E68YHg7gD3f4Ga8YHi3A3u+2f14wfAEhncvsM/n+Ja8YHgCwxMY
83o3AvgE37MoLhicwPIHhCQxPYHgCwxMYnsDwBIYnMLwH+w8YTOm+XXnB8ASGJzC8e4F9G45vyQuG
9JzC824F9Smf34wXD2wHsEQc34wXD2wTsEad24gXD2wfsEUe2sfWCNd6/Bx/R8HYDe8Rr74/JAxd8
10svHaPnvmEX2m8UpMHXsPPs14heZNfpJ1jvHKTRr+LPoE4xWdMf9lEtt4pafLAzdsATxXy7+2g/3V
11h1VAW3TBhXvBTNF1wdNPeTXRNl5w86aGvubSC551yquSdgZwOfMqpp0EXMi86mnnATdIryGus4H3
12S69prhDgW7HXWFQm8C/JF4LzLGDr/UaH/VUCwxMYnsDwBIYnMDyB4QkMT2B4AsMTGJ7A8ASGJzA8
13geEJDE9geAJf7P4Dry+9j2KmlKMAAAAASUVORK5CYII=</one:Data></one:Image></one:OE><one:OE creationTime="2026-09-30T21:57:31.000Z" lastModifiedTime="2026-09-30T22:05:57.000Z" objectID="{D2C7D1FC-8488-4AA6-B242-2C16A821B9A7}{48}{B0}" alignment="left"><one:Tag index="0" completed="false" disabled="false" creationDate="2026-09-30T22:05:57.000Z"/><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\5e789b39-27a8-4562-8c92-f2887ca85b9f.txt" pathSource="C:\one-tests\runs\capture\notebook\notes.txt" preferredName="notes.txt"/></one:OE><one:OE creationTime="2026-09-30T21:57:31.000Z" lastModifiedTime="2026-09-30T22:03:29.000Z" objectID="{D2C7D1FC-8488-4AA6-B242-2C16A821B9A7}{52}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Text after]]></one:T></one:OE></one:OEChildren></one:Outline><one:Image format="png" originalPageNumber="0" lastModifiedTime="2026-09-30T22:19:42.000Z" objectID="{D2C7D1FC-8488-4AA6-B242-2C16A821B9A7}{57}{B0}"><one:Position x="400.0000305175781" y="86.0" z="1"/><one:Size width="119.9999847412109" height="90.0"/><one:Tag index="0" completed="false" disabled="false" creationDate="2026-09-30T22:19:42.000Z"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAKAAAAB4CAIAAAD6wG44AAACWklEQVR4nO3dy1EkQRAE0WYMsRCh
14DoiAGIiDCBwQYsVaFcCgKyK93M8cJvNZ9vAzeHp5/3cZt0f6Bdi9CQxPYHgCwxMYnsDwBIYnMDyB
154QkMT2B4AsMTGJ7A8ASGJzA8geEJDE9geM8Xt6/Xt+9/8Pr8uIg9YX7p7kec55DPBv5zVB72SOBt
16rgDpScBB17nSM4CraGcxtwPX0k5h7gUeQdvPXPqNjnG6V+trrrvgzjXNPeWuCwboXmVTtFxw1VJI
17p1xxwUjdq2OuPHDDFsDTPQ6fHz9jEvgE3fikMeBzdLPzZoBP0w1OHQA+Uzc1+27gk3UjG8h/mWQc
18YM93/x72Aasb2YaPaHibgD3f1E68YHg7gD3f4Ga8YHi3A3u+2f14wfAEhncvsM/n+Ja8YHgCwxMY
193o3AvgE37MoLhicwPIHhCQxPYHgCwxMYnsDwBIYnMLwH+w8YTOm+XXnB8ASGJzC8e4F9G45vyQuG
20JzC824F9Smf34wXD2wHsEQc34wXD2wTsEad24gXD2wfsEUe2sfWCNd6/Bx/R8HYDe8Rr74/JAxd8
21svHaPnvmEX2m8UpMHXsPPs14heZNfpJ1jvHKTRr+LPoE4xWdMf9lEtt4pafLAzdsATxXy7+2g/3V
22h1VAW3TBhXvBTNF1wdNPeTXRNl5w86aGvubSC551yquSdgZwOfMqpp0EXMi86mnnATdIryGus4H3
23S69prhDgW7HXWFQm8C/JF4LzLGDr/UaH/VUCwxMYnsDwBIYnMDyB4QkMT2B4AsMTGJ7A8ASGJzA8
24geEJDE9geAJf7P4Dry+9j2KmlKMAAAAASUVORK5CYII=</one:Data></one:Image><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\5e789b39-27a8-4562-8c92-f2887ca85b9f.txt" pathSource="C:\one-tests\runs\capture\notebook\notes.txt" preferredName="notes.txt" lastModifiedTime="2026-09-30T21:57:31.000Z" objectID="{D2C7D1FC-8488-4AA6-B242-2C16A821B9A7}{59}{B0}"><one:Position x="560.0" y="86.0" z="2"/><one:Size width="54.0" height="63.0"/><one:Tag index="0" completed="false" disabled="false" creationDate="2026-09-30T22:19:49.000Z"/></one:InsertedFile><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T22:19:47.000Z" objectID="{A295E5D7-51A6-4A6A-8978-97B03A3327E2}{15}{B0}"><one:Position x="468.0" y="122.400001525879" z="4"/><one:Size width="180.6122131347656" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-30T22:19:47.000Z" lastModifiedTime="2026-09-30T22:19:47.000Z" objectID="{A295E5D7-51A6-4A6A-8978-97B03A3327E2}{16}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[<a
25href="onenote:files.one#https//example.invalid/picture&amp;section-id={B771AD51-7810-4F70-BA52-95546B525FDD}&amp;page-id={76596C1B-90C9-4493-ACAF-9A0CBA6CC7FD}&amp;base-path=//C:/one-tests/runs/capture/notebook">https://example.invalid/picture</a>]]></one:T></one:OE></one:OEChildren></one:Outline><one:Image format="png" originalPageNumber="0" lastModifiedTime="2026-09-30T22:06:25.000Z" objectID="{D2C7D1FC-8488-4AA6-B242-2C16A821B9A7}{62}{B0}"><one:Position x="400.0000305175781" y="260.0" z="3"/><one:Size width="319.9999694824219" height="80.0" isSetByUser="true"/><one:Data>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
153mZkpvroWuRuuq6urpMm+y5cvZ6UkJSUVFBQwigoPD4+OjhZPcXFxoY6D46pAqq+++oqVQp3zBgDQ
154EtwHYOrTswj5JCqKwawUshc6PT09IyODlUguS9SrV6/Ro0ezEs+fP0+elNot3KR3795jxoxhJT54
1558KC4uJiR061bt8jESZMmScq/YcOGZ8+eXbx4cdeuXUuWLPHw8BAfZVZTU/PgwQPWIW5ubpJKs7Oz
156s7S0FE8RCoX3799nFPLy5UvWEHdjY+OwsDDyZoWrAqlu3779559/iqc4Ojq6uroq8RQAAGoRgKVM
157TjU3NycTGxoaWCmfffYZn8+X/hBcWlp68eJFVp6AgADyreft27dZKYaGhuR8JxZPT09WilAovHbt
158GiMnch6LqampjY0No5D79+/X1dWxEqn9DU26devGSklNTVXs7PPnz3/79q14yrZt2/r27atYaaoo
159kGrv3r2slMDAQOWeAgBALQIwazanuHbt2pGJ5KY3nTp1Ip8RRUtiNf31zJkzrFDE4/HmzJlDlp+Z
160mclKGTBgABngWRwcHMjEnJwcRk6vX79uNiLKLj8/n0xctmwZTzLyiTkrK0uBUx84cIB1xzN69Gjq
161gDWuCqQqLi4+e/aseIqhoSE55QkAQBsCMDkwqkmzYa8JuSBwTk6O+Pgs8q3wmDFjevTowUqsrKys
162ra1lJbLG3FJR8xQVFTFyevPmDStF4enI1NIUQL44b9bz589ZL01NTEyOHDlC9jdwVaAk4eHhrAYw
163efJkKU0UAECDA7C+vr6kj8iZqZJ4e3uTPasnTpygBmNJw6+o/duSHsRlyaNA6CLH8bbkBee7d++Y
164FquqqpIrv2huGOuL/Pjjj9Q3+pwUKAU5w3jGjBlKPwsAAPcBWEdHRykPMbq6umR/ckREhGgr1lOn
165TrE6rs3MzD755BMZ4yg57ItUUVFBJja7hQOJfN6VN/6Joy7oIS95LyA/P5/cC2jRokVkd7epqSl5
166+LRp05oyLF68WBUFSpKbm8t6B6+npzd27Fh5vj0AgIYEYCV2IZJjoYuKikSDiiMjI1kfzZw5U3yu
167ThM+n0/2N5aUlDR7duqA5/fee4+RU4cOHZT4FGtmZkYmRkZGyjVT7fr163KdlHxJ30JKL1CSiIgI
1681rlGjRpFDeoAANrQBa0sffv2JSfYREdH5+fnk0OLqf3PIv369WOlZGZmUrumxaWlpZGJvXv3Zlo8
169JC0vL498LS0jcuFr6jgvEImNjWVVBbmFEQCAsmhPAKYOxTp//nxMTAzrsWbgwIFSNtl1dHRkpdTU
1701FCn54r7448/WCl8Pt/d3Z2R07Bhw1gpAoGAGt1F8vPzLS0tR44cOW/evJCQkPPnzz958qTpdoH8
171LqJlsOS9qragvr6enIEmafUVAICW06oA7Ofnx+pAfvLkyY4dO2R//JX00HPw4EEph2RnZ5MvKYcP
172H67A6NmRI0eSiTExMZLy//HHHyUlJbdu3QoNDV27dq2vr6+trW1UVJTo044dO5JP4b///nt9fb28
173F6b1/vrrL9bLfn19fXItNgAAZdGqAGxsbEyOWX38+LH4X/l8vvStdr29vclXp8ePH79x4wY1v1Ao
174XLZsmWi0lzjFpqiOHTuWXAt63759kgZUHzp0iJXC5/PF7yGmTZvGyvD69WtJy2SGhoZ27drVw8Nj
1758eLFO3fujI2Nffr0aWNjI9MGJCUlsVIGDRpEHSgAAKAc3G7GoKurK6XYTZs2kcW+evVKyiGsRQRJ
176vr6+zX4d6q4Jcu2GZGtrS+7cLuNuSAsXLiSz+fj4kPsxULdDnjp1qniehw8fkiPdjI2Nr127xirt
1771atX5MRoGXcBUoxyt+9tYYGffvop68CAgACh8mAzBgBg0aonYFHHL3UrXxn7n0XWr19vZWXFSiwu
178Lv7www//85//ZGRk1NTUFBcXR0VFjRw5cs+ePaycurq6e/fuVXj+7qpVq8gHr/Pnzw8fPjwyMrKk
179pKSuri4zM3PlypXUFarXrVsn/tcBAwaQoaWqqsrLyys4OPjRo0e1tbX5+fnHjx8fNWoUuXTX2LFj
180Je3coGXIBb8GDhzI0bUAQNsg1K4nYKFQSL70bWJlZSW+R7oUcXFxsi8DwvLtt99Sy5TxCVjShsSy
181mD9/Pllabm4udT5SswwNDbOysoQqoz5PwAKBgJwCHhUVJVQePAEDgJY/AYu2+JW0upa/v7+My1uO
182Hz/+0KFDsq+F2SQoKCg4OJhpmWXLllHXqZbOwcFh586dZHq3bt1OnDhB3dBQCh6Pd+jQIVtbW6YN
183KCgoIJdbsba25uhyAKBN0MIAbGlp6ePjo3D/c5OAgIC4uDjZf4X5fP7u3bt37dqllNVFjhw5snbt
184WtnzOzs7JyQkSFo4esKECVFRUbI/BxsZGf3666+zZs1i2gbqOirUWdQAAMqihQGYOiFY9HpY3rd6
185H330UVZW1saNG1m75JKmTJny8OFDckN7hfF4vO3bt1+9etXJyUl6zo4dO4aEhCQlJZGraImbOHFi
186amrqv/71r2ZfTo8bNy45Odnf359pM6i7VsiyBjgAgMLk7mLVCF5eXt27d8/NzVX48beJiYnJ5s2b
187//Of/8THx8fGxqakpGRnZ5eVlbVr165Tp049e/YcN27c5MmTlb4rrYiHh0dycvLNmzcvXLiQmJj4
1888uXLoqIigUBgbm5ubW09dOhQT0/PKVOmyLjmc48ePU6dOrV9+/azZ88mJiY+evSooKCgurra2NjY
189wsJiwIABI0aMmD59up2dHdPGUAMw5iABgErxWm2hXQAAANDyLmgAAAA1hwAMAADAAQRgAAAADiAA
190AwAAcAABGAAAgAMIwAAAABxAAAYAAOAAAjAAAAAHEIABAAA4gAAMAADAAQRgAAAADiAAAwAAcAAB
191GAAAgAMIwAAAABxAAAYAAOAAAjAAAAAHEIABAAA4gAAMAADAAQRgAAAADiAAAwAAcAABGAAAgAMI
192wAAAABxAAAYAAOAAAjAAAAAHEIABAAA4gAAMAADAAQRgAAAADiAAAwAAcAABGAAAgAMIwAAAAEzr
193+38UfRlkE+m5vQAAAABJRU5ErkJggg==</one:Data><one:OCRData lang="en-US"><one:OCRText><![CDATA[GLACIER HARBOR
194Invoice 4471]]></one:OCRText><one:OCRToken startPos="0" region="0" line="0" x="16.44094467163086" y="20.69291496276855" width="159.8740234375" height="27.77952766418457"/><one:OCRToken startPos="8" region="0" line="0" x="189.3543395996094" y="20.97637939453125" width="155.6220550537109" height="27.49606323242187"/><one:OCRToken startPos="15" region="0" line="1" x="17.29133796691895" y="70.29920959472656" width="119.6220474243164" height="26.36220550537109"/><one:OCRToken startPos="23" region="0" line="1" x="149.1023559570312" y="70.29920959472656" width="74.26771545410156" height="26.36220550537109"/></one:OCRData></one:Image></one:Page>
corpus/object-tags/native/read/page-002.xml created+3
...@@ -0,0 +1,3 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{B2923049-0E5F-47E9-8445-D1DADA89840F}{1}{B0}" name="https://example.invalid/picture" dateTime="2026-09-30T22:19:47.000Z" lastModifiedTime="2026-09-30T22:19:47.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T22:19:47.000Z" lastModifiedTime="2026-09-30T22:19:47.000Z" objectID="{13EF4E6D-9F1A-4647-8EA8-7EF336B1D406}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[<a
3href="https://example.invalid/picture">https://example.invalid/picture</a>]]></one:T></one:OE></one:Title></one:Page>
corpus/object-tags/native/read/page-003.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{C0E88842-6D86-0AAE-0CD9-447154F4FF16}{1}{B0}" name="Untitled page" dateTime="2026-09-29T18:04:32.000Z" lastModifiedTime="2026-09-30T22:03:27.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T18:04:33.000Z" lastModifiedTime="2026-09-29T18:04:33.000Z" objectID="{51C37BCB-D995-0EB8-1884-7F2815CDF398}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page>
corpus/object-tags/native/read/payloads.json created+34
...@@ -0,0 +1,34 @@
1[
2 {
3 "sha256": "32a01b6d0a94deebc53c688868b14b6fccc4e18b3b64ea052823e3856cf35b89",
4 "name": "float.txt",
5 "object": "{EC8778C0-4C56-0389-38D4-AE4F82C56A97}{40}{B0}",
6 "kind": "InsertedFile",
7 "page": "{4CBEB8A4-07D4-03AC-22F4-A24579522E74}{1}{B0}",
8 "bytes": 38
9 },
10 {
11 "sha256": "32a01b6d0a94deebc53c688868b14b6fccc4e18b3b64ea052823e3856cf35b89",
12 "name": "float.txt",
13 "object": "{267669B4-EEF5-0177-0E97-ACD527BFAD96}{18}{B0}",
14 "kind": "InsertedFile",
15 "page": "{4CBEB8A4-07D4-03AC-22F4-A24579522E74}{1}{B0}",
16 "bytes": 38
17 },
18 {
19 "sha256": "33fdf6e43c05ed910d335eda3fa269ccbd1415336c17a4a5357d009ee495b3a6",
20 "name": "notes.txt",
21 "object": "{D2C7D1FC-8488-4AA6-B242-2C16A821B9A7}{48}{B0}",
22 "kind": "InsertedFile",
23 "page": "{75D42A8B-EA0B-4AD8-BDA1-727B844516CE}{1}{B0}",
24 "bytes": 18
25 },
26 {
27 "sha256": "33fdf6e43c05ed910d335eda3fa269ccbd1415336c17a4a5357d009ee495b3a6",
28 "name": "notes.txt",
29 "object": "{D2C7D1FC-8488-4AA6-B242-2C16A821B9A7}{59}{B0}",
30 "kind": "InsertedFile",
31 "page": "{75D42A8B-EA0B-4AD8-BDA1-727B844516CE}{1}{B0}",
32 "bytes": 18
33 }
34]
\ No newline at end of file
corpus/object-tags/native/run.json created+19
...@@ -0,0 +1,19 @@
1{
2 "notebook": "/private/tmp/pictab/nb",
3 "expected_pages": -1,
4 "author": "tools/native/picture-objects.ps1",
5 "author_timeout_seconds": 600,
6 "inspect": true,
7 "collect_notebook": true,
8 "base": {
9 "file": "win7-office-base.qcow2",
10 "format": "qcow2",
11 "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
12 "virtual_size": 68719476736
13 },
14 "scripts": {
15 "author.ps1": "9402c73adc2a48971075f1ead915ee095b5d4abf8d09cdc9fb9473697aebe4c0",
16 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
17 "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
18 }
19}
corpus/object-tags/native/scripts/author.ps1 created+37
...@@ -0,0 +1,37 @@
1param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost)
2# A page of pictures and files, in an outline and on the page, for tagging, linking and OCR
3# by hand in the clone: notebook/photo.png, notebook/ocr.png and notebook/notes.txt ride along
4# with the section.
5Set-StrictMode -Version Latest
6$ErrorActionPreference = 'Stop'
7& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost
8$namespace = 'http://schemas.microsoft.com/office/onenote/2010/onenote'
9$notebook = Join-Path $Root 'notebook'
10$data = { param($name) [Convert]::ToBase64String([IO.File]::ReadAllBytes((Join-Path $notebook $name))) }
11$file = Join-Path $notebook 'notes.txt'
12$app = New-Object -ComObject OneNote.Application
13$notebookId = ''
14try {
15 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
16 $sectionId = ''
17 $app.OpenHierarchy('files.one', $notebookId, [ref]$sectionId, 0)
18 $pageId = ''
19 $app.CreateNewPage($sectionId, [ref]$pageId, 0)
20 $body = '<one:Title><one:OE><one:T><![CDATA[Pictures and files]]></one:T></one:OE></one:Title>'
21 $body += '<one:Outline><one:Position x="36" y="86"/><one:Size width="300" height="200"/><one:OEChildren>'
22 $body += '<one:OE><one:T><![CDATA[Picture below]]></one:T></one:OE>'
23 $body += '<one:OE><one:Image><one:Size width="120" height="90"/><one:Data>' + (& $data 'photo.png') + '</one:Data></one:Image></one:OE>'
24 $body += '<one:OE><one:InsertedFile pathSource="' + $file + '" preferredName="notes.txt"/></one:OE>'
25 $body += '<one:OE><one:T><![CDATA[Text after]]></one:T></one:OE>'
26 $body += '</one:OEChildren></one:Outline>'
27 $body += '<one:Image><one:Position x="400" y="86"/><one:Size width="120" height="90"/><one:Data>' + (& $data 'photo.png') + '</one:Data></one:Image>'
28 $body += '<one:InsertedFile pathSource="' + $file + '" preferredName="notes.txt"><one:Position x="560" y="86"/></one:InsertedFile>'
29 $body += '<one:Image><one:Position x="400" y="260"/><one:Size width="320" height="80"/><one:Data>' + (& $data 'ocr.png') + '</one:Data></one:Image>'
30 $xml = '<one:Page xmlns:one="' + $namespace + '" ID="' + $pageId + '">' + $body + '</one:Page>'
31 $app.UpdatePageContent($xml, [DateTime]::MinValue, 1, $true)
32 $app.SyncHierarchy($notebookId)
33} finally {
34 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
35 [GC]::Collect()
36 [GC]::WaitForPendingFinalizers()
37}
corpus/object-tags/native/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/object-tags/native/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/object-tags/native/source.json created+26
...@@ -0,0 +1,26 @@
1[
2 {
3 "path": "files.one",
4 "bytes": 21160,
5 "sha256": "d7d7688e86bae61505b96daf8af0d5d7adc1af3777ac13f2551cc8a339c1a182",
6 "mtime_ns": 1790805042299289270
7 },
8 {
9 "path": "notes.txt",
10 "bytes": 18,
11 "sha256": "33fdf6e43c05ed910d335eda3fa269ccbd1415336c17a4a5357d009ee495b3a6",
12 "mtime_ns": 1790805042459716143
13 },
14 {
15 "path": "ocr.png",
16 "bytes": 9598,
17 "sha256": "b81dc7dd998956abfc1394b45567140c178b7b4523d06726970f75f4a2b701db",
18 "mtime_ns": 1790805042459075351
19 },
20 {
21 "path": "photo.png",
22 "bytes": 659,
23 "sha256": "2a39fa803c359c78ce662012ae75b810a33b13647c0050c29176301dcad8eb3b",
24 "mtime_ns": 1790805042459606226
25 }
26]
corpus/object-tags/native/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
corpus/object-tags/restored/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/object-tags/restored/candidate/Open Notebook.onetoc2 differ
corpus/object-tags/restored/candidate/files.one created
Binary files /dev/null and b/corpus/object-tags/restored/candidate/files.one differ
corpus/object-tags/restored/cold/commands.jsonl created+3
...@@ -0,0 +1,3 @@
1{"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}
2{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-98295DF8 -ExpectedPages 4 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 4 pages.\r\n", "stderr": "", "error": null}
3{"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}
corpus/object-tags/restored/cold/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-98295df8", "hostname": "ONE-M6-98295DF8"}
corpus/object-tags/restored/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/object-tags/restored/cold/notebook/Open Notebook.onetoc2 differ
corpus/object-tags/restored/cold/notebook/files.one created
Binary files /dev/null and b/corpus/object-tags/restored/cold/notebook/files.one differ
corpus/object-tags/restored/cold/read/32a01b6d0a94deebc53c688868b14b6fccc4e18b3b64ea052823e3856cf35b89.attachment created+1
...@@ -0,0 +1 @@
1Floating file, stored at page level.
corpus/object-tags/restored/cold/read/33fdf6e43c05ed910d335eda3fa269ccbd1415336c17a4a5357d009ee495b3a6.attachment created+1
...@@ -0,0 +1 @@
1notes for tagging
corpus/object-tags/restored/cold/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-98295DF8",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/object-tags/restored/cold/read/hierarchy.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{BE5E2DF1-CE38-4344-BC90-497EE9FA804A}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-10-01T02:36:18.000Z" color="#B49EDE"><one:Section name="files" ID="{626481A0-A5CB-0535-2566-75D4AF7B7B5A}{1}{B0}" path="C:\one-tests\runs\capture\notebook\files.one" lastModifiedTime="2026-10-01T02:36:18.000Z" color="#B49EDE"><one:Page ID="{4E0952B8-3827-087F-08C5-10AC1F6934D1}{1}{B0}" name="Untitled page" dateTime="2026-09-29T18:04:32.000Z" lastModifiedTime="2026-10-01T02:36:17.000Z" pageLevel="1"/><one:Page ID="{C25F625E-5275-017D-26E8-F69832CFE5B3}{1}{B0}" name="Untitled page" dateTime="2026-09-29T18:04:52.000Z" lastModifiedTime="2026-09-29T18:05:53.000Z" pageLevel="1"/><one:Page ID="{FB35F071-BFAA-4809-B9BD-26A6CFD8DD09}{1}{B0}" name="Pictures and files" dateTime="2026-09-30T21:57:30.000Z" lastModifiedTime="2026-09-30T22:19:49.000Z" pageLevel="1"/><one:Page ID="{3C73EAB3-5BFE-4538-8059-850791144FC8}{1}{B0}" name="https://example.invalid/picture" dateTime="2026-09-30T22:19:47.000Z" lastModifiedTime="2026-10-01T02:36:18.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/object-tags/restored/cold/read/page-000.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "https://example.invalid/picture - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/object-tags/restored/cold/read/page-000.png created
Binary files /dev/null and b/corpus/object-tags/restored/cold/read/page-000.png differ
corpus/object-tags/restored/cold/read/page-000.xml created+3
...@@ -0,0 +1,3 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{3C73EAB3-5BFE-4538-8059-850791144FC8}{1}{B0}" name="https://example.invalid/picture" dateTime="2026-09-30T22:19:47.000Z" lastModifiedTime="2026-10-01T02:36:18.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T22:19:47.000Z" lastModifiedTime="2026-09-30T22:19:47.000Z" objectID="{9D0E9497-CABB-4496-8AB4-2A2E7D2C1FC1}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[<a
3href="https://example.invalid/picture">https://example.invalid/picture</a>]]></one:T></one:OE></one:Title></one:Page>
corpus/object-tags/restored/cold/read/page-001.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Untitled page - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/object-tags/restored/cold/read/page-001.png created
Binary files /dev/null and b/corpus/object-tags/restored/cold/read/page-001.png differ
corpus/object-tags/restored/cold/read/page-001.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{4E0952B8-3827-087F-08C5-10AC1F6934D1}{1}{B0}" name="Untitled page" dateTime="2026-09-29T18:04:32.000Z" lastModifiedTime="2026-10-01T02:36:17.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T18:04:33.000Z" lastModifiedTime="2026-09-29T18:04:33.000Z" objectID="{DF22A131-8C34-0C69-1C98-2BF55E50385F}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page>
corpus/object-tags/restored/cold/read/page-002.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Untitled page - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/object-tags/restored/cold/read/page-002.png created
Binary files /dev/null and b/corpus/object-tags/restored/cold/read/page-002.png differ
corpus/object-tags/restored/cold/read/page-002.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{C25F625E-5275-017D-26E8-F69832CFE5B3}{1}{B0}" name="Untitled page" dateTime="2026-09-29T18:04:52.000Z" lastModifiedTime="2026-09-29T18:05:53.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-29T18:04:53.000Z" lastModifiedTime="2026-09-29T18:05:06.000Z" objectID="{6266A23A-19F7-0158-3CC8-FA92C958A150}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[]]></one:T></one:OE></one:Title><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\2141eff6-0714-43c5-9b45-2387696fe77f.txt" pathSource="C:\obs\float.txt" preferredName="float.txt" lastModifiedTime="2026-09-29T18:04:53.000Z" objectID="{6266A23A-19F7-0158-3CC8-FA92C958A150}{40}{B0}"><one:Position x="288.0" y="140.4000091552734" z="0"/><one:Size width="54.0" height="63.0"/></one:InsertedFile><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\2141eff6-0714-43c5-9b45-2387696fe77f.txt" pathSource="C:\obs\float.txt" preferredName="float.txt" lastModifiedTime="2026-09-29T18:05:53.000Z" objectID="{A897B34E-BB54-03A6-0A8B-F8086C226651}{18}{B0}"><one:Position x="342.0" y="284.3999938964844" z="1"/><one:Size width="54.0" height="62.99998092651367"/></one:InsertedFile></one:Page>
corpus/object-tags/restored/cold/read/page-003.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Pictures and files - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/object-tags/restored/cold/read/page-003.png created
Binary files /dev/null and b/corpus/object-tags/restored/cold/read/page-003.png differ
corpus/object-tags/restored/cold/read/page-003.xml created+194
...@@ -0,0 +1,194 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{FB35F071-BFAA-4809-B9BD-26A6CFD8DD09}{1}{B0}" name="Pictures and files" dateTime="2026-09-30T21:57:30.000Z" lastModifiedTime="2026-09-30T22:19:49.000Z" pageLevel="1" lang="en-US"><one:TagDef index="0" type="0" symbol="3" fontColor="automatic" highlightColor="none" name="To Do"/><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T21:57:31.000Z" lastModifiedTime="2026-09-30T22:03:29.000Z" objectID="{5C260B06-D129-4877-B65E-78CBE3BC7260}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Pictures and files]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T22:06:26.000Z" objectID="{5C260B06-D129-4877-B65E-78CBE3BC7260}{39}{B0}"><one:Position x="36.0" y="86.0" z="0"/><one:Size width="119.9999847412109" height="179.8554229736328"/><one:OEChildren><one:OE creationTime="2026-09-30T21:57:31.000Z" lastModifiedTime="2026-09-30T22:03:29.000Z" objectID="{5C260B06-D129-4877-B65E-78CBE3BC7260}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Picture below]]></one:T></one:OE><one:OE creationTime="2026-09-30T21:57:31.000Z" lastModifiedTime="2026-09-30T22:06:26.000Z" objectID="{5C260B06-D129-4877-B65E-78CBE3BC7260}{45}{B0}" alignment="left"><one:Tag index="0" completed="false" disabled="false" creationDate="2026-09-30T22:05:56.000Z"/><one:Image format="png" originalPageNumber="0"><one:Size width="120.0" height="90.0"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAKAAAAB4CAIAAAD6wG44AAACWklEQVR4nO3dy1EkQRAE0WYMsRCh
3DoiAGIiDCBwQYsVaFcCgKyK93M8cJvNZ9vAzeHp5/3cZt0f6Bdi9CQxPYHgCwxMYnsDwBIYnMDyB
44QkMT2B4AsMTGJ7A8ASGJzA8geEJDE9geM8Xt6/Xt+9/8Pr8uIg9YX7p7kec55DPBv5zVB72SOBt
5rgDpScBB17nSM4CraGcxtwPX0k5h7gUeQdvPXPqNjnG6V+trrrvgzjXNPeWuCwboXmVTtFxw1VJI
6p1xxwUjdq2OuPHDDFsDTPQ6fHz9jEvgE3fikMeBzdLPzZoBP0w1OHQA+Uzc1+27gk3UjG8h/mWQc
7YM93/x72Aasb2YaPaHibgD3f1E68YHg7gD3f4Ga8YHi3A3u+2f14wfAEhncvsM/n+Ja8YHgCwxMY
83o3AvgE37MoLhicwPIHhCQxPYHgCwxMYnsDwBIYnMLwH+w8YTOm+XXnB8ASGJzC8e4F9G45vyQuG
9JzC824F9Smf34wXD2wHsEQc34wXD2wTsEad24gXD2wfsEUe2sfWCNd6/Bx/R8HYDe8Rr74/JAxd8
10svHaPnvmEX2m8UpMHXsPPs14heZNfpJ1jvHKTRr+LPoE4xWdMf9lEtt4pafLAzdsATxXy7+2g/3V
11h1VAW3TBhXvBTNF1wdNPeTXRNl5w86aGvubSC551yquSdgZwOfMqpp0EXMi86mnnATdIryGus4H3
12S69prhDgW7HXWFQm8C/JF4LzLGDr/UaH/VUCwxMYnsDwBIYnMDyB4QkMT2B4AsMTGJ7A8ASGJzA8
13geEJDE9geAJf7P4Dry+9j2KmlKMAAAAASUVORK5CYII=</one:Data></one:Image></one:OE><one:OE creationTime="2026-09-30T21:57:31.000Z" lastModifiedTime="2026-09-30T22:05:57.000Z" objectID="{5C260B06-D129-4877-B65E-78CBE3BC7260}{48}{B0}" alignment="left"><one:Tag index="0" completed="false" disabled="false" creationDate="2026-09-30T22:05:57.000Z"/><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\5e789b39-27a8-4562-8c92-f2887ca85b9f.txt" pathSource="C:\one-tests\runs\capture\notebook\notes.txt" preferredName="notes.txt"/></one:OE><one:OE creationTime="2026-09-30T21:57:31.000Z" lastModifiedTime="2026-09-30T22:03:29.000Z" objectID="{5C260B06-D129-4877-B65E-78CBE3BC7260}{52}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Text after]]></one:T></one:OE></one:OEChildren></one:Outline><one:Image format="png" originalPageNumber="0" lastModifiedTime="2026-09-30T22:19:42.000Z" objectID="{5C260B06-D129-4877-B65E-78CBE3BC7260}{57}{B0}"><one:Position x="400.0000305175781" y="86.0" z="1"/><one:Size width="119.9999847412109" height="90.0"/><one:Tag index="0" completed="false" disabled="false" creationDate="2026-09-30T22:19:42.000Z"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAKAAAAB4CAIAAAD6wG44AAACWklEQVR4nO3dy1EkQRAE0WYMsRCh
14DoiAGIiDCBwQYsVaFcCgKyK93M8cJvNZ9vAzeHp5/3cZt0f6Bdi9CQxPYHgCwxMYnsDwBIYnMDyB
154QkMT2B4AsMTGJ7A8ASGJzA8geEJDE9geM8Xt6/Xt+9/8Pr8uIg9YX7p7kec55DPBv5zVB72SOBt
16rgDpScBB17nSM4CraGcxtwPX0k5h7gUeQdvPXPqNjnG6V+trrrvgzjXNPeWuCwboXmVTtFxw1VJI
17p1xxwUjdq2OuPHDDFsDTPQ6fHz9jEvgE3fikMeBzdLPzZoBP0w1OHQA+Uzc1+27gk3UjG8h/mWQc
18YM93/x72Aasb2YaPaHibgD3f1E68YHg7gD3f4Ga8YHi3A3u+2f14wfAEhncvsM/n+Ja8YHgCwxMY
193o3AvgE37MoLhicwPIHhCQxPYHgCwxMYnsDwBIYnMLwH+w8YTOm+XXnB8ASGJzC8e4F9G45vyQuG
20JzC824F9Smf34wXD2wHsEQc34wXD2wTsEad24gXD2wfsEUe2sfWCNd6/Bx/R8HYDe8Rr74/JAxd8
21svHaPnvmEX2m8UpMHXsPPs14heZNfpJ1jvHKTRr+LPoE4xWdMf9lEtt4pafLAzdsATxXy7+2g/3V
22h1VAW3TBhXvBTNF1wdNPeTXRNl5w86aGvubSC551yquSdgZwOfMqpp0EXMi86mnnATdIryGus4H3
23S69prhDgW7HXWFQm8C/JF4LzLGDr/UaH/VUCwxMYnsDwBIYnMDyB4QkMT2B4AsMTGJ7A8ASGJzA8
24geEJDE9geAJf7P4Dry+9j2KmlKMAAAAASUVORK5CYII=</one:Data></one:Image><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\5e789b39-27a8-4562-8c92-f2887ca85b9f.txt" pathSource="C:\one-tests\runs\capture\notebook\notes.txt" preferredName="notes.txt" lastModifiedTime="2026-09-30T21:57:31.000Z" objectID="{5C260B06-D129-4877-B65E-78CBE3BC7260}{59}{B0}"><one:Position x="560.0" y="86.0" z="2"/><one:Size width="54.0" height="63.0"/><one:Tag index="0" completed="false" disabled="false" creationDate="2026-09-30T22:19:49.000Z"/></one:InsertedFile><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T22:19:47.000Z" objectID="{2C743F2D-0407-48BB-8D64-C36D71AEEC25}{15}{B0}"><one:Position x="468.0" y="122.400001525879" z="4"/><one:Size width="180.6122131347656" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-30T22:19:47.000Z" lastModifiedTime="2026-09-30T22:19:47.000Z" objectID="{2C743F2D-0407-48BB-8D64-C36D71AEEC25}{16}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[<a
25href="onenote:files.one#https//example.invalid/picture&amp;section-id={B771AD51-7810-4F70-BA52-95546B525FDD}&amp;page-id={76596C1B-90C9-4493-ACAF-9A0CBA6CC7FD}&amp;base-path=//C:/one-tests/runs/capture/notebook">https://example.invalid/picture</a>]]></one:T></one:OE></one:OEChildren></one:Outline><one:Image format="png" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{5C260B06-D129-4877-B65E-78CBE3BC7260}{62}{B0}"><one:Position x="400.0000305175781" y="260.0" z="3"/><one:Size width="319.9999694824219" height="80.0" isSetByUser="true"/><one:Data>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
153mZkpvroWuRuuq6urpMm+y5cvZ6UkJSUVFBQwigoPD4+OjhZPcXFxoY6D46pAqq+++oqVQp3zBgDQ
154EtwHYOrTswj5JCqKwawUshc6PT09IyODlUguS9SrV6/Ro0ezEs+fP0+elNot3KR3795jxoxhJT54
1558KC4uJiR061bt8jESZMmScq/YcOGZ8+eXbx4cdeuXUuWLPHw8BAfZVZTU/PgwQPWIW5ubpJKs7Oz
156s7S0FE8RCoX3799nFPLy5UvWEHdjY+OwsDDyZoWrAqlu3779559/iqc4Ojq6uroq8RQAAGoRgKVM
157TjU3NycTGxoaWCmfffYZn8+X/hBcWlp68eJFVp6AgADyreft27dZKYaGhuR8JxZPT09WilAovHbt
158GiMnch6LqampjY0No5D79+/X1dWxEqn9DU26devGSklNTVXs7PPnz3/79q14yrZt2/r27atYaaoo
159kGrv3r2slMDAQOWeAgBALQIwazanuHbt2pGJ5KY3nTp1Ip8RRUtiNf31zJkzrFDE4/HmzJlDlp+Z
160mclKGTBgABngWRwcHMjEnJwcRk6vX79uNiLKLj8/n0xctmwZTzLyiTkrK0uBUx84cIB1xzN69Gjq
161gDWuCqQqLi4+e/aseIqhoSE55QkAQBsCMDkwqkmzYa8JuSBwTk6O+Pgs8q3wmDFjevTowUqsrKys
162ra1lJbLG3FJR8xQVFTFyevPmDStF4enI1NIUQL44b9bz589ZL01NTEyOHDlC9jdwVaAk4eHhrAYw
163efJkKU0UAECDA7C+vr6kj8iZqZJ4e3uTPasnTpygBmNJw6+o/duSHsRlyaNA6CLH8bbkBee7d++Y
164FquqqpIrv2huGOuL/Pjjj9Q3+pwUKAU5w3jGjBlKPwsAAPcBWEdHRykPMbq6umR/ckREhGgr1lOn
165TrE6rs3MzD755BMZ4yg57ItUUVFBJja7hQOJfN6VN/6Joy7oIS95LyA/P5/cC2jRokVkd7epqSl5
166+LRp05oyLF68WBUFSpKbm8t6B6+npzd27Fh5vj0AgIYEYCV2IZJjoYuKikSDiiMjI1kfzZw5U3yu
167ThM+n0/2N5aUlDR7duqA5/fee4+RU4cOHZT4FGtmZkYmRkZGyjVT7fr163KdlHxJ30JKL1CSiIgI
1681rlGjRpFDeoAANrQBa0sffv2JSfYREdH5+fnk0OLqf3PIv369WOlZGZmUrumxaWlpZGJvXv3Zlo8
169JC0vL498LS0jcuFr6jgvEImNjWVVBbmFEQCAsmhPAKYOxTp//nxMTAzrsWbgwIFSNtl1dHRkpdTU
1701FCn54r7448/WCl8Pt/d3Z2R07Bhw1gpAoGAGt1F8vPzLS0tR44cOW/evJCQkPPnzz958qTpdoH8
171LqJlsOS9qragvr6enIEmafUVAICW06oA7Ofnx+pAfvLkyY4dO2R//JX00HPw4EEph2RnZ5MvKYcP
172H67A6NmRI0eSiTExMZLy//HHHyUlJbdu3QoNDV27dq2vr6+trW1UVJTo044dO5JP4b///nt9fb28
173F6b1/vrrL9bLfn19fXItNgAAZdGqAGxsbEyOWX38+LH4X/l8vvStdr29vclXp8ePH79x4wY1v1Ao
174XLZsmWi0lzjFpqiOHTuWXAt63759kgZUHzp0iJXC5/PF7yGmTZvGyvD69WtJy2SGhoZ27drVw8Nj
1758eLFO3fujI2Nffr0aWNjI9MGJCUlsVIGDRpEHSgAAKAc3G7GoKurK6XYTZs2kcW+evVKyiGsRQRJ
176vr6+zX4d6q4Jcu2GZGtrS+7cLuNuSAsXLiSz+fj4kPsxULdDnjp1qniehw8fkiPdjI2Nr127xirt
1771atX5MRoGXcBUoxyt+9tYYGffvop68CAgACh8mAzBgBg0aonYFHHL3UrXxn7n0XWr19vZWXFSiwu
178Lv7www//85//ZGRk1NTUFBcXR0VFjRw5cs+ePaycurq6e/fuVXj+7qpVq8gHr/Pnzw8fPjwyMrKk
179pKSuri4zM3PlypXUFarXrVsn/tcBAwaQoaWqqsrLyys4OPjRo0e1tbX5+fnHjx8fNWoUuXTX2LFj
180Je3coGXIBb8GDhzI0bUAQNsg1K4nYKFQSL70bWJlZSW+R7oUcXFxsi8DwvLtt99Sy5TxCVjShsSy
181mD9/Pllabm4udT5SswwNDbOysoQqoz5PwAKBgJwCHhUVJVQePAEDgJY/AYu2+JW0upa/v7+My1uO
182Hz/+0KFDsq+F2SQoKCg4OJhpmWXLllHXqZbOwcFh586dZHq3bt1OnDhB3dBQCh6Pd+jQIVtbW6YN
183KCgoIJdbsba25uhyAKBN0MIAbGlp6ePjo3D/c5OAgIC4uDjZf4X5fP7u3bt37dqllNVFjhw5snbt
184WtnzOzs7JyQkSFo4esKECVFRUbI/BxsZGf3666+zZs1i2gbqOirUWdQAAMqihQGYOiFY9HpY3rd6
185H330UVZW1saNG1m75JKmTJny8OFDckN7hfF4vO3bt1+9etXJyUl6zo4dO4aEhCQlJZGraImbOHFi
186amrqv/71r2ZfTo8bNy45Odnf359pM6i7VsiyBjgAgMLk7mLVCF5eXt27d8/NzVX48beJiYnJ5s2b
187//Of/8THx8fGxqakpGRnZ5eVlbVr165Tp049e/YcN27c5MmTlb4rrYiHh0dycvLNmzcvXLiQmJj4
1888uXLoqIigUBgbm5ubW09dOhQT0/PKVOmyLjmc48ePU6dOrV9+/azZ88mJiY+evSooKCgurra2NjY
189wsJiwIABI0aMmD59up2dHdPGUAMw5iABgErxWm2hXQAAANDyLmgAAAA1hwAMAADAAQRgAAAADiAA
190AwAAcAABGAAAgAMIwAAAABxAAAYAAOAAAjAAAAAHEIABAAA4gAAMAADAAQRgAAAADiAAAwAAcAAB
191GAAAgAMIwAAAABxAAAYAAOAAAjAAAAAHEIABAAA4gAAMAADAAQRgAAAADiAAAwAAcAABGAAAgAMI
192wAAAABxAAAYAAOAAAjAAAAAHEIABAAA4gAAMAADAAQRgAAAADiAAAwAAcAABGAAAgAMIwAAAAEzr
193+38UfRlkE+m5vQAAAABJRU5ErkJggg==</one:Data><one:OCRData lang="en-US"><one:OCRText><![CDATA[GLACIER HARBOR
194Invoice 4471]]></one:OCRText><one:OCRToken startPos="0" region="0" line="0" x="16.44094467163086" y="20.69291496276855" width="159.8740234375" height="27.77952766418457"/><one:OCRToken startPos="8" region="0" line="0" x="189.3543395996094" y="20.97637939453125" width="155.6220550537109" height="27.49606323242187"/><one:OCRToken startPos="15" region="0" line="1" x="17.29133796691895" y="70.29920959472656" width="119.6220474243164" height="26.36220550537109"/><one:OCRToken startPos="23" region="0" line="1" x="149.1023559570312" y="70.29920959472656" width="74.26771545410156" height="26.36220550537109"/></one:OCRData></one:Image></one:Page>
corpus/object-tags/restored/cold/read/payloads.json created+34
...@@ -0,0 +1,34 @@
1[
2 {
3 "sha256": "32a01b6d0a94deebc53c688868b14b6fccc4e18b3b64ea052823e3856cf35b89",
4 "name": "float.txt",
5 "object": "{6266A23A-19F7-0158-3CC8-FA92C958A150}{40}{B0}",
6 "kind": "InsertedFile",
7 "page": "{C25F625E-5275-017D-26E8-F69832CFE5B3}{1}{B0}",
8 "bytes": 38
9 },
10 {
11 "sha256": "32a01b6d0a94deebc53c688868b14b6fccc4e18b3b64ea052823e3856cf35b89",
12 "name": "float.txt",
13 "object": "{A897B34E-BB54-03A6-0A8B-F8086C226651}{18}{B0}",
14 "kind": "InsertedFile",
15 "page": "{C25F625E-5275-017D-26E8-F69832CFE5B3}{1}{B0}",
16 "bytes": 38
17 },
18 {
19 "sha256": "33fdf6e43c05ed910d335eda3fa269ccbd1415336c17a4a5357d009ee495b3a6",
20 "name": "notes.txt",
21 "object": "{5C260B06-D129-4877-B65E-78CBE3BC7260}{48}{B0}",
22 "kind": "InsertedFile",
23 "page": "{FB35F071-BFAA-4809-B9BD-26A6CFD8DD09}{1}{B0}",
24 "bytes": 18
25 },
26 {
27 "sha256": "33fdf6e43c05ed910d335eda3fa269ccbd1415336c17a4a5357d009ee495b3a6",
28 "name": "notes.txt",
29 "object": "{5C260B06-D129-4877-B65E-78CBE3BC7260}{59}{B0}",
30 "kind": "InsertedFile",
31 "page": "{FB35F071-BFAA-4809-B9BD-26A6CFD8DD09}{1}{B0}",
32 "bytes": 18
33 }
34]
\ No newline at end of file
corpus/object-tags/restored/cold/run.json created+18
...@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/ocrlab/candidate",
3 "expected_pages": 4,
4 "author": null,
5 "author_timeout_seconds": 600,
6 "inspect": false,
7 "collect_notebook": true,
8 "base": {
9 "file": "win7-office-base.qcow2",
10 "format": "qcow2",
11 "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
12 "virtual_size": 68719476736
13 },
14 "scripts": {
15 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
16 "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
17 }
18}
corpus/object-tags/restored/cold/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/object-tags/restored/cold/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/object-tags/restored/cold/source.json created+14
...@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1604,
5 "sha256": "dc9d3f6501503965aa97e94b86766f4e9a87d279eeff4a253ff4612ca8993405",
6 "mtime_ns": 1790822016309856876
7 },
8 {
9 "path": "files.one",
10 "bytes": 67592,
11 "sha256": "fab6f6881081cf1b05cc375301bcfe1e7eda90a294bab3fa137108cf92b45026",
12 "mtime_ns": 1790822016309661293
13 }
14]
corpus/object-tags/restored/cold/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
corpus/picture-link/README.md created+15
...@@ -0,0 +1,15 @@
1# Pictures with a link
2
3`lab/` holds screenshots of OneNote 2010 in a disposable clone of
4`corpus/m6/native-features-01`, on the page "Image png", whose picture stores a link
5(WzHyperlinkUrl, the COM read's `hyperlink`). Hovering it shows the address and "Ctrl+click
6to follow link" (`hover.png`); a click selects the picture (`click-selects.png`); Ctrl+click
7opens the address, here an unreachable one (`ctrl-click.png`). Snowbound follows the same
8link on Ctrl+click, Command+click on macOS.
9
10`candidate/` is the output of `a_linked_picture_keeps_its_link_through_undo` in
11`crates/canvas/tests/pictures.rs`: the picture deleted and brought back by undo, published
12as one revision. `cold/` is a fresh OneNote 2010 read, whose picture still links there.
13`tools/test_picture_link.py` checks this without a VM. Regenerate with
14`CANVAS_PICTURE_LINK_EXPORT` set to a new absolute directory while running the test, then
15cold-open it with `tools/native_runner.py OUTPUT COLD --collect-notebook`.
corpus/picture-link/candidate/Features.one created
Binary files /dev/null and b/corpus/picture-link/candidate/Features.one differ
corpus/picture-link/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/picture-link/candidate/Open Notebook.onetoc2 differ
corpus/picture-link/cold/commands.jsonl created+3
...@@ -0,0 +1,3 @@
1{"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}
2{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-70F8F401 -ExpectedPages -1", "exit": 0, "stdout": "Read 1 sections and 13 pages.\r\n", "stderr": "", "error": null}
3{"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}
corpus/picture-link/cold/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-70f8f401", "hostname": "ONE-M6-70F8F401"}
corpus/picture-link/cold/notebook/Features.one created
Binary files /dev/null and b/corpus/picture-link/cold/notebook/Features.one differ
corpus/picture-link/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/picture-link/cold/notebook/Open Notebook.onetoc2 differ
corpus/picture-link/cold/read/4cff0039dbdf53a59f5f4e4592c29a5297ea3b7acce24415c0ae83c36c6bd7c2.attachment created
Binary files /dev/null and b/corpus/picture-link/cold/read/4cff0039dbdf53a59f5f4e4592c29a5297ea3b7acce24415c0ae83c36c6bd7c2.attachment differ
corpus/picture-link/cold/read/56d4af65701c26df20bd4021eda95b6e830348ce3a746086079fe89285548dc9.attachment created
Binary files /dev/null and b/corpus/picture-link/cold/read/56d4af65701c26df20bd4021eda95b6e830348ce3a746086079fe89285548dc9.attachment differ
corpus/picture-link/cold/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-70F8F401",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/picture-link/cold/read/hierarchy.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{61B523C4-0363-4001-9972-20EDE31F9FC2}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T23:00:10.000Z" color="#B49EDE"><one:Section name="Features" ID="{85110ED2-E5F5-05D7-1E5C-DC57A8A418C6}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Features.one" lastModifiedTime="2026-09-30T23:00:10.000Z" color="#91BAAE"><one:Page ID="{86DA38AE-66AD-012D-0140-0BD7D50D4CD6}{1}{B0}" name="Image png" dateTime="2026-09-06T02:25:40.000Z" lastModifiedTime="2026-09-30T23:00:09.000Z" pageLevel="1"/><one:Page ID="{E4150670-59A6-0ECB-0BDD-D595FA83480D}{1}{B0}" name="Image jpg" dateTime="2026-09-06T02:25:42.000Z" lastModifiedTime="2026-09-30T23:00:09.000Z" pageLevel="1"/><one:Page ID="{4D27D57A-6002-00EF-257A-3D7B07F997BC}{1}{B0}" name="Image bmp" dateTime="2026-09-06T02:25:42.000Z" lastModifiedTime="2026-09-30T23:00:09.000Z" pageLevel="1"/><one:Page ID="{E25EA0DE-E27F-0A28-1A8D-D17E0ECC7E0F}{1}{B0}" name="Image tiff" dateTime="2026-09-06T02:25:43.000Z" lastModifiedTime="2026-09-30T23:00:09.000Z" pageLevel="1"/><one:Page ID="{2B90AB1F-25D8-0528-23B6-0068569D16DB}{1}{B0}" name="Image gif" dateTime="2026-09-06T02:25:44.000Z" lastModifiedTime="2026-09-30T23:00:09.000Z" pageLevel="1"/><one:Page ID="{2F5BAA67-B283-0E93-0BEF-3242BD9EA86E}{1}{B0}" name="Image role background=false, printout=false" dateTime="2026-09-06T02:25:45.000Z" lastModifiedTime="2026-09-30T23:00:09.000Z" pageLevel="1"/><one:Page ID="{F86A5F48-E893-06F7-2CDE-4540E56F4C1D}{1}{B0}" name="Image role background=true, printout=false" dateTime="2026-09-06T02:25:45.000Z" lastModifiedTime="2026-09-30T23:00:09.000Z" pageLevel="1"/><one:Page ID="{6F95DBC7-8E09-0A8C-29EE-9E4F78401929}{1}{B0}" name="Image role background=false, printout=true" dateTime="2026-09-06T02:25:46.000Z" lastModifiedTime="2026-09-30T23:00:09.000Z" pageLevel="1"/><one:Page ID="{3DBE8FF1-FCD5-05FE-3C1A-2C47F261DC16}{1}{B0}" name="Image role background=true, printout=true" dateTime="2026-09-06T02:25:46.000Z" lastModifiedTime="2026-09-30T23:00:09.000Z" pageLevel="1"/><one:Page ID="{95B9BD1C-79B9-0611-2445-33C3814DE07A}{1}{B0}" name="Files and recording" dateTime="2026-09-06T02:25:46.000Z" lastModifiedTime="2026-09-30T23:00:09.000Z" pageLevel="1"/><one:Page ID="{710863F5-A9D2-0E07-3FB8-007CC60AD628}{1}{B0}" name="Paragraph controls" dateTime="2026-09-06T02:25:48.000Z" lastModifiedTime="2026-09-30T23:00:10.000Z" pageLevel="1"/><one:Page ID="{55DF55E0-26A1-0C16-13B3-2B2199955F6D}{1}{B0}" name="Numbering controls" dateTime="2026-09-06T02:25:49.000Z" lastModifiedTime="2026-09-30T23:00:10.000Z" pageLevel="1"/><one:Page ID="{ADE0856F-5F03-05E7-03D6-EE9AF29EDD7B}{1}{B0}" name="Task controls" dateTime="2026-09-06T02:25:50.000Z" lastModifiedTime="2026-09-30T23:00:10.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/picture-link/cold/read/page-000.xml created+46
...@@ -0,0 +1,46 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{2B90AB1F-25D8-0528-23B6-0068569D16DB}{1}{B0}" name="Image gif" dateTime="2026-09-06T02:25:44.000Z" lastModifiedTime="2026-09-30T23:00:09.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-06T02:25:45.000Z" lastModifiedTime="2026-09-06T02:25:45.000Z" objectID="{50C5A32E-43F2-00DB-3410-9455A6AA9FB8}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Image gif]]></one:T></one:OE></one:Title><one:Image format="png" hyperlink="https://example.invalid/image/gif" alt="Pattern gif" originalPageNumber="0" lastModifiedTime="2026-09-06T02:25:45.000Z" objectID="{50C5A32E-43F2-00DB-3410-9455A6AA9FB8}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="144.0" height="48.00000381469727" isSetByUser="true"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAGAAAABACAMAAADS6oI8AAADAFBMVEW4t5mrt5O4qpWrqo6euo2S
3uoeZs4ieqoiSqoK4nJCrnIq4kIyrkIaen4SSn36ZmH+ekH+SkHmGuoGGs395t3pounJos3CGqnx5
4qnVtqm9iqmqGnHd5nHGGkHN5kG1on2lomGdokGS4hIirhIK4d4Srd32eh3ySh3aZgHeed3eSd3G4
5a4Cra3m4YHyrYHaea3OSa22eYG+SYGmGh3CGgG55hGloh2FogF+Gd2t5d2Rtd15id1mGa2d5a2CG
6YGN5YF1ta1pia1VoYFRWumlWs2dJt2I4ulo4s1hWqmRJql09qlcyqlImulEaukshs0wKukMKs0Em
7qkwaqkYPqkAEqjtWnF9JnFlWkFtJkFU4n1E4mE84kEwmn0gan0IhmEMmkEMakD0KnzoKmDgKkDVW
8h1hWgFZJhFE4h0k4gEdWd1NJd0w9d0Yyd0FWa09Ja0hWYEtJYEU9a0Iyaz04YDwmh0AahzohgDsK
9hzIKgDAmdzsadzUPdy8EdyomazcaazEmYDMaYC0PaysEayYKYCW4VHirVHK4R3SrR22eV2ySV2aZ
10UGeeR2eSR2G4O3CrO2m4MGyrMGaeO2OSO12eMF+SMFmGV2CGUF55VFloV1FoUE+GR1t5R1RtR05i
11R0mGO1d5O1CGMFN5ME1tO0piO0VoMES4JGirJGK4GGSrGF6eJ1ySJ1aZIFeeGFeSGFG4DmGrDlq4
12A12rA1eeDlSSDk6eA1CSA0qGJE95JEmGGEt5GEVoJ0FoID9oGDyGDkh5DkGGA0R5Az5tDjtiDjZo
13AzVWVEdJVEE4Vzk4UDdWR0NJRzw9RzYyRzFWOz9JOzhWMDtJMDU9OzIyOy04MCwmVzAaVyohUCsK
14VyIKUCAmRysaRyUPRx8ERxomOycaOyEmMCMaMB0POxsEOxYKMBVWJDdJJDFWGDNJGC04Jyk4ICc4
15GCRWDjBJDilWAyxJAyY9DiMyDh44Ax0mJyAaJxohIBsmGBsaGBUKJxIKIBAKGA0mDhgaDhImAxQa
16Aw4PDgwEDgcKAwZYdbPqAAAAAWJLR0QAiAUdSAAAAAxjbVBQSkNtcDA3MTIAAAADSABzvAAABjJJ
17REFUWEedkImbTgUcha9ChZl2RUKR0TKTtGiRsZUSEpI0tKm0GoW0WSOjMmNpL82oFG1apJJltNDC
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
45AElFTkSuQmCC
46</one:Data></one:Image></one:Page>
corpus/picture-link/cold/read/page-001.xml created+6
...@@ -0,0 +1,6 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{2F5BAA67-B283-0E93-0BEF-3242BD9EA86E}{1}{B0}" name="Image role background=false, printout=false" dateTime="2026-09-06T02:25:45.000Z" lastModifiedTime="2026-09-30T23:00:09.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-06T02:25:45.000Z" lastModifiedTime="2026-09-06T02:25:45.000Z" objectID="{1D8CE7A0-6949-0F4D-2D02-51EB1E0078DC}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Image role background=false, printout=false]]></one:T></one:OE></one:Title><one:Image format="png" alt="Role pattern" originalPageNumber="2" lastModifiedTime="2026-09-06T02:25:46.000Z" objectID="{1D8CE7A0-6949-0F4D-2D02-51EB1E0078DC}{39}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="288.0" height="192.0" isSetByUser="true"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAGAAAABACAIAAABqVuVZAAAAwUlEQVR4nO3ZwQ2DQBDF0Hkk/dec
3CuASwXCwtBVY/mYBM3OMznEC4TsfMx1nEALk2o8ACZB/GpJBAiSD3PcgbmICJIM0MVvX/RokQDJI
4E1ODvPOrS5EWIBmkialB1ntcpAVIBmli1rvTRVGAZJAmZj1AvawKkAzSxNQg6zEu0gIkgzQx69Hp
5oihAMkgT85LTn1UBkkGamBpkvcdFWoBkkCZmvTtdFAVIBmli1gPUy6oAySBNTA2yHuMiLUAyyPMT
6+wHXVRp4qDc0HQAAAABJRU5ErkJggg==</one:Data></one:Image><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-06T02:25:45.000Z" objectID="{1D8CE7A0-6949-0F4D-2D02-51EB1E0078DC}{41}{B0}"><one:Position x="72.0" y="108.0" z="1"/><one:Size width="146.7475433349609" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-06T02:25:45.000Z" lastModifiedTime="2026-09-06T02:25:45.000Z" objectID="{1D8CE7A0-6949-0F4D-2D02-51EB1E0078DC}{42}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Foreground role control]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/picture-link/cold/read/page-002.xml created+6
...@@ -0,0 +1,6 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{3DBE8FF1-FCD5-05FE-3C1A-2C47F261DC16}{1}{B0}" name="Image role background=true, printout=true" dateTime="2026-09-06T02:25:46.000Z" lastModifiedTime="2026-09-30T23:00:09.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-06T02:25:46.000Z" lastModifiedTime="2026-09-06T02:25:46.000Z" objectID="{D27133B4-CC86-0FF3-1D02-3535E1C0B3D1}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Image role background=true, printout=true]]></one:T></one:OE></one:Title><one:Image format="png" backgroundImage="true" isPrintOut="true" alt="Role pattern" originalPageNumber="2" lastModifiedTime="2026-09-06T02:25:48.000Z" objectID="{D27133B4-CC86-0FF3-1D02-3535E1C0B3D1}{39}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="288.0" height="192.0" isSetByUser="true"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAGAAAABACAIAAABqVuVZAAAAwUlEQVR4nO3ZwQ2DQBDF0Hkk/dec
3CuASwXCwtBVY/mYBM3OMznEC4TsfMx1nEALk2o8ACZB/GpJBAiSD3PcgbmICJIM0MVvX/RokQDJI
4E1ODvPOrS5EWIBmkialB1ntcpAVIBmli1rvTRVGAZJAmZj1AvawKkAzSxNQg6zEu0gIkgzQx69Hp
5oihAMkgT85LTn1UBkkGamBpkvcdFWoBkkCZmvTtdFAVIBmli1gPUy6oAySBNTA2yHuMiLUAyyPMT
6+wHXVRp4qDc0HQAAAABJRU5ErkJggg==</one:Data></one:Image><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-06T02:25:47.000Z" objectID="{D27133B4-CC86-0FF3-1D02-3535E1C0B3D1}{41}{B0}"><one:Position x="72.0" y="108.0" z="1"/><one:Size width="146.7475433349609" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-06T02:25:46.000Z" lastModifiedTime="2026-09-06T02:25:46.000Z" objectID="{D27133B4-CC86-0FF3-1D02-3535E1C0B3D1}{42}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Foreground role control]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/picture-link/cold/read/page-003.xml created+9
...@@ -0,0 +1,9 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{4D27D57A-6002-00EF-257A-3D7B07F997BC}{1}{B0}" name="Image bmp" dateTime="2026-09-06T02:25:42.000Z" lastModifiedTime="2026-09-30T23:00:09.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-06T02:25:43.000Z" lastModifiedTime="2026-09-06T02:25:43.000Z" objectID="{1C6391D4-C906-07B8-3EDB-2E6DE96118ED}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Image bmp]]></one:T></one:OE></one:Title><one:Image format="png" hyperlink="https://example.invalid/image/bmp" alt="Pattern bmp" originalPageNumber="0" lastModifiedTime="2026-09-06T02:25:49.000Z" objectID="{1C6391D4-C906-07B8-3EDB-2E6DE96118ED}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="144.0" height="48.00000381469727" isSetByUser="true"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAGAAAABACAIAAABqVuVZAAAAAXNSR0IArs4c6QAAAAlwSFlzAAAO
3xAAADsQBlSsOGwAAARlJREFUeF7t2VFHQ2EcBvD3PWetVqvVam1ERESMiIiIiL7/J5p9gD1dvF51
48eNcn4uf53n2P1ZLKUOpnkMCtYx0kkAt00F8gkAtsxFQBJpPAEWgxRGgCLScAopAq2NAEWhzAigC
53c0ARaD7U0AR6OEMUAR6nAOKQE/ngCLQ8wWgCLRdAIpAL5eAItDrFaAI9LYEFIHerwFFoI8bQBHo
6cwUoAn3dAopA32tAEehnAygKjPu/xjyHBAY0WQDQL+0BBKhtYSVIgiSo6w+xiqmYiqnYX34J2SAb
7ZINskA1qa4EESZAEdW1B15e7g9xBbf2VIAmSICPtDmprgQRJkAR1bUHXl7uD3EFt/ZUgCZIgI+0O
8amuBBEnQP07QDo1OW207QPoWAAAAAElFTkSuQmCC
9</one:Data></one:Image></one:Page>
corpus/picture-link/cold/read/page-004.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{55DF55E0-26A1-0C16-13B3-2B2199955F6D}{1}{B0}" name="Numbering controls" dateTime="2026-09-06T02:25:49.000Z" lastModifiedTime="2026-09-30T23:00:10.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-06T02:25:49.000Z" lastModifiedTime="2026-09-06T02:25:49.000Z" objectID="{10E3BBA3-3006-0EE3-22FC-05B2F92345BE}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Numbering controls]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-06T02:25:50.000Z" objectID="{10E3BBA3-3006-0EE3-22FC-05B2F92345BE}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="125.0376281738281" height="137.070068359375"/><one:OEChildren><one:OE creationTime="2026-09-06T02:25:49.000Z" lastModifiedTime="2026-09-06T02:25:49.000Z" objectID="{10E3BBA3-3006-0EE3-22FC-05B2F92345BE}{40}{B0}" alignment="left" quickStyleIndex="0"><one:List><one:Number numberSequence="0" numberFormat="##" restartNumberingAt="3" font="Calibri" text="3"/></one:List><one:T><![CDATA[Sequence 0, item 0]]></one:T></one:OE><one:OE creationTime="2026-09-06T02:25:49.000Z" lastModifiedTime="2026-09-06T02:25:49.000Z" objectID="{10E3BBA3-3006-0EE3-22FC-05B2F92345BE}{46}{B0}" alignment="left" quickStyleIndex="0"><one:List><one:Number numberSequence="0" numberFormat="##" font="Calibri" text="4"/></one:List><one:T><![CDATA[Sequence 0, item 1]]></one:T></one:OE><one:OE creationTime="2026-09-06T02:25:49.000Z" lastModifiedTime="2026-09-06T02:25:49.000Z" objectID="{10E3BBA3-3006-0EE3-22FC-05B2F92345BE}{52}{B0}" alignment="left" quickStyleIndex="0"><one:List><one:Number numberSequence="1" numberFormat="##" restartNumberingAt="3" font="Calibri" text="III"/></one:List><one:T><![CDATA[Sequence 1, item 0]]></one:T></one:OE><one:OE creationTime="2026-09-06T02:25:49.000Z" lastModifiedTime="2026-09-06T02:25:49.000Z" objectID="{10E3BBA3-3006-0EE3-22FC-05B2F92345BE}{58}{B0}" alignment="left" quickStyleIndex="0"><one:List><one:Number numberSequence="1" numberFormat="##" font="Calibri" text="IV"/></one:List><one:T><![CDATA[Sequence 1, item 1]]></one:T></one:OE><one:OE creationTime="2026-09-06T02:25:49.000Z" lastModifiedTime="2026-09-06T02:25:49.000Z" objectID="{10E3BBA3-3006-0EE3-22FC-05B2F92345BE}{64}{B0}" alignment="left" quickStyleIndex="0"><one:List><one:Number numberSequence="2" numberFormat="##" restartNumberingAt="3" font="Calibri" text="iii"/></one:List><one:T><![CDATA[Sequence 2, item 0]]></one:T></one:OE><one:OE creationTime="2026-09-06T02:25:49.000Z" lastModifiedTime="2026-09-06T02:25:49.000Z" objectID="{10E3BBA3-3006-0EE3-22FC-05B2F92345BE}{70}{B0}" alignment="left" quickStyleIndex="0"><one:List><one:Number numberSequence="2" numberFormat="##" font="Calibri" text="iv"/></one:List><one:T><![CDATA[Sequence 2, item 1]]></one:T></one:OE><one:OE creationTime="2026-09-06T02:25:49.000Z" lastModifiedTime="2026-09-06T02:25:49.000Z" objectID="{10E3BBA3-3006-0EE3-22FC-05B2F92345BE}{76}{B0}" alignment="left" quickStyleIndex="0"><one:List><one:Number numberSequence="3" numberFormat="##" restartNumberingAt="3" font="Calibri" text="C"/></one:List><one:T><![CDATA[Sequence 3, item 0]]></one:T></one:OE><one:OE creationTime="2026-09-06T02:25:49.000Z" lastModifiedTime="2026-09-06T02:25:49.000Z" objectID="{10E3BBA3-3006-0EE3-22FC-05B2F92345BE}{82}{B0}" alignment="left" quickStyleIndex="0"><one:List><one:Number numberSequence="3" numberFormat="##" font="Calibri" text="D"/></one:List><one:T><![CDATA[Sequence 3, item 1]]></one:T></one:OE><one:OE creationTime="2026-09-06T02:25:49.000Z" lastModifiedTime="2026-09-06T02:25:49.000Z" objectID="{10E3BBA3-3006-0EE3-22FC-05B2F92345BE}{88}{B0}" alignment="left" quickStyleIndex="0"><one:List><one:Number numberSequence="4" numberFormat="##" restartNumberingAt="3" font="Calibri" text="c"/></one:List><one:T><![CDATA[Sequence 4, item 0]]></one:T></one:OE><one:OE creationTime="2026-09-06T02:25:49.000Z" lastModifiedTime="2026-09-06T02:25:49.000Z" objectID="{10E3BBA3-3006-0EE3-22FC-05B2F92345BE}{94}{B0}" alignment="left" quickStyleIndex="0"><one:List><one:Number numberSequence="4" numberFormat="##" font="Calibri" text="d"/></one:List><one:T><![CDATA[Sequence 4, item 1]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/picture-link/cold/read/page-005.xml created+6
...@@ -0,0 +1,6 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{6F95DBC7-8E09-0A8C-29EE-9E4F78401929}{1}{B0}" name="Image role background=false, printout=true" dateTime="2026-09-06T02:25:46.000Z" lastModifiedTime="2026-09-30T23:00:09.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-06T02:25:46.000Z" lastModifiedTime="2026-09-06T02:25:46.000Z" objectID="{0AF56AE5-BB07-0230-0A58-C3D41529A053}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Image role background=false, printout=true]]></one:T></one:OE></one:Title><one:Image format="png" isPrintOut="true" alt="Role pattern" originalPageNumber="2" lastModifiedTime="2026-09-06T02:25:47.000Z" objectID="{0AF56AE5-BB07-0230-0A58-C3D41529A053}{39}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="288.0" height="192.0" isSetByUser="true"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAGAAAABACAIAAABqVuVZAAAAwUlEQVR4nO3ZwQ2DQBDF0Hkk/dec
3CuASwXCwtBVY/mYBM3OMznEC4TsfMx1nEALk2o8ACZB/GpJBAiSD3PcgbmICJIM0MVvX/RokQDJI
4E1ODvPOrS5EWIBmkialB1ntcpAVIBmli1rvTRVGAZJAmZj1AvawKkAzSxNQg6zEu0gIkgzQx69Hp
5oihAMkgT85LTn1UBkkGamBpkvcdFWoBkkCZmvTtdFAVIBmli1gPUy6oAySBNTA2yHuMiLUAyyPMT
6+wHXVRp4qDc0HQAAAABJRU5ErkJggg==</one:Data></one:Image><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-06T02:25:46.000Z" objectID="{0AF56AE5-BB07-0230-0A58-C3D41529A053}{41}{B0}"><one:Position x="72.0" y="108.0" z="1"/><one:Size width="146.7475433349609" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-06T02:25:46.000Z" lastModifiedTime="2026-09-06T02:25:46.000Z" objectID="{0AF56AE5-BB07-0230-0A58-C3D41529A053}{42}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Foreground role control]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/picture-link/cold/read/page-006.xml created+4
...@@ -0,0 +1,4 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{710863F5-A9D2-0E07-3FB8-007CC60AD628}{1}{B0}" name="Paragraph controls" dateTime="2026-09-06T02:25:48.000Z" lastModifiedTime="2026-09-30T23:00:10.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="1" name="Fixture style" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="14.0" strikethrough="true" spaceBefore="0.25" spaceAfter="0.125"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-06T02:25:48.000Z" lastModifiedTime="2026-09-06T02:25:48.000Z" objectID="{34DE4DA0-EE84-0D79-1B05-DB2C0F45A8A3}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Paragraph controls]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-06T02:25:49.000Z" objectID="{34DE4DA0-EE84-0D79-1B05-DB2C0F45A8A3}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="480.0" height="87.66204071044922"/><one:OEChildren><one:OE creationTime="2026-09-06T02:25:48.000Z" lastModifiedTime="2026-09-06T02:25:48.000Z" objectID="{34DE4DA0-EE84-0D79-1B05-DB2C0F45A8A3}{40}{B0}" alignment="left" spaceBefore="9.0" spaceAfter="4.5" quickStyleIndex="1"><one:T><![CDATA[<span
3style='text-decoration:line-through'>Inherited strike and spacing</span>]]></one:T></one:OE><one:OE creationTime="2026-09-06T02:25:48.000Z" lastModifiedTime="2026-09-06T02:25:48.000Z" objectID="{34DE4DA0-EE84-0D79-1B05-DB2C0F45A8A3}{47}{B0}" alignment="center" spaceBefore="12.0" spaceAfter="6.0" quickStyleIndex="0"><one:T><![CDATA[Centered paragraph]]></one:T></one:OE><one:OE RTL="true" creationTime="2026-09-06T02:25:48.000Z" lastModifiedTime="2026-09-06T02:25:48.000Z" objectID="{34DE4DA0-EE84-0D79-1B05-DB2C0F45A8A3}{52}{B0}" alignment="right" quickStyleIndex="0" style="font-family:Arial;font-size:11.0pt;text-align:right"><one:T><![CDATA[<span
4style='direction:rtl;unicode-bidi:embed' lang=ar-SA>مرحبا بالعالم</span>]]></one:T></one:OE><one:OE creationTime="2026-09-06T02:25:48.000Z" lastModifiedTime="2026-09-06T02:25:48.000Z" collapsed="1" objectID="{34DE4DA0-EE84-0D79-1B05-DB2C0F45A8A3}{58}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Collapsed parent]]></one:T><one:OEChildren indent="2"><one:OE creationTime="2026-09-06T02:25:48.000Z" lastModifiedTime="2026-09-06T02:25:48.000Z" objectID="{34DE4DA0-EE84-0D79-1B05-DB2C0F45A8A3}{63}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Hidden child remains stored]]></one:T><one:OEChildren><one:OE creationTime="2026-09-06T02:25:48.000Z" lastModifiedTime="2026-09-06T02:25:48.000Z" objectID="{34DE4DA0-EE84-0D79-1B05-DB2C0F45A8A3}{68}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Grandchild]]></one:T></one:OE></one:OEChildren></one:OE></one:OEChildren></one:OE><one:OE creationTime="2026-09-06T02:25:48.000Z" lastModifiedTime="2026-09-06T02:25:48.000Z" objectID="{34DE4DA0-EE84-0D79-1B05-DB2C0F45A8A3}{73}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[AB&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;C]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/picture-link/cold/read/page-007.xml created+6
...@@ -0,0 +1,6 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{86DA38AE-66AD-012D-0140-0BD7D50D4CD6}{1}{B0}" name="Image png" dateTime="2026-09-06T02:25:40.000Z" lastModifiedTime="2026-09-30T23:00:09.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-06T02:25:42.000Z" lastModifiedTime="2026-09-06T02:25:42.000Z" objectID="{F9849DAF-9E1D-05CB-221D-D71EBD73E5DB}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Image png]]></one:T></one:OE></one:Title><one:Image format="png" hyperlink="https://example.invalid/image/png" alt="Pattern png" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{F9849DAF-9E1D-05CB-221D-D71EBD73E5DB}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="144.0" height="48.00000381469727" isSetByUser="true"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAGAAAABACAIAAABqVuVZAAAAwUlEQVR4nO3ZwQ2DQBDF0Hkk/dec
3CuASwXCwtBVY/mYBM3OMznEC4TsfMx1nEALk2o8ACZB/GpJBAiSD3PcgbmICJIM0MVvX/RokQDJI
4E1ODvPOrS5EWIBmkialB1ntcpAVIBmli1rvTRVGAZJAmZj1AvawKkAzSxNQg6zEu0gIkgzQx69Hp
5oihAMkgT85LTn1UBkkGamBpkvcdFWoBkkCZmvTtdFAVIBmli1gPUy6oAySBNTA2yHuMiLUAyyPMT
6+wHXVRp4qDc0HQAAAABJRU5ErkJggg==</one:Data></one:Image></one:Page>
corpus/picture-link/cold/read/page-008.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{95B9BD1C-79B9-0611-2445-33C3814DE07A}{1}{B0}" name="Files and recording" dateTime="2026-09-06T02:25:46.000Z" lastModifiedTime="2026-09-30T23:00:09.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:MediaPlaylist><one:MediaReference mediaID="{9A418E57-7F64-40C5-87D0-8A8F7C1E5422}"/></one:MediaPlaylist><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-06T02:25:47.000Z" lastModifiedTime="2026-09-06T02:25:47.000Z" objectID="{8FA38498-A15A-08DC-3759-A172FAAA260D}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Files and recording]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-06T02:25:47.000Z" objectID="{8FA38498-A15A-08DC-3759-A172FAAA260D}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="136.6015625" height="139.427719116211"/><one:OEChildren><one:OE creationTime="2026-09-06T02:25:47.000Z" lastModifiedTime="2026-09-06T02:25:47.000Z" objectID="{8FA38498-A15A-08DC-3759-A172FAAA260D}{40}{B0}" alignment="left"><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\7d1ab415-649a-43bd-a863-bbef841467de.txt" pathSource="C:\one-tests\runs\capture\notebook\fixture-data\attachment.txt" preferredName="Fictitious attachment.txt"/></one:OE><one:OE creationTime="2026-09-06T02:25:47.000Z" lastModifiedTime="2026-09-06T02:25:47.000Z" objectID="{8FA38498-A15A-08DC-3759-A172FAAA260D}{44}{B0}" alignment="left"><one:MediaFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\5cdfe8bc-15a0-4ef8-9710-d05c8cbe5118.wav" pathSource="C:\one-tests\runs\capture\notebook\fixture-data\silence.wav" preferredName="silence.wav"><one:MediaReference mediaID="{9A418E57-7F64-40C5-87D0-8A8F7C1E5422}"/></one:MediaFile></one:OE><one:OE creationTime="2026-09-06T02:25:47.000Z" lastModifiedTime="2026-09-06T02:25:47.000Z" objectID="{8FA38498-A15A-08DC-3759-A172FAAA260D}{48}{B0}" alignment="left" quickStyleIndex="0"><one:MediaIndex timeIndex="500"><one:MediaReference mediaID="{9A418E57-7F64-40C5-87D0-8A8F7C1E5422}"/></one:MediaIndex><one:T><![CDATA[Recording annotation]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/picture-link/cold/read/page-009.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{ADE0856F-5F03-05E7-03D6-EE9AF29EDD7B}{1}{B0}" name="Task controls" dateTime="2026-09-06T02:25:50.000Z" lastModifiedTime="2026-09-30T23:00:10.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-06T02:25:50.000Z" lastModifiedTime="2026-09-06T02:25:50.000Z" objectID="{FF1642F1-31DA-0DD3-27A8-B129400E7A6F}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Task controls]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-06T02:25:51.000Z" objectID="{FF1642F1-31DA-0DD3-27A8-B129400E7A6F}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="151.0606994628906" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-06T02:25:50.000Z" lastModifiedTime="2026-09-06T02:25:50.000Z" objectID="{FF1642F1-31DA-0DD3-27A8-B129400E7A6F}{40}{B0}" alignment="left" quickStyleIndex="0"><one:OutlookTask startDate="2020-01-02T00:00:00.000Z" dueDate="2020-01-03T00:00:00.000Z" guidTask="{45AF40E3-1D54-463C-90D3-C454F2F71A2E}" completed="false" disabled="true" creationDate="2020-01-02T03:04:05.000Z"/><one:T><![CDATA[Disabled standalone task]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/picture-link/cold/read/page-010.xml created+300
...@@ -0,0 +1,300 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{E25EA0DE-E27F-0A28-1A8D-D17E0ECC7E0F}{1}{B0}" name="Image tiff" dateTime="2026-09-06T02:25:43.000Z" lastModifiedTime="2026-09-30T23:00:09.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-06T02:25:44.000Z" lastModifiedTime="2026-09-06T02:25:44.000Z" objectID="{A1DE56A1-CD3B-0637-20D3-DD4DD713D8CA}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Image tiff]]></one:T></one:OE></one:Title><one:Image format="png" hyperlink="https://example.invalid/image/tiff" alt="Pattern tiff" originalPageNumber="0" lastModifiedTime="2026-09-06T02:25:45.000Z" objectID="{A1DE56A1-CD3B-0637-20D3-DD4DD713D8CA}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="144.0" height="48.00000381469727" isSetByUser="true"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAGAAAABACAIAAABqVuVZAAAAAXNSR0IArs4c6QAAAARnQU1BAACx
3jwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAEImSURBVHheDbiBV2GL+v+/f9V1q+uk46TjpDFp
4TBqDMUhoh3Zoh3Zoh3Zoh3YYgzE0Jo1J46TjpOOk61Yf32pZ1bKqZVXLqpZV/W2/8y8863nez/v1
5AgAAaAL+vxagiQQ0twIt7cC/yACpA/h3J9BKBdq6gPZu4D90gNwD/NQLdDABSh/Q2Q/8zAaoA8Av
6g0AXF6DxgG4B8KsQoIuA3yRAjxRgyIBeBfACBJhK4KUa6IMAlgbo1wGvYICtB14bgQEE4JiAQQvw
7BgW4VuCtHeBhAN8BCFzAOxwQuoH3XkBEAGIfIAkAQ0FAGgKGI4AsCshjgGIFGEkA4CowugYoU4Bq
8HVBvAGMZANoExrcATQ7QbgO6HWCiAMC7wOQeoC8Bhn3AeAhMlQHkCJg+AUwVwHwKWM6BmSqAXgCz
9V4C1BtiuAfstMFcHsDtg/gFwNADnI+B6BhYAoPn/a2puamluJjW3tDb/q72ZRG7+d0dza2dzG7W5
10vav5P93NZHrzTz3NHb3NFGZzZ1/zz/3NVHbzLwPNXYPNNG5zN6/5V0EzXdj8m6i5R9LMkDb3yppf
11KJqZYPNLZXOfupkFNfdrml/pmtlw82t984CxmYM0D5qa31iauWjzW2szz97Mx5oFjuZ3rmYh3vze
123SzyNouJZomveSjQLA02D4eaZZFmebRZEWseWWkGE82jq83KtWZVqlm93jy20Qxlmsc3mzVbzdpc
13s267eWKnGS40T+426/eaDaVm437z1GEzUm6ePmo2nTSbK82W0+aZ82a02jx70Wy9arbVmu3XzXO3
14zVi9ef6u2fHQ7Gw0ux6bF56bcQAgNTX9MxtSC4n0r1YSqZ30bzKptYPU1klqp5L+00Uid5N+opM6
15ekiUXlInk/RzH4naT/qFTeoaINEGSd1c0q88El1A+k1I6hGRGBJSr5T0QkZiKkgvQVKfksRSk/oh
160isNia0jvYZJA3oSx0gaREhvTCSuhfQWJfGsJL6dJMBI7xwkoYv0HieJ3CSxlyQhSEM+kjRAGg6S
17ZCGSPEJSREkjMRK4QhpNkJSrJNUaSZ0ija2ToA3SeIak2SRpt0i6HGlimwTvkCYLJP0uybBHMpZI
18U/sk5JA0XSaZjkjmE5KlQpo5JaHnpNkqyXpBsl2R7DXS3DUJuyXN10mOO5LzgeRqkBYeSfgzaREA
192pqb2lpa2v5FaiO1tv27va2V3NbW0dbe2fYfahu5q+2n7rYOehulp62zt+1nZhu1r+2X/rYudhtt
20oK17sO1Xbhud1/aboK1H2MYQtfVK2l5I25iytpeKtj6wjaVs61e3vYLa2Jq217q2AbiNo28bNLa9
21Qdq4pra3ljYe2sa3tgnsbe+wNqGj7b2rTYS3id1tEm/bENEm9bUNB9pkwTZ5qE0RaRuJtoGxttGV
22NmWiTbXapl5rG0u1Qett4xttmkybdrNNt9U2kWuDt9smd9r0hTbDbptxr22q1Ibst00ftpnKbeaj
23NstJ20ylDT1tmz1vs1bbbBdt9qu2uVobdt02f9vmqLc579pcD20LjTb8sW3xuc0NAOSWJvK/Wsgk
24EvnfreTWdnIbmdzeQf5PJ5lMJf/URe7oJlPo5M4e8s+9ZCqT/EsfuaufTGOTuwfIvw6S6Vzybzxy
25j4DMEJJ7ReQXEjJTSn4pI/cpyCyQ3K8kv1KT2RD5tYY8oCNzYPKgnvzGSOYi5LcmMs9C5qNkgZX8
26zk4WYuT3DrLIRRbjZImbPOQlSwnysI8sC5DlQbIiRB6JkMEoeTRGVq6QVQmyepU8tkaGUuTxdbJm
27g6zNkHWb5IktMpwjT26T9TtkQ4Fs3CVP7ZGREnl6n2w6JJvLZMsReeaEjFbIs6dk6znZViXbL8hz
28V2SsRp6/Jjtuyc462XVHXngg4w3y4iPZ/Uz2AADlX00UUgvl3yRKayulrZ3STqb8p4NC7qT8RKV0
29dFEo3ZROOuXnHgq1l/ILk9LVR6H1U7rZlF8HKPRBym9cSg+PwhBQeoWUFyIKU0J5KaX0ySgsBaUf
30pLxSUthqymuIMqChcHSUQZjyRk/hGilvEQrPROFbKAKU8s5KEdop7zGKyEERuygSnDLkpki9lGGC
31IvNR5AGKIkgZCVHACGU0SlHGKKoVijpBGVulQGuU8RRFs07RblB0GcrEJgXeokzmKPptimGHYixQ
32pnYpyB5lukQx7VPMhxRLmTJzREFPKLMVivWUYjun2KuUuQsKdkWZr1Ec1xTnLcVVpyzcUfAHymKD
334n6keJ4pXgCgkpqo/26htpKoba3U9nbqf/7ZnA7qT53UDiqV0kXt7Kb+TKdSe6i/9FK7mFRaH7W7
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
242B583DmKZg/jmwcrWwZfcQWL74OvOwWrhILl7sLZ38K10kNo/+H54sF4+SB8dbJwc/KgcZE4Pfj8/
2432KweZC8Otq4O/qgd5K4P/rw92K4f5O8Odh4O/mocFB4P/n4+2AWAsqSpPNRSlpLKw61lWXtZTi4r
244OsojnWWQWh7tKiu7yyp6Wd1THustQ8zyeF9Z01/Wssu6gfLEYBnmlid5Zb2gbBCWjaLylKSMSMvT
245srJJUTaDZYuyPKMuo1B5VlO26so2uGzXl+eMZQwpz5vKDkvZiZZd1vKCvYxj5UVH2e0qe/Cy111e
2468pYJorzsK/sCZX+wHAiVP0T+0Tnlj7FyaKUcTpQjq+VPa+Voqvx5vRzbKMcz5ZXN8petciJX/rpd
247Xt0pJwvltd3yt71yqlT+vl9ePyyny+WNo/KPk3KmUv79tLx5Xs5Wy1sX5T+uyrla+c/r8vZtOV8v
24879yV/3ooFxrlvx/Lu8/lIgAcDzUdS1uOh0nHstZjefuxgnw80nEMdh6PUo+VXceq7mM1/Xis5xjq
249PR5nHmv6jrX9xzr28cTAMTx4PMk91vOODYJjo/B4SnSMSI6npccm2bFZcWwBj2eUx6j6eBY6tmqO
250bbpjO3w8pz/GjMfzyLHDdOy0HLvQ4wXrMW4/XsSO3Y5jj+vYix8vuY8J7/EycezzHfsDx4Hg8YfQ
251cTBy/DF6HIodh1eOI4njT6vH0bXjz6nj2PpxfON4JXP8ZfM4sXX8NXe8un2c3DleKxx/2z1O7R1/
252Lx2v7x+nD483ysc/jo4zJ8e/V443T4+z58db1eM/Lo5zV8d/1o63r4/zt8c79eO/7o4LD8d/N453
253H4+Lz8d7AFCRNlWGWyoyUkXeWlG0V0bIFbCjMtpZUVIrqq6KursyRq9APZXx3oqGWdH2VXT9lQl2
254BR6oTA5W9NyKgVcxCipTwgoiqkxLKiZpxSyrWBSVGbCCKiuz6ooVqtg0FbuuMgdXMH1l3lhxIBWn
255qeKyVBbQCm6tLNorbqzicVS8rsoSXiHclWVvxUdU/L5KIFD5EKwEQ5WPkUooWgnHKpGVyqdEJbpa
256+bxWiaUq8fXKykblS6aS2Kx83aqs5irJ7craTuVboZLarXzfq6yXKun9ysZh5Ue5kjmq/H5S2axU
257sqeVrfPKH9VK7qLy51Vlu1bJX1d2bit/1SuFu8rfD5XdRqX4WNl7rvwXAM6Gm85kLWdy0pmi9Wyk
258/Qwkn412nCk7z1TUM3XX2Vj3GUQ/G+850/SeaZlnur6zif4zmH02OXCmHzwzcM+MvLMpwRkiPJsW
259nZkkZ2bpmUV2NqM4Q8GzWeWZVX1mg87smrM53RkGn83rzxzGMydy5jKdLVjOcPRs0Xrmtp95sDOv
26042zJdUbgZ8vuM5/3zE+cBXxnHwJnweDZx9BZKHIWjp5FYmefVs6iibPPq2extbN46mxl/ezLxlki
261c/Z182x16yyZO1vbPvu2c5YqnH3fPVvfO0uXzjb2z34cnmXKZ78fnW2enGUrZ1unZ3+cn+WqZ39e
262nG1fneVrZzvXZ3/dnhXqZ3/fne0+nBUbZ3uPZ/99PisBQFXWVJW3VBWk6khrFWyvjpKryo6qqrOq
263plbHuqpQd3WcXtX0VLW9VR2zOtFXhfurk+yqfqBqGKwaudUpXhURVKeFVZOoapZULdLqjKyKKqqz
264YNWqrNrUVTtUndNUMV11Hq469FWnsepCqgumKm6pLqJVt7XqsVe9WHXJUSVc1WW86nNX/d5qgKh+
2658FWDgerHYDUUqoYj1Ui0+ilWja5UPyeqsX9k11p1JVX9sl5NbFS/Zqqrm9XkVnUtV/22XU3tVL8X
266quu71fRedaNU/bFfzRxWfy9XN4+q2ZPqVqX6x2k1d179s1rdvqjmr6o7tepf19XCbfXvenX3rlp8
267qO41qv99rJaeq/8DgEt506Wi5XKEdAm2Xo62XyrJl6qOS3Xn5Rj1Euq6HO++1NAvtT2Xut7LCeYl
2683Hc52X+pZ18aBi6Ng5dT3EuEdzktuDQJL82iS4vkckZ6icouZxWXVvDSpry0qy/noEtMczmvu3TA
269l079pct4uYBc4qbLRculG730WC+99ssl7JJwXC67Ln34pd99GfBefiAug77Lj4HLUPAyHLqMRC4/
270RS+jscvPK5exxGV89XJl7fJL6jKxfvl143I1c5ncvFzbuvyWu0xtX37fuVwvXKZ3Lzf2Ln+ULjP7
271l78fXm6WL7NHl1snl39ULnOnl3+eX25XL/MXlztXl3/VLgvXl3/fXu7WL4t3l3sPl/9tXJYeL//3
272fLkPADVFU22kpQaSaqOtNWV7TUWuqTtqY501iFob76ppumtaek3XU5vorcHM2mRfTd9fM7BrxoHa
2731GAN4dameTWToGYW1iyi2oykhkprs7KaVVGzgTW7sjanrmFQbV5Tc+hqTrjm0tcWjDUcqS2aam5L
274zYPWvNbakr1GYLVlR83nqvnxWsBd++CtBYnaR18tFKiFg7VIqPYpUotGa59jtdhKLZ6orazWvqzV
275/lGBX9drqxu1ZKa2tln7tlVL5Wrft2vrO7V0obaxW/uxV8uUar/v1zYPa9lybeuo9sdJLVep/Xla
2762z6v5au1nYvaX1e1Qq3293Vt97ZWrNf27mr/faiVGrX/Pdb2n2sHAHAz0nQDttyMkm6UrTeq9hs1
277+Was4wbqvBmn3mi6brTdNzr6zUTPDdx7M8m80ffdGPpvjOybqYEbZPBmmntj4t2YBTcW4c2M6AaV
2783MxKb6yyG5vixg7ezClvMPXNPHTj0Nw4dTcu+GZBf4MbbxaRG7fpxmO58aI3S9Ybwn6zjN34HDd+
279100Av/ngvgl6bz4SNyHfTThwEwnefArdRCM3n6M3sdhNfOVmJXHzZfUmsXbzNXWzun6T3LhZy9x8
28027xJbd18z92sb9+kd242Cjc/dm8yeze/l24292+yhzdb5Zs/jm5yJzd/Vm62T2/y5zc71Zu/Lm4K
281Vzd/1252r2+Ktzd79Zv/3t2UHm7+17jZf7w5eL45BIA62FQfbakrSXVVa13dXh8j16GO+nhnXUOt
282a7vquu76BL0O99Qne+t6Zt3QVzf216fYdWSgPj1YN3HrZl7dIqjPCOuoqD4rqVuldZusblfU58A6
283pqzPq+sOqO7U1F26+gJcx/X1RWPdjdQ9prrXUl9C64S1vmyv+7C631EPuOof8HrQXf/orYeIethX
284jwTqn4L1aKj+OVKPRevxWH1lpf4lUU+s1r+u1VdT9eR6fW2j/i1TT23Wv2/V13P19HZ9Y6f+o1DP
2857NZ/36tvlurZ/frWYf2Pcj13VP/zpL5dqedP6zvn9b+q9cJF/e+r+m6tXryu793W/1uvl+7q/3uo
2867zfqB4/1w+f6/wHA/WjTvbLlXkW6V7fej7XfQ+T78Y57Tee9lnqv67qf6L6H6feTPff63nsD897Y
287dz/Vf4+w76cH7k2D92buvYV3PyO4R4X3s6J7q+TeJr23y+7nFPcYeD+vvHeo753QvUtzv6C7x+H7
288Rf2923jvQe69pvslyz2B3i9b7332ez92H3Dcf3DdB/H7j+77kPc+TNxHfPefAvfR4P3n0H0sch+P
2893q/E7r+s3CcS919X71fX7pOp+7X1+28b96nM/ffN+/Wt+3TufmP7/sfOfaZw//vu/ebefbZ0v7V/
290/8fhfa58/+fR/fbJfb5yv3N6/9f5faF6//fF/e7VfbF2v3d9/9/b+1L9/n939/sP9weN+8PH+/97
291vi8DQEPZ1FC1NNSkxlhrA2pvjJMbmo6GtrOhozYmuhpwd2OS3tD3NAy9DSOzMdXXQPob0+yGaaBh
292HmxYuI0ZXgMVNGaFDauoYZM07NLGnKyBKRrzYMOhbDjVDRfUWNA0cF1jEW649Q2PseFFGkumBmFp
293LKMNn7XhtzcCWOODoxF0NT7ijZC7EfY2IkTjk68RDTQ+BxuxUCMeaaxEG19ijcRK42uisbraSK41
2941lKNb+uN1Ebje6axvvmPJW1s5Bo/thuZncbvhcbmbiO719gqNf7Yb+QOG3+WG9tHjfxJY6fS+Ou0
295UThv/F1t7F40ileNvVrjv9eN0m3jf/XG/l3j4KFx2Gj832Oj/Nz4fwDwpGp6Urc8jZGeoNan8fYn
296DflJ2/Gk63yaoD7BXU+T3U96+pOh58nY+zTFfEL6nqb7n0zsJ/PAk2XwaYb7hPKeZgVPVuGTTfRk
297lzzNSZ8w2dO84skBPjmVTy710wL0hGueFnVPbvjJo3/yGp+WkCfC9LRsefKhT37rU8D+9AF7Cjqe
298PrqeQvhT2P0U8T59Ip6ivqfPgadY8CkeelqJPH2JPiViT19XnlYTT8nVp7W1p2+pp9T60/eNp/XM
299U3rzaWPr6UfuKbP99PvO02bhKbv7tLX39EfpKbf/9Ofh03b5KX/0tHPy9FflqXD69Pf50271qXjx
300tHf19N/aU+n66X+3T/v1p4O7p8OHp/9rPJUfn/7f89PR/w8QCy14LJdV2gAAAABJRU5ErkJggg==</one:Data></one:Image></one:Page>
corpus/picture-link/cold/read/page-011.xml created+21
...@@ -0,0 +1,21 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{E4150670-59A6-0ECB-0BDD-D595FA83480D}{1}{B0}" name="Image jpg" dateTime="2026-09-06T02:25:42.000Z" lastModifiedTime="2026-09-30T23:00:09.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-06T02:25:42.000Z" lastModifiedTime="2026-09-06T02:25:42.000Z" objectID="{3A28A05F-AE7B-062E-35AE-475132CD13FC}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Image jpg]]></one:T></one:OE></one:Title><one:Image format="jpg" hyperlink="https://example.invalid/image/jpg" alt="Pattern jpg" originalPageNumber="0" lastModifiedTime="2026-09-06T02:25:50.000Z" objectID="{3A28A05F-AE7B-062E-35AE-475132CD13FC}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="144.0" height="48.00000381469727" isSetByUser="true"/><one:Data>/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a
3HBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIy
4MjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCABAAGADASIA
5AhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQA
6AAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3
7ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWm
8p6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEA
9AwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSEx
10BhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElK
11U1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3
12uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDwhYfa
13plh9qsrD7VMsPtUqRFOqVVh9qmWH2qysPtUyw+1UpHbTqlZYfaplh9qsrD7VMsPtVqR206pVWGpl
14h9qsrDUyw+1UpHbTqlZYalWH2q0sNSrD7VSkdtOqVlh9qmWH2qysPtUyw+1UpHdTqnHrD7VMsPtV
15lYfaplh9q81SPyqnVKqw+1TLD7VZWH2qZYfaqUjtp1SssPtUyw+1WVh9qmWH2qlI7adUqrDUyw+1
16WVhqZYfarUjtp1SssNSrD7VaWGpVh9qpSO2nVKyw+1TLD7VZWH2qZYfaqUjup1Tj1h9qmWH2qysP
17tUyw+1eapH5VTqlVYfaplh9qsrD7VMsPtVKR206pWWH2qZYfarKw+1TLD7VSkdtOqVVhqZYfarKw
181MsPtVqR206pWWGpVh9qtLDUqw+1UpHbTqlZYfaplh9qsrD7VMsPtVKR206px6w+1TLD7VZWH2qZ
19YfavNUj8rp1SqsPtUyw+1WVh9qmWH2qlI7adUrLD7VMsPtVlYfaplh9qpSO2nVKqw1MsPtVlYamW
20H2q1I7adUrLDUqw+1WlhqVYfaqUjtp1SssPtUyw+1WVh9qmWH2qlI7adU//Z
21</one:Data></one:Image></one:Page>
corpus/picture-link/cold/read/page-012.xml created+6
...@@ -0,0 +1,6 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{F86A5F48-E893-06F7-2CDE-4540E56F4C1D}{1}{B0}" name="Image role background=true, printout=false" dateTime="2026-09-06T02:25:45.000Z" lastModifiedTime="2026-09-30T23:00:09.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-06T02:25:46.000Z" lastModifiedTime="2026-09-06T02:25:46.000Z" objectID="{3ED7DAC4-B868-0BA3-1E44-97B162C75586}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Image role background=true, printout=false]]></one:T></one:OE></one:Title><one:Image format="png" backgroundImage="true" alt="Role pattern" originalPageNumber="2" lastModifiedTime="2026-09-06T02:25:49.000Z" objectID="{3ED7DAC4-B868-0BA3-1E44-97B162C75586}{39}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="288.0" height="192.0" isSetByUser="true"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAGAAAABACAIAAABqVuVZAAAAwUlEQVR4nO3ZwQ2DQBDF0Hkk/dec
3CuASwXCwtBVY/mYBM3OMznEC4TsfMx1nEALk2o8ACZB/GpJBAiSD3PcgbmICJIM0MVvX/RokQDJI
4E1ODvPOrS5EWIBmkialB1ntcpAVIBmli1rvTRVGAZJAmZj1AvawKkAzSxNQg6zEu0gIkgzQx69Hp
5oihAMkgT85LTn1UBkkGamBpkvcdFWoBkkCZmvTtdFAVIBmli1gPUy6oAySBNTA2yHuMiLUAyyPMT
6+wHXVRp4qDc0HQAAAABJRU5ErkJggg==</one:Data></one:Image><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-06T02:25:46.000Z" objectID="{3ED7DAC4-B868-0BA3-1E44-97B162C75586}{41}{B0}"><one:Position x="72.0" y="108.0" z="1"/><one:Size width="146.7475433349609" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-06T02:25:46.000Z" lastModifiedTime="2026-09-06T02:25:46.000Z" objectID="{3ED7DAC4-B868-0BA3-1E44-97B162C75586}{42}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Foreground role control]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/picture-link/cold/read/payloads.json created+18
...@@ -0,0 +1,18 @@
1[
2 {
3 "sha256": "4cff0039dbdf53a59f5f4e4592c29a5297ea3b7acce24415c0ae83c36c6bd7c2",
4 "name": "Fictitious attachment.txt",
5 "object": "{8FA38498-A15A-08DC-3759-A172FAAA260D}{40}{B0}",
6 "kind": "InsertedFile",
7 "page": "{95B9BD1C-79B9-0611-2445-33C3814DE07A}{1}{B0}",
8 "bytes": 27
9 },
10 {
11 "sha256": "56d4af65701c26df20bd4021eda95b6e830348ce3a746086079fe89285548dc9",
12 "name": "silence.wav",
13 "object": "{8FA38498-A15A-08DC-3759-A172FAAA260D}{44}{B0}",
14 "kind": "MediaFile",
15 "page": "{95B9BD1C-79B9-0611-2445-33C3814DE07A}{1}{B0}",
16 "bytes": 16044
17 }
18]
\ No newline at end of file
corpus/picture-link/cold/run.json created+18
...@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/linklab/candidate",
3 "expected_pages": -1,
4 "author": null,
5 "author_timeout_seconds": 600,
6 "inspect": false,
7 "collect_notebook": true,
8 "base": {
9 "file": "win7-office-base.qcow2",
10 "format": "qcow2",
11 "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
12 "virtual_size": 68719476736
13 },
14 "scripts": {
15 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
16 "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
17 }
18}
corpus/picture-link/cold/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/picture-link/cold/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/picture-link/cold/source.json created+14
...@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Features.one",
4 "bytes": 123080,
5 "sha256": "d1cc5e4deb98245d742488199c676ad1a44edc808a219d9bd9d1856d5b2ccac0",
6 "mtime_ns": 1790809064276439170
7 },
8 {
9 "path": "Open Notebook.onetoc2",
10 "bytes": 1604,
11 "sha256": "be341d46f14d6242c0e2933e88941f157d2071df895730d01be03156592a808c",
12 "mtime_ns": 1790809064276690168
13 }
14]
corpus/picture-link/cold/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
corpus/picture-link/lab/click-selects.png created
Binary files /dev/null and b/corpus/picture-link/lab/click-selects.png differ
corpus/picture-link/lab/ctrl-click.png created
Binary files /dev/null and b/corpus/picture-link/lab/ctrl-click.png differ
corpus/picture-link/lab/hover.png created
Binary files /dev/null and b/corpus/picture-link/lab/hover.png differ
corpus/printout/README.md+7-3
...@@ -11,9 +11,13 @@ frames printout pages....@@ -11,9 +11,13 @@ frames printout pages.
11`candidate/` is the output of `printouts_show_onenotes_rendering_and_move_as_pictures`11`candidate/` is the output of `printouts_show_onenotes_rendering_and_move_as_pictures`
12in `crates/canvas/tests/printouts.rs`: one printout page moved down, the other deleted12in `crates/canvas/tests/printouts.rs`: one printout page moved down, the other deleted
13and the deletion undone, and text typed before the citation, published as one revision.13and the deletion undone, and text typed before the citation, published as one revision.
14Snowbound does not write XPS packages, so the undone page returns as the PNG OneNote14The undone page is written back as it was read: its XPS package as the PictureContainer,
15showed of it. `cold/` is a fresh OneNote 2010 read: the moved page is still a printout,15under the file-data type OneNote gives XPS packages (0x8003A), the PNG beside it as the
16the restored one a picture. `tools/test_printout.py` checks this without a VM.16WebPictureContainer14, IsPrintout, its page number, and the undocumented properties tying
17it to its package, kept as stored; without that type or those properties OneNote reads
18the page back but draws nothing there. `cold/` is a fresh OneNote 2010 read (with its PDF
19export): both pages are printouts and both draw, the moved one lower down; the restored
20page's package is a copy, which OneNote counts as a second (`xpsFileIndex="1"`). `tools/test_printout.py` checks this without a VM.
17Regenerate with `SNOWBOUND_PRINTOUT_EXPORT` set to a new absolute directory while21Regenerate with `SNOWBOUND_PRINTOUT_EXPORT` set to a new absolute directory while
18running the test, then cold-open it with22running the test, then cold-open it with
19`tools/native_runner.py OUTPUT COLD --expected-pages 2 --collect-notebook --screenshots`.23`tools/native_runner.py OUTPUT COLD --expected-pages 2 --collect-notebook --screenshots`.
corpus/printout/candidate/Open Notebook.onetoc2
Binary files a/corpus/printout/candidate/Open Notebook.onetoc2 and b/corpus/printout/candidate/Open Notebook.onetoc2 differ
corpus/printout/candidate/Printouts.one
Binary files a/corpus/printout/candidate/Printouts.one and b/corpus/printout/candidate/Printouts.one differ
corpus/printout/cold/commands.jsonl+1-1
...@@ -1,3 +1,3 @@...@@ -1,3 +1,3 @@
1{"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}1{"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}
2{"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}2{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-02DA9A86 -ExpectedPages 2 -Pdf -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 2 pages.\r\n", "stderr": "", "error": null}
3{"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}3{"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}
corpus/printout/cold/machine.json+1-1
...@@ -1 +1 @@...@@ -1 +1 @@
1{"name": "m6-b402fe00", "hostname": "ONE-M6-B402FE00"}1{"name": "m6-02da9a86", "hostname": "ONE-M6-02DA9A86"}
corpus/printout/cold/notebook/Open Notebook.onetoc2
Binary files a/corpus/printout/cold/notebook/Open Notebook.onetoc2 and b/corpus/printout/cold/notebook/Open Notebook.onetoc2 differ
corpus/printout/cold/notebook/Printouts.one
Binary files a/corpus/printout/cold/notebook/Printouts.one and b/corpus/printout/cold/notebook/Printouts.one differ
corpus/printout/cold/read/environment.json+1-1
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1{ 1{
2 "powershell": "5.1.14409.1005", 2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010", 3 "schema": "xs2010",
4 "hostname": "ONE-M6-B402FE00", 4 "hostname": "ONE-M6-02DA9A86",
5 "cold": true, 5 "cold": true,
6 "onenote": "14.0.4763.1000" 6 "onenote": "14.0.4763.1000"
7} 7}
corpus/printout/cold/read/hierarchy.xml+1-1
...@@ -1,2 +1,2 @@...@@ -1,2 +1,2 @@
1<?xml version="1.0"?> 1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{07CD8409-983C-4130-ACB9-C4153BF32885}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T05:02:36.000Z" color="#B49EDE"><one:Section name="Printouts" ID="{054C6BCE-5F57-09F2-0D51-A0B21E9C6778}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Printouts.one" lastModifiedTime="2026-09-30T05:02:36.000Z" color="#F6B078"><one:Page ID="{DC91B969-8F8E-0E38-22C6-17772EFD8803}{1}{B0}" name="Printout" dateTime="2026-09-30T03:34:55.000Z" lastModifiedTime="2026-09-30T03:36:49.000Z" pageLevel="1"/><one:Page ID="{0DFA3DDD-69CE-0060-0F6D-47BCB686B585}{1}{B0}" name="Pasted shape" dateTime="2026-09-30T03:34:55.000Z" lastModifiedTime="2026-09-30T03:35:09.000Z" pageLevel="1"/></one:Section></one:Notebook> 2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{7FA4C1E2-744A-4FC7-B663-86B24107964E}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-10-01T02:16:13.000Z" color="#B49EDE" isCurrentlyViewed="true"><one:Section name="Printouts" ID="{85BBB604-70CF-0E0B-2809-9F72823F622C}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Printouts.one" lastModifiedTime="2026-10-01T02:16:13.000Z" color="#F6B078" isCurrentlyViewed="true"><one:Page ID="{5C6664A3-A016-09C1-079E-28B7B25E8D57}{1}{B0}" name="Printout" dateTime="2026-09-30T03:34:55.000Z" lastModifiedTime="2026-09-30T03:36:49.000Z" pageLevel="1"/><one:Page ID="{8D0DE017-4656-0799-2A35-787C2A25B0D1}{1}{B0}" name="Pasted shape" dateTime="2026-09-30T03:34:55.000Z" lastModifiedTime="2026-09-30T03:35:09.000Z" pageLevel="1" isCurrentlyViewed="true"/></one:Section></one:Notebook>
corpus/printout/cold/read/page-000.navigation.json+1-1
...@@ -6,7 +6,7 @@...@@ -6,7 +6,7 @@
6 "h": 600,6 "h": 600,
7 "error": null,7 "error": null,
8 "win": {8 "win": {
9 "title": "Pasted shape - Microsoft OneNote",9 "title": "Printout - Microsoft OneNote",
10 "class": "Framework::CFrame",10 "class": "Framework::CFrame",
11 "dialog": false11 "dialog": false
12 }12 }
corpus/printout/cold/read/page-000.pdf created
Binary files /dev/null and b/corpus/printout/cold/read/page-000.pdf differ
corpus/printout/cold/read/page-000.png
Binary files a/corpus/printout/cold/read/page-000.png and b/corpus/printout/cold/read/page-000.png differ
corpus/printout/cold/read/page-000.xml+525-73
...@@ -1,74 +1,526 @@...@@ -1,74 +1,526 @@
1<?xml version="1.0"?> 1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{0DFA3DDD-69CE-0060-0F6D-47BCB686B585}{1}{B0}" name="Pasted shape" dateTime="2026-09-30T03:34:55.000Z" lastModifiedTime="2026-09-30T03:35:09.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T03:34:56.000Z" lastModifiedTime="2026-09-30T03:35:06.000Z" objectID="{15FA1599-679E-03F5-0B27-750F6FE79355}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Pasted shape]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T03:35:10.000Z" objectID="{4D9E7F24-CAE3-0AFE-1A0F-AFE4A29EC7B9}{10}{B0}"><one:Position x="36.0" y="86.4000015258789" z="0"/><one:Size width="122.2499847412109" height="95.92770385742187"/><one:OEChildren><one:OE creationTime="2026-09-30T03:35:08.000Z" lastModifiedTime="2026-09-30T03:35:10.000Z" objectID="{4D9E7F24-CAE3-0AFE-1A0F-AFE4A29EC7B9}{18}{B0}" alignment="left"><one:Image format="png" originalPageNumber="0"><one:Size width="122.25" height="82.49999237060547"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAKMAAABuCAIAAADEeDS9AAAAAXNSR0IArs4c6QAAD+tJREFUeF7t 2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{5C6664A3-A016-09C1-079E-28B7B25E8D57}{1}{B0}" name="Printout" dateTime="2026-09-30T03:34:55.000Z" lastModifiedTime="2026-09-30T03:36:49.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="1" name="cite" fontColor="#595959" highlightColor="automatic" font="Calibri" fontSize="9.0" spaceBefore="0.0" spaceAfter="0.0"/><one:XPSFile xpsFileIndex="0" idDocument="{4061E200-F6FA-4AED-9E31-3DE29839A9AF}"><one:CallbackID callbackID="{5C6664A3-A016-09C1-079E-28B7B25E8D57}{1}{D108}"/></one:XPSFile><one:XPSFile xpsFileIndex="1" idDocument="{4061E200-F6FA-4AED-9E31-3DE29839A9AF}"><one:CallbackID callbackID="{5C6664A3-A016-09C1-079E-28B7B25E8D57}{1}{D117}"/></one:XPSFile><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T03:34:55.000Z" lastModifiedTime="2026-09-30T03:35:20.000Z" objectID="{6497E6DD-AEB7-0A57-2859-F70A49FCB175}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Printout]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T03:35:28.000Z" objectID="{5B8B92BB-9FEB-06D8-085F-BB1AEF8C3FD6}{14}{B0}"><one:Position x="36.0" y="99.16597747802734" z="2"/><one:Size width="303.0612182617187" height="73.98631286621094"/><one:OEChildren><one:OE creationTime="2026-09-30T03:35:28.000Z" lastModifiedTime="2026-09-30T03:35:28.000Z" objectID="{5B8B92BB-9FEB-06D8-085F-BB1AEF8C3FD6}{23}{B0}" alignment="left"><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\f2504f4c-e774-48dd-8e37-fe896574adbe.docx" pathSource="C:\work\printout\inputs\two-pages.docx" preferredName="two-pages.docx"/></one:OE><one:OE creationTime="2026-09-30T03:35:28.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{5B8B92BB-9FEB-06D8-085F-BB1AEF8C3FD6}{20}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[Printed Inserted from: &lt;<a
3XX1QVNcVT1ahKgSKgLhM3TVSCBT8AlvwAwgaTIJJBpgkpPmwzaBt1T+EzqgzHeNkEtuppDPqH+pk 3href="file:///C:\work\printout\inputs\two-pages.docx">file://C:\work\printout\inputs\two-pages.docx</a>&gt;]]></one:T></one:OE></one:OEChildren></one:Outline><one:Image format="png" xpsFileIndex="0" originalPageNumber="0" isPrintOut="true" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{5B8B92BB-9FEB-06D8-085F-BB1AEF8C3FD6}{32}{B0}"><one:Position x="36.0" y="225.352279663086" z="1"/><one:Size width="478.7999572753906" height="619.2000122070312"/><one:Data>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</one:Data></one:Image></one:OE><one:OE creationTime="2026-09-30T03:35:09.000Z" lastModifiedTime="2026-09-30T03:35:09.000Z" objectID="{4D9E7F24-CAE3-0AFE-1A0F-AFE4A29EC7B9}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> 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
106P0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAg
107QIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgF
108DLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBP
109gAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECA
110AAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwA
111AQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAAB
112AgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI
1134gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAEC
114BAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIE
115DDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQI
116ECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQ
117iAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsL
118EE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAg
119QIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI
120/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECA
121AAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAW
122MMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8
123AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAAB
124AgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMD
125BAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIE
126CBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAg
127iwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQI
128ECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQ
129MMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAg
130QIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBA
131IBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywu
132QDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECA
133AAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg
1348wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAAB
135AgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFY
136wCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADx
137BAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIE
138CBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wP
139ECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQ
140IEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGD
141LC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMg
142QIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBA
143wCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CA
144AAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAA
145gVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4
146APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAAB
147AgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGD
148zA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIE
149CBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRi
150AYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLE
151EyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQ
152IEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/
153QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBA
154gACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUM
155srgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+A
156AAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAA
157AYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AAB
158AgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAEC
159BGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMji
160AsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIE
161CBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQM
162Mj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQ
163IECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCI
164BQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQ
165T4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBA
166gAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8
167AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAA
168AQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYw
169yOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwB
170AgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAEC
171BAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wME
172CBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQI
173EIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCL
174CxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQ
175IECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAw
176yPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBA
177gAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAg
178FjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5A
179PAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAA
180AQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDz
181AwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAEC
182BAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjA
183IIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEE
184CBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQI
185EDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8Q
186IECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAg
187QCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMs
188LkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBA
189gAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDA
190IPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAA
191AQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACB
192WMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA
1938QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAEC
194BAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPM
195DxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQI
196ECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIB
197gywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQT
198IECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAg
199QMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9A
200gAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECA
201AIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyy
202uADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AA
203AQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAAB
204g8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAEC
205BAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIE
206YgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOIC
207xBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQI
208ECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwy
209P0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAg
210QIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgF
211DLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBP
212gAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECA
213AAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwA
214AQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAAB
215AgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI
2164gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAEC
217BAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIE
218DDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQI
219ECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQ
220iAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsL
221EE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAg
222QIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI
223/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECA
224AAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAW
225MMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8
226AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAAB
227AgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMD
228BAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIE
229CBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAg
230iwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQI
231ECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQ
232MMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAg
233QIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBA
234IBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywu
235QDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECA
236AAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg
2378wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAAB
238AgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFY
239wCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADx
240BAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIE
241CBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wP
242ECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQ
243IEAgFjDI4gLEEyBAgAABAgQWJ4sLPY6I6UcAAAAASUVORK5CYII=</one:Data></one:Image><one:Image format="png" xpsFileIndex="1" originalPageNumber="1" isPrintOut="true" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{5B8B92BB-9FEB-06D8-085F-BB1AEF8C3FD6}{39}{B0}"><one:Position x="36.0" y="824.7522583007812" z="0"/><one:Size width="478.7999572753906" height="619.2000732421874"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAzAAAAQgCAIAAAC2Gy5ZAAAAAXNSR0IArs4c6QAAAAlwSFlzAAAO
244xAAADsQBlSsOGwAAPt9JREFUeF7t3bmqtnm57eG9NjZnYAeGamYDdqGGJiIidoGnoGBzDKKCzSFo
245ILaIgRoaqgg2oZmJimcgW6z93+uBl8lnrapa7fPbNa4vkM9Zc85n3Nd4g8E7Z+m/PPfcc//LHwIE
246CBAgQIAAgfsE/vd9j/ZkAgQIECBAgACB/ydgkHkdECBAgAABAgRuFjDIbi7A4wkQIECAAAECBpnX
247AAECBAgQIEDgZgGD7OYCPJ4AAQIECBAgYJB5DRAgQIAAAQIEbhYwyG4uwOMJECBAgAABAgaZ1wAB
248AgQIECBA4GYBg+zmAjyeAAECBAgQIGCQeQ0QIECAAAECBG4WMMhuLsDjCRAgQIAAAQIGmdcAAQIE
249CBAgQOBmAYPs5gI8ngABAgQIECBgkHkNECBAgAABAgRuFjDIbi7A4wkQIECAAAECBpnXAAECBAgQ
250IEDgZgGD7OYCPJ4AAQIECBAgYJB5DRAgQIAAAQIEbhYwyG4uwOMJECBAgAABAgaZ1wABAgQIECBA
2514GYBg+zmAjyeAAECBAgQIGCQeQ0QIECAAAECBG4WMMhuLsDjCRAgQIAAAQIGmdcAAQIECBAgQOBm
252AYPs5gI8ngABAgQIECBgkHkNECBAgAABAgRuFjDIbi7A4wkQIECAAAECBpnXAAECBAgQIEDgZgGD
2537OYCPJ4AAQIECBAgYJB5DRAgQIAAAQIEbhYwyG4uwOMJECBAgAABAgaZ1wABAgQIECBA4GYBg+zm
254AjyeAAECBAgQIGCQeQ0QIECAAAECBG4WMMhuLsDjCRAgQIAAAQIGmdcAAQIECBAgQOBmAYPs5gI8
255ngABAgQIECBgkHkNECBAgAABAgRuFjDIbi7A4wkQIECAAAECBpnXAAECBAgQIEDgZgGD7OYCPJ4A
256AQIECBAgYJB5DRAgQIAAAQIEbhYwyG4uwOMJECBAgAABAgaZ1wABAgQIECBA4GYBg+zmAjyeAAEC
257BAgQIGCQeQ0QIECAAAECBG4WMMhuLsDjCRAgQIAAAQIGmdcAAQIECBAgQOBmAYPs5gI8ngABAgQI
258ECBgkHkNECBAgAABAgRuFjDIbi7A4wkQIECAAAECBpnXAAECBAgQIEDgZgGD7OYCPJ4AAQIECBAg
259YJB5DRAgQIAAAQIEbhYwyG4uwOMJECBAgAABAgaZ1wABAgQIECBA4GYBg+zmAjyeAAECBAgQIGCQ
260eQ0QIECAAAECBG4WMMhuLsDjCRAgQIAAAQIGmdcAAQIECBAgQOBmAYPs5gI8ngABAgQIECBgkHkN
261ECBAgAABAgRuFjDIbi7A4wkQIECAAAECBpnXAAECBAgQIEDgZgGD7OYCPJ4AAQIECBAgYJB5DRAg
262QIAAAQIEbhYwyG4uwOMJECBAgAABAgaZ1wABAgQIECBA4GYBg+zmAjyeAAECBAgQIGCQeQ0QIECA
263AAECBG4WMMhuLsDjCRAgQIAAAQIGmdcAAQIECBAgQOBmAYPs5gI8ngABAgQIECBgkHkNECBAgAAB
264AgRuFjDIbi7A4wkQIECAAAECBpnXAAECBAgQIEDgZgGD7OYCPJ4AAQIECBAgYJB5DRAgQIAAAQIE
265bhYwyG4uwOMJECBAgAABAgaZ1wABAgQIECBA4GYBg+zmAjyeAAECBAgQIGCQeQ0QIECAAAECBG4W
266MMhuLsDjCRAgQIAAAQIGmdcAAQIECBAgQOBmAYPs5gI8ngABAgQIECBgkHkNECBAgAABAgRuFjDI
267bi7A4wkQIECAAAECBpnXAAECBAgQIEDgZgGD7OYCPJ4AAQIECBAgYJB5DRAgQIAAAQIEbhYwyG4u
268wOMJECBAgAABAgaZ1wABAgQIECBA4GYBg+zmAjyeAAECBAgQIGCQeQ0QIECAAAECBG4WMMhuLsDj
269CRAgQIAAAQIGmdcAAQIECBAgQOBmAYPs5gI8ngABAgQIECBgkHkNECBAgAABAgRuFjDIbi7A4wkQ
270IECAAAECBln3NfCNb3zjE5/4RCTfz372s1e+8pWRMGIQIECAAIGXmYBB9uKF/v73vz9b5Przu9/9
2717sW/4L/oM5577rl//OMf/4FvdgV+4aif/OQnf/rTn770b37C/P3vf3/pn+8zCRAgQIAAgZcuYJC9
272iNVf/vKXd73rXT/+8Y//z7/+efe73/3Sce/6zLe+9a0n6tve9rYXCHAG1l3xPJcAAQIECBB4RsAg
273e/GXxHmb6g1veMP1eX/729+uv5yh9upXv/p62+zrX//647s8Pvh4/+n6Yd/15/HB8+PI8/fzNtUz
274b7w9fTfueZOd73/e+no85cQ4n3a+6vxw83zPK8yV7fpH5xHPfP71T7///e9/8IMffLyR9vS5j5BP
275b/zDH/7wz3me+eaP9+SennzFOH+ueM9QPP3M/8l3H1+8dZ9BgAABAgT+JwXOOyX+vLDAxz/+8Ve8
2764hV//vOfn37aq171qp/85CfnI+fj5++//e1vz9/PKvrqV796fdr54PnPM27O1/7pT386fz+fc/5+
277febXvva1Z/7++Krr216f/NGPfvSZbOeD58/1wfNNTrbHJ5//en38RHo89Ap/BTh/vz7/+vv1oGdO
278uJ57Hfs457rxvCyfCfP0m5/vdgU7u+rxlEfC88FH7Mejrw9e2R5f/swj/FcCBAgQILAg4B2yF1+/
2793/72t7/yla+88Y1vfPp+0nnb7AMf+MD54te97nVf+tKXfv7zn5+3gn7wgx987GMfe/pe2je/+c3z
280487Xv/7154PnZ4gf+chHzmden/DlL3/5+qni+973vvPrWefLz9tUj297/tF56POG+/Wvf319/Hzh
281eaPregvqfIdPf/rTz/v5jwCf+tSnzuf/8+ecEfahD33oCnOF/M1vfvP0nHPjj370oxf+5u94xzvO
282J5yNdX5gesSuT37zm998PfGsrg9/+MPXB9/73vde77cdioNw4VyY3iR7XmQfJECAAIGXvYBB9pIq
283Plvn/FbWWTbvfOc7z2g48+IMoMdP3z73uc+d8f7Xv/71n38H/0y0pw84W+R85jOPfO1rX3u9e3R9
28425cU6F8/6Xzh+c9//ob/1nc4P3h93u9/5tEPf/jDxznXhHrec14g2xltj3/6+Onk+ano9cSzus4j
285rk/4whe+cIba+csvfvGLz3/+84/nvvTDfSYBAgQIEHiZCRhk/45Cr3dxzmy63tS5fs3/+vOZz3zm
286Na95zT9/r8fbQtc/+uUvf/kCz7u+7Uv/czbTS//kF/jMN73pTSfn03POpc97zgt8k8fvip1fC/vs
287Zz97fbczYa8vOT/0fEzY827cJfme97znvEP29Lkv/C8i/Jcc65sQIECAAIGggEH2IqWcefH4nf3z
288I8Xz2W9/+9uv97Se+Z+NuN4i+u53v3t9x/OL8+c/z1ti512i6yPny7/3ve+9//3v/7ce+fTbnk8+
289b7w972eed+muj3/xi188Q+rfO+Me3/Pxe/rnnbPz9tUzPy58es4ZW2dFPW+Yc901xa7Dz6I63/Yx
290Q7/1rW9dX3U+eH5L7Npe3/nOd64PnvfJzjtkjyX3+P6H7um/J/EiDfnHBAgQIEDgZSCw8Ity/8kb
291z++2X79K//g1/Mcvwj8+/vQX/K8PPn5l/vr9/We+/Hzw8ev/T38H//qd+vPn+s335/2l/vOFj294
292nXa+6rwUH2c+80v9jyRPP+3xoOtfMnj816c5r9/lf3zk6SOuZ52QJ8zjc65fzz9/Hh85n/D4qscH
293zzd8/PsH1+/yP/5c/zLB+a8PnP9kd76cAAECBAj8fyHwLy/9N5BeBuvzZXDC+Y2r80v9kR/tnal6
294/kWB6+ePL/zn/A9knP8Jt/OD3evTzntgv/rVryJXvFh2/5wAAQIECPy3C/iR5X87sQccgfMvN7zl
295LW+5KM7PKP9j/w8EJAkQIECAwMtVwCB7uTbbuuuPf/zj+S2061+oPP8DIp03+VpM0hAgQIDAqoAf
296Wa42724CBAgQIEAgI+AdskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGD
297bLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBb
298bd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFab
299dzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebd
300TYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcT
301IECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQI
302ECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIE
303CBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAEC
304BAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAAB
305AhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBA
306RsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJAR
307MMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQM
308skwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMs
309U4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtU
310IQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUI
311QoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQ
312IECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQI
313ECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIE
314CBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAEC
315BAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAAB
316AqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDA
317qoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAq
318YJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoY
319ZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZ
320avPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbba
321vLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbv
322bgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbub
323AAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZA
324gAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAg
325QIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQ
326IEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQI
327EMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIE
328MgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGM
329gEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNg
330kGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhk
331mSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmm
332CkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlC
333EAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCE
334AAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFA
335gAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAg
336QIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQ
337IEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQI
338EFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIE
339VgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFV
340AYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXA
341IFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDI
342Vpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV
3435t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5
344dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7d
345BAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcB
346AgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAA
347AQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECA
348AAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBA
349gEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAg
350kBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhk
351BAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkB
352gyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAg
353y1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgy
354VQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwV
355ghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4Ug
356BAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgB
357AgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAA
358AQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECA
359AAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBA
360gMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAg
361sCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAis
362Chhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsC
363Btlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBB
364ttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCt
365Nu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvN
366u5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPu
367JkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJ
368ECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIE
369CBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAEC
370BAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAAB
371AgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAA
372gYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAg
373I2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgI
374GGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIG
375WaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGW
376qUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWq
377EIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoE
378IUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEI
379ECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIE
380CBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAEC
381BAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAAB
382AgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAA
383gVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBg
384VcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgV
385MMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUM
386stXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNs
387tXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt
3883t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3
389NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1N
390gAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMg
391QIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQ
392IECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQI
393ECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIE
394CGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAEC
395GQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBG
396wCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEw
397yDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyy
398TBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxT
399hSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1Qh
400CAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhC
401gAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAg
402QIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQ
403IECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQI
404ECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIE
405CKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAEC
406qwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCq
407gEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpg
408kK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhk
409q827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq
4108+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8
411uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9u
412AgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sA
413AQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCA
414AAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBA
415gACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAg
416QCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQ
417yAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQy
418AgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyA
419QZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQ
420ZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZ
421KgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYK
422QQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQ
423AgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQA
424AQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCA
425AAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBA
426gACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAg
427QGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQ
428WBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRW
429BQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUB
430g2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAg
431W23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhW
432m3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm
4333U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3
434EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0E
435CBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwEC
436BAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAAB
437AgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAA
438AQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECA
439QEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQ
440ETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQE
441DLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGD
442LFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDL
443VCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJV
444CEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWC
445ECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAE
446CBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAEC
447BAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAAB
448AgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAA
449AQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECA
450wKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCw
451KmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwK
452GGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG
4532Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG2
4542ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02
455724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827
456mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4m
457QIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQ
458IECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQI
459ECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIE
460CBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAEC
461BDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACB
462jIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAj
463YJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgY
464ZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZ
465pgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZap
466QhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQ
467hAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQh
468QIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQ
469IECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQI
470ECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIE
471CBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAEC
472BFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACB
473VQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBV
474wCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUw
475yFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy
4761ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1
477eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e
4783QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3
479AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2A
480AAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBA
481gAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAg
482QIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQ
483IJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQI
484ZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZ
485AYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbA
486IMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDI
487MlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJM
488FYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOF
489IAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEI
490AQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKA
491AAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBA
492gAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAg
493QIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQ
494ILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQI
495rAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKr
496AgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqA
497QbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQ
498rTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSr
499zbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz
5007iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7
501CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724C
502BAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwAB
503AgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAA
504AQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECA
505AIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBA
506ICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDI
507CBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDIC
508BlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBB
509lqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBl
510qhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkq
511BCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpB
512CBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhAC
513BAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAAB
514AgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAA
515AQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECA
516AIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBA
517YFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBY
518FTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYF
519DLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGD
520bLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBb
521bd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFab
522dzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebd
523TYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcT
524IECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQI
525ECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIE
526CBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgI/F9mGeSSDxi9rAAAAABJRU5ErkJggg==</one:Data></one:Image></one:Page>
corpus/printout/cold/read/page-001.navigation.json+1-1
...@@ -6,7 +6,7 @@...@@ -6,7 +6,7 @@
6 "h": 600,6 "h": 600,
7 "error": null,7 "error": null,
8 "win": {8 "win": {
9 "title": "Printout - Microsoft OneNote",9 "title": "Pasted shape - Microsoft OneNote",
10 "class": "Framework::CFrame",10 "class": "Framework::CFrame",
11 "dialog": false11 "dialog": false
12 }12 }
corpus/printout/cold/read/page-001.pdf created
Binary files /dev/null and b/corpus/printout/cold/read/page-001.pdf differ
corpus/printout/cold/read/page-001.png
Binary files a/corpus/printout/cold/read/page-001.png and b/corpus/printout/cold/read/page-001.png differ
corpus/printout/cold/read/page-001.xml+73-525
...@@ -1,526 +1,74 @@...@@ -1,526 +1,74 @@
1<?xml version="1.0"?> 1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{DC91B969-8F8E-0E38-22C6-17772EFD8803}{1}{B0}" name="Printout" dateTime="2026-09-30T03:34:55.000Z" lastModifiedTime="2026-09-30T03:36:49.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="1" name="cite" fontColor="#595959" highlightColor="automatic" font="Calibri" fontSize="9.0" spaceBefore="0.0" spaceAfter="0.0"/><one:XPSFile xpsFileIndex="0" idDocument="{4061E200-F6FA-4AED-9E31-3DE29839A9AF}"><one:CallbackID callbackID="{DC91B969-8F8E-0E38-22C6-17772EFD8803}{1}{D629}"/></one:XPSFile><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T03:34:55.000Z" lastModifiedTime="2026-09-30T03:35:20.000Z" objectID="{E4603B17-812F-0DAE-0D01-C8CAD55FB421}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Printout]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T03:35:28.000Z" objectID="{DB7C4F71-B073-0121-2D07-84DA732F3A82}{14}{B0}"><one:Position x="36.0" y="99.16597747802734" z="2"/><one:Size width="303.0612182617187" height="73.98631286621094"/><one:OEChildren><one:OE creationTime="2026-09-30T03:35:28.000Z" lastModifiedTime="2026-09-30T03:35:28.000Z" objectID="{DB7C4F71-B073-0121-2D07-84DA732F3A82}{23}{B0}" alignment="left"><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\f2504f4c-e774-48dd-8e37-fe896574adbe.docx" pathSource="C:\work\printout\inputs\two-pages.docx" preferredName="two-pages.docx"/></one:OE><one:OE creationTime="2026-09-30T03:35:28.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{DB7C4F71-B073-0121-2D07-84DA732F3A82}{20}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[Printed Inserted from: &lt;<a 2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{8D0DE017-4656-0799-2A35-787C2A25B0D1}{1}{B0}" name="Pasted shape" dateTime="2026-09-30T03:34:55.000Z" lastModifiedTime="2026-09-30T03:35:09.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T03:34:56.000Z" lastModifiedTime="2026-09-30T03:35:06.000Z" objectID="{950DC853-4806-040C-2E7F-4ACFF3449601}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Pasted shape]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T03:35:10.000Z" objectID="{CD69A2EE-E57B-0D07-3F57-90243E3DC2ED}{10}{B0}"><one:Position x="36.0" y="86.4000015258789" z="0"/><one:Size width="122.2499847412109" height="95.92770385742187"/><one:OEChildren><one:OE creationTime="2026-09-30T03:35:08.000Z" lastModifiedTime="2026-09-30T03:35:10.000Z" objectID="{CD69A2EE-E57B-0D07-3F57-90243E3DC2ED}{18}{B0}" alignment="left"><one:Image format="png" originalPageNumber="0"><one:Size width="122.25" height="82.49999237060547"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAKMAAABuCAIAAADEeDS9AAAAAXNSR0IArs4c6QAAD+tJREFUeF7t
3href="file:///C:\work\printout\inputs\two-pages.docx">file://C:\work\printout\inputs\two-pages.docx</a>&gt;]]></one:T></one:OE></one:OEChildren></one:Outline><one:Image format="png" xpsFileIndex="0" originalPageNumber="0" isPrintOut="true" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{DB7C4F71-B073-0121-2D07-84DA732F3A82}{32}{B0}"><one:Position x="36.0" y="225.352279663086" z="1"/><one:Size width="478.7999572753906" height="619.2000122070312"/><one:Data>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</one:Data></one:Image></one:OE><one:OE creationTime="2026-09-30T03:35:09.000Z" lastModifiedTime="2026-09-30T03:35:09.000Z" objectID="{CD69A2EE-E57B-0D07-3F57-90243E3DC2ED}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
75FjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5A
76PAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAA
77AQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDz
78AwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAEC
79BAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjA
80IIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEE
81CBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQI
82EDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8Q
83IECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAg
84QCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMs
85LkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBA
86gAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDA
87IPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAA
88AQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACB
89WMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA
908QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAEC
91BAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPM
92DxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQI
93ECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIB
94gywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQT
95IECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAg
96QMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9A
97gAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECA
98AIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyy
99uADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AA
100AQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAAB
101g8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAEC
102BAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIE
103YgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOIC
104xBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQI
105ECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwy
106P0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAg
107QIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgF
108DLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBP
109gAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECA
110AAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwA
111AQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAAB
112AgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI
1134gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAEC
114BAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIE
115DDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQI
116ECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQ
117iAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsL
118EE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAg
119QIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI
120/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECA
121AAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAW
122MMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8
123AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAAB
124AgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMD
125BAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIE
126CBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAg
127iwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQI
128ECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQ
129MMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAg
130QIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBA
131IBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywu
132QDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECA
133AAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg
1348wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAAB
135AgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFY
136wCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADx
137BAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIE
138CBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wP
139ECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQ
140IEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGD
141LC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMg
142QIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBA
143wCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CA
144AAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAA
145gVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4
146APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAAB
147AgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGD
148zA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIE
149CBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRi
150AYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLE
151EyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQ
152IEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/
153QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBA
154gACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUM
155srgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+A
156AAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAA
157AYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AAB
158AgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAEC
159BGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMji
160AsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIE
161CBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQM
162Mj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQ
163IECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCI
164BQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQ
165T4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBA
166gAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8
167AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAA
168AQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYw
169yOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwB
170AgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAEC
171BAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wME
172CBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQI
173EIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCL
174CxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQ
175IECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAw
176yPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBA
177gAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAg
178FjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5A
179PAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAA
180AQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDz
181AwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAEC
182BAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjA
183IIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEE
184CBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQI
185EDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8Q
186IECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAg
187QCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMs
188LkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBA
189gAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDA
190IPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAA
191AQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACB
192WMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA
1938QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAEC
194BAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPM
195DxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQI
196ECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIB
197gywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQT
198IECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAg
199QMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9A
200gAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECA
201AIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyy
202uADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AA
203AQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAAB
204g8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAEC
205BAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIE
206YgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOIC
207xBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQI
208ECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwy
209P0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAg
210QIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgF
211DLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBP
212gAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECA
213AAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwA
214AQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAAB
215AgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI
2164gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAEC
217BAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIE
218DDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQI
219ECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQ
220iAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsL
221EE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAg
222QIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI
223/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECA
224AAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAW
225MMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8
226AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAAB
227AgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMD
228BAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIE
229CBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAg
230iwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQI
231ECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQ
232MMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAg
233QIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBA
234IBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywu
235QDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECA
236AAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg
2378wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAAB
238AgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFY
239wCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADx
240BAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIE
241CBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wP
242ECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQ
243IEAgFjDI4gLEEyBAgAABAgQWJ4sLPY6I6UcAAAAASUVORK5CYII=</one:Data></one:Image><one:Image format="png" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{DB7C4F71-B073-0121-2D07-84DA732F3A82}{39}{B0}"><one:Position x="36.0" y="824.7522583007812" z="0"/><one:Size width="477.3599853515625" height="617.7600708007812"/><one:Data>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
275b/zDH/7wz3me+eaP9+SennzFOH+ueM9QPP3M/8l3H1+8dZ9BgAABAgT+JwXOOyX+vLDAxz/+8Ve8
2764hV//vOfn37aq171qp/85CfnI+fj5++//e1vz9/PKvrqV796fdr54PnPM27O1/7pT386fz+fc/5+
277febXvva1Z/7++Krr216f/NGPfvSZbOeD58/1wfNNTrbHJ5//en38RHo89Ap/BTh/vz7/+vv1oGdO
278uJ57Hfs457rxvCyfCfP0m5/vdgU7u+rxlEfC88FH7Mejrw9e2R5f/swj/FcCBAgQILAg4B2yF1+/
2793/72t7/yla+88Y1vfPp+0nnb7AMf+MD54te97nVf+tKXfv7zn5+3gn7wgx987GMfe/pe2je/+c3z
280487Xv/7154PnZ4gf+chHzmden/DlL3/5+qni+973vvPrWefLz9tUj297/tF56POG+/Wvf319/Hzh
281eaPregvqfIdPf/rTz/v5jwCf+tSnzuf/8+ecEfahD33oCnOF/M1vfvP0nHPjj370oxf+5u94xzvO
282J5yNdX5gesSuT37zm998PfGsrg9/+MPXB9/73vde77cdioNw4VyY3iR7XmQfJECAAIGXvYBB9pIq
283Plvn/FbWWTbvfOc7z2g48+IMoMdP3z73uc+d8f7Xv/71n38H/0y0pw84W+R85jOPfO1rX3u9e3R9
28425cU6F8/6Xzh+c9//ob/1nc4P3h93u9/5tEPf/jDxznXhHrec14g2xltj3/6+Onk+ano9cSzus4j
285rk/4whe+cIba+csvfvGLz3/+84/nvvTDfSYBAgQIEHiZCRhk/45Cr3dxzmy63tS5fs3/+vOZz3zm
286Na95zT9/r8fbQtc/+uUvf/kCz7u+7Uv/czbTS//kF/jMN73pTSfn03POpc97zgt8k8fvip1fC/vs
287Zz97fbczYa8vOT/0fEzY827cJfme97znvEP29Lkv/C8i/Jcc65sQIECAAIGggEH2IqWcefH4nf3z
288I8Xz2W9/+9uv97Se+Z+NuN4i+u53v3t9x/OL8+c/z1ti512i6yPny7/3ve+9//3v/7ce+fTbnk8+
289b7w972eed+muj3/xi188Q+rfO+Me3/Pxe/rnnbPz9tUzPy58es4ZW2dFPW+Yc901xa7Dz6I63/Yx
290Q7/1rW9dX3U+eH5L7Npe3/nOd64PnvfJzjtkjyX3+P6H7um/J/EiDfnHBAgQIEDgZSCw8Ity/8kb
291z++2X79K//g1/Mcvwj8+/vQX/K8PPn5l/vr9/We+/Hzw8ev/T38H//qd+vPn+s335/2l/vOFj294
292nXa+6rwUH2c+80v9jyRPP+3xoOtfMnj816c5r9/lf3zk6SOuZ52QJ8zjc65fzz9/Hh85n/D4qscH
293zzd8/PsH1+/yP/5c/zLB+a8PnP9kd76cAAECBAj8fyHwLy/9N5BeBuvzZXDC+Y2r80v9kR/tnal6
294/kWB6+ePL/zn/A9knP8Jt/OD3evTzntgv/rVryJXvFh2/5wAAQIECPy3C/iR5X87sQccgfMvN7zl
295LW+5KM7PKP9j/w8EJAkQIECAwMtVwCB7uTbbuuuPf/zj+S2061+oPP8DIp03+VpM0hAgQIDAqoAf
296Wa42724CBAgQIEAgI+AdskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGD
297bLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBb
298bd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFab
299dzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebd
300TYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcT
301IECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQI
302ECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIE
303CBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAEC
304BAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAAB
305AhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBA
306RsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJAR
307MMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQM
308skwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMs
309U4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtU
310IQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUI
311QoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQ
312IECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQI
313ECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIE
314CBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAEC
315BAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAAB
316AqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDA
317qoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAq
318YJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoY
319ZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZ
320avPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbba
321vLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbv
322bgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbub
323AAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZA
324gAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAg
325QIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQ
326IEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQI
327EMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIE
328MgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGM
329gEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNg
330kGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhk
331mSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmm
332CkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlC
333EAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCE
334AAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFA
335gAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAg
336QIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQ
337IEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQI
338EFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIE
339VgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFV
340AYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXA
341IFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDI
342Vpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV
3435t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5
344dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7d
345BAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcB
346AgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAA
347AQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECA
348AAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBA
349gEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAg
350kBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhk
351BAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkB
352gyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAg
353y1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgy
354VQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwV
355ghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4Ug
356BAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgB
357AgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAA
358AQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECA
359AAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBA
360gMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAg
361sCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAis
362Chhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsC
363Btlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBB
364ttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCt
365Nu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvN
366u5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPu
367JkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJ
368ECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIE
369CBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAEC
370BAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAAB
371AgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAA
372gYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAg
373I2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgI
374GGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIG
375WaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGW
376qUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWq
377EIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoE
378IUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEI
379ECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIE
380CBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAEC
381BAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAAB
382AgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAA
383gVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBg
384VcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgV
385MMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUM
386stXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNs
387tXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt
3883t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3
389NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1N
390gAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMg
391QIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQ
392IECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQI
393ECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIE
394CGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAEC
395GQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBG
396wCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEw
397yDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyy
398TBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxT
399hSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1Qh
400CAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhC
401gAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAg
402QIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQ
403IECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQI
404ECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIE
405CKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAEC
406qwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCq
407gEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpg
408kK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhk
409q827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq
4108+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8
411uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9u
412AgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sA
413AQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCA
414AAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBA
415gACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAg
416QCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQ
417yAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQy
418AgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyA
419QZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQ
420ZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZ
421KgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYK
422QQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQ
423AgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQA
424AQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCA
425AAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBA
426gACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAg
427QGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQ
428WBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRW
429BQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUB
430g2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAg
431W23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhW
432m3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm
4333U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3
434EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0E
435CBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwEC
436BAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAAB
437AgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAA
438AQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECA
439QEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQ
440ETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQE
441DLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGD
442LFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDL
443VCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJV
444CEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWC
445ECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAE
446CBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAEC
447BAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAAB
448AgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAA
449AQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECA
450wKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCw
451KmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwK
452GGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG
4532Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG2
4542ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02
455724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827
456mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4m
457QIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQ
458IECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQI
459ECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIE
460CBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAEC
461BDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACB
462jIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAj
463YJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgY
464ZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZ
465pgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZap
466QhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQ
467hAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQh
468QIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQ
469IECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQI
470ECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIE
471CBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAEC
472BFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACB
473VQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBV
474wCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUw
475yFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy
4761ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1
477eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e
4783QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3
479AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2A
480AAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBA
481gAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAg
482QIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQ
483IJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQI
484ZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZ
485AYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbA
486IMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDI
487MlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJM
488FYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOF
489IAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEI
490AQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKA
491AAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBA
492gAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAg
493QIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQ
494ILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQI
495rAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKr
496AgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqA
497QbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQ
498rTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSr
499zbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz
5007iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7
501CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724C
502BAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwAB
503AgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAA
504AQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECA
505AIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBA
506ICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDI
507CBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDIC
508BlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBB
509lqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBl
510qhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkq
511BCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpB
512CBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhAC
513BAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAAB
514AgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAA
515AQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECA
516AIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBA
517YFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBY
518FTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYF
519DLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGD
520bLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBb
521bd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFab
522dzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebd
523TYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcT
524IECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQI
525ECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIE
526CBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgI/F9mGeSSDxi9rAAAAABJRU5ErkJggg==</one:Data></one:Image></one:Page>
corpus/printout/cold/read/payloads.json+2-2
...@@ -2,9 +2,9 @@...@@ -2,9 +2,9 @@
2 { 2 {
3 "sha256": "e15f2722a8d11f9e68ac5eff54320f7c4ef5540fb6eb858223e31b983fa1bed7", 3 "sha256": "e15f2722a8d11f9e68ac5eff54320f7c4ef5540fb6eb858223e31b983fa1bed7",
4 "name": "two-pages.docx", 4 "name": "two-pages.docx",
5 "object": "{DB7C4F71-B073-0121-2D07-84DA732F3A82}{23}{B0}", 5 "object": "{5B8B92BB-9FEB-06D8-085F-BB1AEF8C3FD6}{23}{B0}",
6 "kind": "InsertedFile", 6 "kind": "InsertedFile",
7 "page": "{DC91B969-8F8E-0E38-22C6-17772EFD8803}{1}{B0}", 7 "page": "{5C6664A3-A016-09C1-079E-28B7B25E8D57}{1}{B0}",
8 "bytes": 12589 8 "bytes": 12589
9 } 9 }
10]10]
\ No newline at end of file
corpus/printout/cold/run.json+1-1
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1{1{
2 "notebook": "/private/tmp/unsup-lab/printout-candidate",2 "notebook": "/private/tmp/printlab/candidate",
3 "expected_pages": 2,3 "expected_pages": 2,
4 "author": null,4 "author": null,
5 "author_timeout_seconds": 600,5 "author_timeout_seconds": 600,
corpus/printout/cold/source.json+5-5
...@@ -2,13 +2,13 @@...@@ -2,13 +2,13 @@
2 {2 {
3 "path": "Open Notebook.onetoc2",3 "path": "Open Notebook.onetoc2",
4 "bytes": 1612,4 "bytes": 1612,
5 "sha256": "29bbfd869154aea1720f98cf7c7cc40387313b00c8dfdb61d159f968312367b4",5 "sha256": "fe8a117e418bb9aea7565bd92f01e03f87d763ba60bb5df24ab85b428dbd4f08",
6 "mtime_ns": 17907442757646083286 "mtime_ns": 1790820802979688144
7 },7 },
8 {8 {
9 "path": "Printouts.one",9 "path": "Printouts.one",
10 "bytes": 268208,10 "bytes": 443488,
11 "sha256": "5b48da81b1b374da9af23b4dd664b01b100c34be71aef721907f4365afe5577e",11 "sha256": "2be9306af1295605fd3deab3dfd16de2bfdeeecd6b696b48b4a6104b8d997c06",
12 "mtime_ns": 179074427576432916512 "mtime_ns": 1790820802979090020
13 }13 }
14]14]
corpus/recycle-purge/README.md created+19
...@@ -0,0 +1,19 @@
1# Recycle bin purge
2
3OneNote 2010 deletes for good what its Notebook Recycle Bin has held "after 60
4days" (`DaysToKeepRecycledItems`), a few minutes after it starts. Observed in a lab
5clone on 2026-09-30, with the clock moved to age pages and sections:
6
7- it judges by when the content last changed, not when it was deleted: pages
8 created 70 days back and deleted a minute before were purged, a page deleted
9 that day but changed that day was kept;
10- a page goes from `OneNote_DeletedPages.one` in one appended revision;
11- a binned section goes as a file once none of its pages changed in 60 days, its
12 file's own times aside; the bin's TOC keeps listing it, unchanged.
13
14`candidate/` is the notebook
15`the_recycle_bin_forgets_pages_and_sections_unchanged_for_sixty_days` in
16`crates/notebook/tests/structure.rs` exports with `NOTEBOOK_PURGE_EXPORT`: a page
17and a section deleted into the bin, then purged as 61 days on. `cold/` is a fresh
18OneNote 2010's read: Deleted Pages empty, the section gone, no repair, every file
19left as written. `tools/test_recycle_purge.py` checks it without a VM.
corpus/recycle-purge/candidate/New Section 1.one created
Binary files /dev/null and b/corpus/recycle-purge/candidate/New Section 1.one differ
corpus/recycle-purge/candidate/OneNote_RecycleBin/OneNote_DeletedPages.one created
Binary files /dev/null and b/corpus/recycle-purge/candidate/OneNote_RecycleBin/OneNote_DeletedPages.one differ
corpus/recycle-purge/candidate/OneNote_RecycleBin/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/recycle-purge/candidate/OneNote_RecycleBin/Open Notebook.onetoc2 differ
corpus/recycle-purge/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/recycle-purge/candidate/Open Notebook.onetoc2 differ
corpus/recycle-purge/cold/hierarchy.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="purge" nickname="purge" ID="{98858EA9-DB12-48A2-B756-EB379B77AA25}{1}{B0}" path="C:\lab\purge" lastModifiedTime="2026-09-30T22:51:49.000Z" color="#FFD700"><one:Section name="New Section 1" ID="{6BE45CC0-74B3-0F1C-3814-D784BA1B84EB}{1}{B0}" path="C:\lab\purge\New Section 1.one" lastModifiedTime="2026-09-30T22:51:49.000Z" color="#8AA8E4"><one:Page ID="{B4E67780-D3AA-0F66-271A-852DFBA50A95}{1}{B0}" name="Kept" dateTime="2026-09-30T22:51:02.000Z" lastModifiedTime="2026-09-30T22:51:49.000Z" pageLevel="1"/></one:Section><one:SectionGroup name="OneNote_RecycleBin" ID="{221540FE-0F55-4BD2-B5B1-B600558FB8F3}{1}{B0}" path="C:\lab\purge\OneNote_RecycleBin" lastModifiedTime="2026-09-30T22:51:20.000Z" isRecycleBin="true"><one:Section name="Deleted Pages" ID="{C10E127F-A313-04F9-2E2E-21B017977A20}{1}{B0}" path="C:\lab\purge\OneNote_RecycleBin\OneNote_DeletedPages.one" lastModifiedTime="2026-09-30T22:51:20.000Z" color="#E1E1E1" isInRecycleBin="true" isDeletedPages="true"/></one:SectionGroup></one:Notebook>
corpus/recycle-purge/cold/recycle-bin.png created
Binary files /dev/null and b/corpus/recycle-purge/cold/recycle-bin.png differ
corpus/section-color/README.md created+19
...@@ -0,0 +1,19 @@
1# Section and notebook colour
2
3`native` is what OneNote 2010 did on the VM (2026-09-30). A section tab's context menu
4ends with Section Color, sixteen named colours and None (`section-color-menu.png`);
5`colors.txt` is each as the hierarchy XML reports it, in the menu's order. Picking one
6appends a revision to the section's own file setting its metadata's `0x14001cbe`
7(COLORREF; `teal.one`), and None sets `0xFFFFFFFF` (`none.one`); the table of contents is
8left byte for byte. Notebook Properties (`notebook-properties.png`, from the notebook's
9Rename… or Properties…) offers the same sixteen without None (`notebook-colors.png`) and
10appends a revision to the root `Open Notebook.onetoc2` setting its root's `0x14001cbe`
11(`before.onetoc2`, `after.onetoc2`). Its Display name is written to neither file: OneNote
12keeps it in its local `OneNoteOfflineCache.onecache`, so the hierarchy's `nickname`
13changes and `name`, the folder, stays.
14
15`candidate` is `crates/snowbound/src/menus.rs`'s notebook, exported with
16`SNOWBOUND_SECTION_COLOR_EXPORT`: a section per colour and None, each made then coloured
17with `Notebook::set_section_color`, and the notebook coloured Teal with
18`Notebook::set_color`. `cold` is a fresh OneNote 2010's read: every section and the
19notebook in the colour it was given. `tools/test_section_color.py` checks it without a VM.
corpus/section-color/candidate/Apple.one created
Binary files /dev/null and b/corpus/section-color/candidate/Apple.one differ
corpus/section-color/candidate/Blue Mist.one created
Binary files /dev/null and b/corpus/section-color/candidate/Blue Mist.one differ
corpus/section-color/candidate/Blue.one created
Binary files /dev/null and b/corpus/section-color/candidate/Blue.one differ
corpus/section-color/candidate/Cyan.one created
Binary files /dev/null and b/corpus/section-color/candidate/Cyan.one differ
corpus/section-color/candidate/Green.one created
Binary files /dev/null and b/corpus/section-color/candidate/Green.one differ
corpus/section-color/candidate/Lemon.one created
Binary files /dev/null and b/corpus/section-color/candidate/Lemon.one differ
corpus/section-color/candidate/Magenta.one created
Binary files /dev/null and b/corpus/section-color/candidate/Magenta.one differ
corpus/section-color/candidate/New Section 1.one created
Binary files /dev/null and b/corpus/section-color/candidate/New Section 1.one differ
corpus/section-color/candidate/None.one created
Binary files /dev/null and b/corpus/section-color/candidate/None.one differ
corpus/section-color/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/section-color/candidate/Open Notebook.onetoc2 differ
corpus/section-color/candidate/Orange.one created
Binary files /dev/null and b/corpus/section-color/candidate/Orange.one differ
corpus/section-color/candidate/Purple Mist.one created
Binary files /dev/null and b/corpus/section-color/candidate/Purple Mist.one differ
corpus/section-color/candidate/Purple.one created
Binary files /dev/null and b/corpus/section-color/candidate/Purple.one differ
corpus/section-color/candidate/Red Chalk.one created
Binary files /dev/null and b/corpus/section-color/candidate/Red Chalk.one differ
corpus/section-color/candidate/Red.one created
Binary files /dev/null and b/corpus/section-color/candidate/Red.one differ
corpus/section-color/candidate/Silver.one created
Binary files /dev/null and b/corpus/section-color/candidate/Silver.one differ
corpus/section-color/candidate/Tan.one created
Binary files /dev/null and b/corpus/section-color/candidate/Tan.one differ
corpus/section-color/candidate/Teal.one created
Binary files /dev/null and b/corpus/section-color/candidate/Teal.one differ
corpus/section-color/candidate/Yellow.one created
Binary files /dev/null and b/corpus/section-color/candidate/Yellow.one differ
corpus/section-color/cold/commands.jsonl created+3
...@@ -0,0 +1,3 @@
1{"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}
2{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-35D6278F -ExpectedPages -1 -KeepOpen", "exit": 0, "stdout": "Read 18 sections and 18 pages.\r\n", "stderr": "", "error": null}
3{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\read -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
corpus/section-color/cold/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-35d6278f", "hostname": "ONE-M6-35D6278F"}
corpus/section-color/cold/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-35D6278F",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/section-color/cold/read/hierarchy.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{DE201FFA-1326-48B5-A3F3-ED7AA61FFD24}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-10-01T03:35:50.000Z" color="#4DBCCA"><one:Section name="New Section 1" ID="{F34D2C0C-CEB4-00FE-251C-827EA5EAC473}{1}{B0}" path="C:\one-tests\runs\capture\notebook\New Section 1.one" lastModifiedTime="2026-10-01T03:35:44.000Z" color="#8AA8E4"><one:Page ID="{90D5B5C6-8777-05E1-2EA4-C9948ED4A5BC}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:44.000Z" pageLevel="1"/></one:Section><one:Section name="Blue" ID="{AADE0D52-BE3E-026A-327F-A3CD5347178B}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Blue.one" lastModifiedTime="2026-10-01T03:35:44.000Z" color="#8AA8E4"><one:Page ID="{3239575D-5857-0398-280F-710EAE4FDFB5}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:44.000Z" pageLevel="1"/></one:Section><one:Section name="Yellow" ID="{DD1F63C2-1935-04F7-3D18-2C6DFE4C8C47}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Yellow.one" lastModifiedTime="2026-10-01T03:35:44.000Z" color="#FFD869"><one:Page ID="{791B4AA8-533F-050A-3BF6-A583EF3BAD0B}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:44.000Z" pageLevel="1"/></one:Section><one:Section name="Green" ID="{BF5E1568-2CE4-07D7-3F3B-E2A2E7F6FE43}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Green.one" lastModifiedTime="2026-10-01T03:35:44.000Z" color="#B7C997"><one:Page ID="{24D52AE5-5F97-0097-3AF6-922A51E28C12}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:44.000Z" pageLevel="1"/></one:Section><one:Section name="Red" ID="{AEB0A8AC-CE2A-082E-0A88-F389C27A1BBB}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Red.one" lastModifiedTime="2026-10-01T03:35:45.000Z" color="#EE9597"><one:Page ID="{81BDCDF3-EF9E-070A-2434-2A780B71D8F9}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:45.000Z" pageLevel="1"/></one:Section><one:Section name="Purple" ID="{E539F8FA-C896-0C83-2789-9C4B935CD20F}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Purple.one" lastModifiedTime="2026-10-01T03:35:45.000Z" color="#B49EDE"><one:Page ID="{E90EECF6-6E48-0632-1940-7E8970A58DC6}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:45.000Z" pageLevel="1"/></one:Section><one:Section name="Cyan" ID="{AF1E4259-EBF2-000B-0088-223A0BC20B32}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Cyan.one" lastModifiedTime="2026-10-01T03:35:46.000Z" color="#91BAAE"><one:Page ID="{BC9979E2-5D95-00B6-2C74-B7A77A233187}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:46.000Z" pageLevel="1"/></one:Section><one:Section name="Orange" ID="{9A592B35-90D3-091F-206E-BE6BF8B5DA83}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Orange.one" lastModifiedTime="2026-10-01T03:35:46.000Z" color="#F6B078"><one:Page ID="{1C88B44F-EC4D-0184-1DB4-3AC18E902D25}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:46.000Z" pageLevel="1"/></one:Section><one:Section name="Magenta" ID="{A2CBA2FC-5BAD-01B7-2665-8449DB67E89F}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Magenta.one" lastModifiedTime="2026-10-01T03:35:47.000Z" color="#D5A4BB"><one:Page ID="{9D9C8963-166F-0589-3BAB-A943E8F9DC28}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:47.000Z" pageLevel="1"/></one:Section><one:Section name="Blue Mist" ID="{3E316B71-E3AD-049D-0A91-9AE3725C9D2B}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Blue Mist.one" lastModifiedTime="2026-10-01T03:35:47.000Z" color="#9BBBD2"><one:Page ID="{3E7714AB-FD3C-0D84-2BAF-44342145F507}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:47.000Z" pageLevel="1"/></one:Section><one:Section name="Purple Mist" ID="{69597FC7-D44F-0D46-1AA2-7D146F94F975}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Purple Mist.one" lastModifiedTime="2026-10-01T03:35:48.000Z" color="#AB9CB7"><one:Page ID="{7C39C61C-A0F8-00D5-3893-8297F6742D60}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:48.000Z" pageLevel="1"/></one:Section><one:Section name="Tan" ID="{7D747A78-6A7F-0397-1D5B-3CC69A75BCED}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Tan.one" lastModifiedTime="2026-10-01T03:35:48.000Z" color="#E8D199"><one:Page ID="{8E3AAB5C-B419-0B5F-16E0-1AF5A9438BD6}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:48.000Z" pageLevel="1"/></one:Section><one:Section name="Lemon" ID="{ADED87CE-BA73-010E-2722-26E7ACE70859}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Lemon.one" lastModifiedTime="2026-10-01T03:35:48.000Z" color="#F5F96F"><one:Page ID="{DDB49CEE-9C38-04F0-1E0F-639616393CE1}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:48.000Z" pageLevel="1"/></one:Section><one:Section name="Apple" ID="{15D11240-5CF2-09C6-3A16-CD68FF1436F9}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Apple.one" lastModifiedTime="2026-10-01T03:35:48.000Z" color="#ADE792"><one:Page ID="{DEA8ADE8-2F81-051A-3649-CC8A836EC084}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:48.000Z" pageLevel="1"/></one:Section><one:Section name="Teal" ID="{C431D5A9-3F5A-094E-0F1F-08EF1EE8F560}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Teal.one" lastModifiedTime="2026-10-01T03:35:48.000Z" color="#4DBCCA"><one:Page ID="{C1B5C6E3-76DA-0D31-1416-106902477F66}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:48.000Z" pageLevel="1"/></one:Section><one:Section name="Red Chalk" ID="{FF23FB4E-B44C-07CB-0BE6-4C43D9011522}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Red Chalk.one" lastModifiedTime="2026-10-01T03:35:48.000Z" color="#BA7575"><one:Page ID="{00EDDE16-DA2C-07F0-2C57-EB684E97986D}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:48.000Z" pageLevel="1"/></one:Section><one:Section name="Silver" ID="{2EAD8856-08CB-0803-29AC-B4AC54902F50}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Silver.one" lastModifiedTime="2026-10-01T03:35:49.000Z" color="#9595AA"><one:Page ID="{A65346B6-179C-06CD-15B8-2CFDFAEB150F}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:02.000Z" lastModifiedTime="2026-10-01T03:35:49.000Z" pageLevel="1"/></one:Section><one:Section name="None" ID="{8B4D3FA5-96DA-0D09-06EE-D1F568924219}{1}{B0}" path="C:\one-tests\runs\capture\notebook\None.one" lastModifiedTime="2026-10-01T03:35:50.000Z" color="none"><one:Page ID="{BB87BCFA-D952-06DA-0B0B-704A444F1847}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:02.000Z" lastModifiedTime="2026-10-01T03:35:50.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/section-color/cold/read/page-000.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Untitled page - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/section-color/cold/read/page-000.png created
Binary files /dev/null and b/corpus/section-color/cold/read/page-000.png differ
corpus/section-color/cold/read/page-000.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{00EDDE16-DA2C-07F0-2C57-EB684E97986D}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:48.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:29:01.000Z" objectID="{00EDDE16-DA2C-07F0-2C57-EB684E97986D}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page>
corpus/section-color/cold/read/page-001.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Untitled page - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/section-color/cold/read/page-001.png created
Binary files /dev/null and b/corpus/section-color/cold/read/page-001.png differ
corpus/section-color/cold/read/page-001.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{1C88B44F-EC4D-0184-1DB4-3AC18E902D25}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:46.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:29:01.000Z" objectID="{1C88B44F-EC4D-0184-1DB4-3AC18E902D25}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page>
corpus/section-color/cold/read/page-002.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Untitled page - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/section-color/cold/read/page-002.png created
Binary files /dev/null and b/corpus/section-color/cold/read/page-002.png differ
corpus/section-color/cold/read/page-002.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{24D52AE5-5F97-0097-3AF6-922A51E28C12}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:44.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:29:01.000Z" objectID="{24D52AE5-5F97-0097-3AF6-922A51E28C12}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page>
corpus/section-color/cold/read/page-003.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Untitled page - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/section-color/cold/read/page-003.png created
Binary files /dev/null and b/corpus/section-color/cold/read/page-003.png differ
corpus/section-color/cold/read/page-003.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{3239575D-5857-0398-280F-710EAE4FDFB5}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:44.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:29:01.000Z" objectID="{3239575D-5857-0398-280F-710EAE4FDFB5}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page>
corpus/section-color/cold/read/page-004.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Untitled page - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/section-color/cold/read/page-004.png created
Binary files /dev/null and b/corpus/section-color/cold/read/page-004.png differ
corpus/section-color/cold/read/page-004.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{3E7714AB-FD3C-0D84-2BAF-44342145F507}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:47.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:29:01.000Z" objectID="{3E7714AB-FD3C-0D84-2BAF-44342145F507}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page>
corpus/section-color/cold/read/page-005.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Untitled page - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/section-color/cold/read/page-005.png created
Binary files /dev/null and b/corpus/section-color/cold/read/page-005.png differ
corpus/section-color/cold/read/page-005.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{791B4AA8-533F-050A-3BF6-A583EF3BAD0B}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:44.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:29:01.000Z" objectID="{791B4AA8-533F-050A-3BF6-A583EF3BAD0B}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page>
corpus/section-color/cold/read/page-006.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Untitled page - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/section-color/cold/read/page-006.png created
Binary files /dev/null and b/corpus/section-color/cold/read/page-006.png differ
corpus/section-color/cold/read/page-006.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{7C39C61C-A0F8-00D5-3893-8297F6742D60}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:48.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:29:01.000Z" objectID="{7C39C61C-A0F8-00D5-3893-8297F6742D60}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page>
corpus/section-color/cold/read/page-007.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Untitled page - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/section-color/cold/read/page-007.png created
Binary files /dev/null and b/corpus/section-color/cold/read/page-007.png differ
corpus/section-color/cold/read/page-007.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{81BDCDF3-EF9E-070A-2434-2A780B71D8F9}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:45.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:29:01.000Z" objectID="{81BDCDF3-EF9E-070A-2434-2A780B71D8F9}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page>
corpus/section-color/cold/read/page-008.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Untitled page - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/section-color/cold/read/page-008.png created
Binary files /dev/null and b/corpus/section-color/cold/read/page-008.png differ
corpus/section-color/cold/read/page-008.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{8E3AAB5C-B419-0B5F-16E0-1AF5A9438BD6}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:48.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:29:01.000Z" objectID="{8E3AAB5C-B419-0B5F-16E0-1AF5A9438BD6}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page>
corpus/section-color/cold/read/page-009.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Untitled page - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/section-color/cold/read/page-009.png created
Binary files /dev/null and b/corpus/section-color/cold/read/page-009.png differ
corpus/section-color/cold/read/page-009.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{90D5B5C6-8777-05E1-2EA4-C9948ED4A5BC}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:44.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:29:01.000Z" objectID="{90D5B5C6-8777-05E1-2EA4-C9948ED4A5BC}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page>
corpus/section-color/cold/read/page-010.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Untitled page - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/section-color/cold/read/page-010.png created
Binary files /dev/null and b/corpus/section-color/cold/read/page-010.png differ
corpus/section-color/cold/read/page-010.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{9D9C8963-166F-0589-3BAB-A943E8F9DC28}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:47.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:29:01.000Z" objectID="{9D9C8963-166F-0589-3BAB-A943E8F9DC28}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page>
corpus/section-color/cold/read/page-011.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Untitled page - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/section-color/cold/read/page-011.png created
Binary files /dev/null and b/corpus/section-color/cold/read/page-011.png differ
corpus/section-color/cold/read/page-011.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{A65346B6-179C-06CD-15B8-2CFDFAEB150F}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:02.000Z" lastModifiedTime="2026-10-01T03:35:49.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:02.000Z" lastModifiedTime="2026-10-01T03:29:02.000Z" objectID="{A65346B6-179C-06CD-15B8-2CFDFAEB150F}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page>
corpus/section-color/cold/read/page-012.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Untitled page - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/section-color/cold/read/page-012.png created
Binary files /dev/null and b/corpus/section-color/cold/read/page-012.png differ
corpus/section-color/cold/read/page-012.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{BB87BCFA-D952-06DA-0B0B-704A444F1847}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:02.000Z" lastModifiedTime="2026-10-01T03:35:50.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:02.000Z" lastModifiedTime="2026-10-01T03:29:02.000Z" objectID="{BB87BCFA-D952-06DA-0B0B-704A444F1847}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page>
corpus/section-color/cold/read/page-013.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Untitled page - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/section-color/cold/read/page-013.png created
Binary files /dev/null and b/corpus/section-color/cold/read/page-013.png differ
corpus/section-color/cold/read/page-013.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{BC9979E2-5D95-00B6-2C74-B7A77A233187}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:46.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:29:01.000Z" objectID="{BC9979E2-5D95-00B6-2C74-B7A77A233187}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page>
corpus/section-color/cold/read/page-014.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Untitled page - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/section-color/cold/read/page-014.png created
Binary files /dev/null and b/corpus/section-color/cold/read/page-014.png differ
corpus/section-color/cold/read/page-014.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{C1B5C6E3-76DA-0D31-1416-106902477F66}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:48.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:29:01.000Z" objectID="{C1B5C6E3-76DA-0D31-1416-106902477F66}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page>
corpus/section-color/cold/read/page-015.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Untitled page - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/section-color/cold/read/page-015.png created
Binary files /dev/null and b/corpus/section-color/cold/read/page-015.png differ
corpus/section-color/cold/read/page-015.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{DDB49CEE-9C38-04F0-1E0F-639616393CE1}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:48.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:29:01.000Z" objectID="{DDB49CEE-9C38-04F0-1E0F-639616393CE1}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page>
corpus/section-color/cold/read/page-016.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Untitled page - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/section-color/cold/read/page-016.png created
Binary files /dev/null and b/corpus/section-color/cold/read/page-016.png differ
corpus/section-color/cold/read/page-016.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{DEA8ADE8-2F81-051A-3649-CC8A836EC084}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:48.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:29:01.000Z" objectID="{DEA8ADE8-2F81-051A-3649-CC8A836EC084}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page>
corpus/section-color/cold/read/page-017.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Untitled page - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/section-color/cold/read/page-017.png created
Binary files /dev/null and b/corpus/section-color/cold/read/page-017.png differ
corpus/section-color/cold/read/page-017.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{E90EECF6-6E48-0632-1940-7E8970A58DC6}{1}{B0}" name="Untitled page" dateTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:35:45.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-10-01T03:29:01.000Z" lastModifiedTime="2026-10-01T03:29:01.000Z" objectID="{E90EECF6-6E48-0632-1940-7E8970A58DC6}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[]]></one:T></one:OE></one:Title></one:Page>
corpus/section-color/cold/read/payloads.json created+3
...@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/section-color/cold/run.json created+18
...@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/snowbound-properties/section-color/candidate",
3 "expected_pages": -1,
4 "author": null,
5 "author_timeout_seconds": 600,
6 "inspect": false,
7 "collect_notebook": false,
8 "base": {
9 "file": "win7-office-base.qcow2",
10 "format": "qcow2",
11 "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
12 "virtual_size": 68719476736
13 },
14 "scripts": {
15 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
16 "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
17 }
18}
corpus/section-color/cold/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/section-color/cold/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/section-color/cold/source.json created+116
...@@ -0,0 +1,116 @@
1[
2 {
3 "path": "Apple.one",
4 "bytes": 6352,
5 "sha256": "0bf6021d0904aa6e281574ce50db4f24d5bb94980140b444e21b9720e9d4c0d4",
6 "mtime_ns": 1790825341918454713
7 },
8 {
9 "path": "Blue Mist.one",
10 "bytes": 6352,
11 "sha256": "d9f3430fc9d4671a22a62a5575513df6cb710376d709e6bff47fab2598a7cc15",
12 "mtime_ns": 1790825341692411886
13 },
14 {
15 "path": "Blue.one",
16 "bytes": 6352,
17 "sha256": "10811cc79043e79e26a908c4974a6f8b8df923223a509d2673adbe0b9b060f1d",
18 "mtime_ns": 1790825341319295896
19 },
20 {
21 "path": "Cyan.one",
22 "bytes": 6352,
23 "sha256": "d51a7b08f6cda049ea8209174498d50283c7ec9dc0a63583b35029aeb0c4e578",
24 "mtime_ns": 1790825341540263286
25 },
26 {
27 "path": "Green.one",
28 "bytes": 6352,
29 "sha256": "470c4d19a7ca9d03c1243604bb8d4142feb5ca33c1e9244c3732f0275b02315d",
30 "mtime_ns": 1790825341404235629
31 },
32 {
33 "path": "Lemon.one",
34 "bytes": 6352,
35 "sha256": "a30ee07c1d1eaabd7d8ac2dd5ba93a12d62db8ca5602f11ef9cdaf2127461d45",
36 "mtime_ns": 1790825341865471198
37 },
38 {
39 "path": "Magenta.one",
40 "bytes": 6352,
41 "sha256": "d2fb77176b7b891f822e9b4e0a86071c811bcef4655e066c66aaf101661eead1",
42 "mtime_ns": 1790825341641456588
43 },
44 {
45 "path": "New Section 1.one",
46 "bytes": 6000,
47 "sha256": "8f9614276e8f8a60f681660b2015c0f2f59428eedef503087c5aea8874d7fc7b",
48 "mtime_ns": 1790825341261553569
49 },
50 {
51 "path": "None.one",
52 "bytes": 6352,
53 "sha256": "d61d1899f38e371b042d40641bc7bd1ba6eadf78b205bb20bcd1a9470e5eff53",
54 "mtime_ns": 1790825342154418500
55 },
56 {
57 "path": "Open Notebook.onetoc2",
58 "bytes": 14552,
59 "sha256": "61b231025ae93c0242bd59468e14f493ccb24c231c59fcb2fae28ad4c6efc8a6",
60 "mtime_ns": 1790825342179576107
61 },
62 {
63 "path": "Orange.one",
64 "bytes": 6352,
65 "sha256": "1635f1fb9340b69e5abe82ef00bc99df332d83708187f44f573f5d887630c374",
66 "mtime_ns": 1790825341589404452
67 },
68 {
69 "path": "Purple Mist.one",
70 "bytes": 6352,
71 "sha256": "aa06592b69ced1ddd935d625eba1c4e02fd084e9c032ba5976226cf7a59cbc21",
72 "mtime_ns": 1790825341748511081
73 },
74 {
75 "path": "Purple.one",
76 "bytes": 6352,
77 "sha256": "4ade0b3953c99a4ab8e29a7855370c9a3bf1b9e06c359fd2f523abf167dc8797",
78 "mtime_ns": 1790825341493283421
79 },
80 {
81 "path": "Red Chalk.one",
82 "bytes": 6352,
83 "sha256": "1e9c778c50a963c66ab375e1e8f67cff05e2a11d95a5a89abf0dfa09dcc6d05a",
84 "mtime_ns": 1790825342043389615
85 },
86 {
87 "path": "Red.one",
88 "bytes": 6352,
89 "sha256": "f17f0e4de9947c3b40567f8ba60a9ec3f127c1bb7dfdc55b778e7b08e1f3b60d",
90 "mtime_ns": 1790825341447219727
91 },
92 {
93 "path": "Silver.one",
94 "bytes": 6352,
95 "sha256": "aa854f8b8c8333ae2d274d9a17333bb3a5c7c62bb35e69da1640385df3904101",
96 "mtime_ns": 1790825342101432402
97 },
98 {
99 "path": "Tan.one",
100 "bytes": 6352,
101 "sha256": "236d18dff5679e7bf10c108c44d18ab8df1d01cf860c2bd94b0d74fdb0c81015",
102 "mtime_ns": 1790825341806399157
103 },
104 {
105 "path": "Teal.one",
106 "bytes": 6352,
107 "sha256": "fbf08e85eee0b224bb76b82e3541fee9abe033bc04dd7a6abb09a8c0ae774e5f",
108 "mtime_ns": 1790825341979577971
109 },
110 {
111 "path": "Yellow.one",
112 "bytes": 6352,
113 "sha256": "a30a5231b8ec1a0d603c325694f538e432e6095d601dbcce002c2e663b42f3f7",
114 "mtime_ns": 1790825341361229906
115 }
116]
corpus/section-color/cold/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
corpus/section-color/native/after.onetoc2 created
Binary files /dev/null and b/corpus/section-color/native/after.onetoc2 differ
corpus/section-color/native/before.onetoc2 created
Binary files /dev/null and b/corpus/section-color/native/before.onetoc2 differ
corpus/section-color/native/colors.txt created+17
...@@ -0,0 +1,17 @@
1Blue #8AA8E4
2Yellow #FFD869
3Green #B7C997
4Red #EE9597
5Purple #B49EDE
6Cyan #91BAAE
7Orange #F6B078
8Magenta #D5A4BB
9Blue Mist #9BBBD2
10Purple Mist #AB9CB7
11Tan #E8D199
12Lemon #F5F96F
13Apple #ADE792
14Teal #4DBCCA
15Red Chalk #BA7575
16Silver #9595AA
17None none
corpus/section-color/native/none.one created
Binary files /dev/null and b/corpus/section-color/native/none.one differ
corpus/section-color/native/notebook-colors.png created
Binary files /dev/null and b/corpus/section-color/native/notebook-colors.png differ
corpus/section-color/native/notebook-menu.png created
Binary files /dev/null and b/corpus/section-color/native/notebook-menu.png differ
corpus/section-color/native/notebook-properties.png created
Binary files /dev/null and b/corpus/section-color/native/notebook-properties.png differ
corpus/section-color/native/section-color-menu.png created
Binary files /dev/null and b/corpus/section-color/native/section-color-menu.png differ
corpus/section-color/native/section-menu.png created
Binary files /dev/null and b/corpus/section-color/native/section-menu.png differ
corpus/section-color/native/teal.one created
Binary files /dev/null and b/corpus/section-color/native/teal.one differ
corpus/styles/README.md created+27
...@@ -0,0 +1,27 @@
1# Styles and themes
2
3`onenote/` is OneNote 2010 applying its own Styles gallery in the lab (2026-09-30):
4`Styles.one` holds a page with each of the eleven styles applied (Ctrl+Alt+1 to 6 and the
5gallery), Enter after each, a style over explicit formatting, Clear Formatting, and a second
6page whose styles were made through COM in Georgia, onto which OneNote added its own `h1`
7and into which a pasted Georgia heading brought its own; `applied.xml` is the first page's
8COM read. Applying a style stores a paragraph style object per style per page and clears
9the paragraph's explicit character formatting; headings carry NextStyle `p`.
10
11`candidate/` is the notebook `themed_pages_hold_their_theme_under_onenote_s_names` in
12`crates/snowbound/src/themes.rs` writes: a section per built-in theme (OneNote, Manuscript,
13Editorial, Modern), each a page with a paragraph in every gallery style applied through the
14editor, then restyled as opening the page does, and `.snowbound/themes.json` giving each
15section its theme. `cold/` is `tools/native_styles.py` in a fresh OneNote 2010 clone: every
16page draws in its theme (`read/page-00N.png`) and lists the eleven styles under OneNote's
17names with the theme's values, and no `.snowbound` section group appears.
18
19`onenote-edit/` is OneNote 2010 editing the Manuscript page of `candidate/` in the lab:
20Ctrl+Alt+2 on the Normal paragraph and Enter after it, and Enter after the theme's Heading
215. OneNote applies its own `h2` and `p` beside the theme's (`Manuscript.png`); reopened in
22Snowbound they read under those names and restyling heals them
23(`styles_onenote_adds_to_a_themed_page_are_healed`).
24
25`tools/test_styles.py` checks this without a VM. Regenerate with `SNOWBOUND_STYLES_EXPORT`
26set to a new absolute directory while running the test, then `tools/native_styles.py OUTPUT
27COLD`.
corpus/styles/candidate/.snowbound/themes.json created+33
...@@ -0,0 +1,33 @@
1{
2 "themes": [],
3 "assignments": [
4 {
5 "scope": {
6 "section": "125e8f01c6ee404b96cc675d00ceb659"
7 },
8 "theme": "onenote",
9 "assigned": 134000000000000000
10 },
11 {
12 "scope": {
13 "section": "af4f8ea23c5f7e41838ac7cbea473590"
14 },
15 "theme": "manuscript",
16 "assigned": 134000000000000000
17 },
18 {
19 "scope": {
20 "section": "18af2996b565454c971191d887028fc8"
21 },
22 "theme": "editorial",
23 "assigned": 134000000000000000
24 },
25 {
26 "scope": {
27 "section": "f0ca724b3a9d3d4b82bc6f28bd77966c"
28 },
29 "theme": "modern",
30 "assigned": 134000000000000000
31 }
32 ]
33}
\ No newline at end of file
corpus/styles/candidate/Editorial.one created
Binary files /dev/null and b/corpus/styles/candidate/Editorial.one differ
corpus/styles/candidate/Manuscript.one created
Binary files /dev/null and b/corpus/styles/candidate/Manuscript.one differ
corpus/styles/candidate/Modern.one created
Binary files /dev/null and b/corpus/styles/candidate/Modern.one differ
corpus/styles/candidate/OneNote.one created
Binary files /dev/null and b/corpus/styles/candidate/OneNote.one differ
corpus/styles/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/styles/candidate/Open Notebook.onetoc2 differ
corpus/styles/cold/commands.jsonl created+4
...@@ -0,0 +1,4 @@
1{"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}
2{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\author.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-222E5D78", "exit": 0, "stdout": "", "stderr": "", "error": null}
3{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-222E5D78 -ExpectedPages 4 -UseCurrentCache -KeepOpen", "exit": 0, "stdout": "Read 4 sections and 4 pages.\r\n", "stderr": "", "error": null}
4{"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}
corpus/styles/cold/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-222e5d78", "hostname": "ONE-M6-222E5D78"}
corpus/styles/cold/notebook/Editorial.one created
Binary files /dev/null and b/corpus/styles/cold/notebook/Editorial.one differ
corpus/styles/cold/notebook/Manuscript.one created
Binary files /dev/null and b/corpus/styles/cold/notebook/Manuscript.one differ
corpus/styles/cold/notebook/Modern.one created
Binary files /dev/null and b/corpus/styles/cold/notebook/Modern.one differ
corpus/styles/cold/notebook/OneNote.one created
Binary files /dev/null and b/corpus/styles/cold/notebook/OneNote.one differ
corpus/styles/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/styles/cold/notebook/Open Notebook.onetoc2 differ
corpus/styles/cold/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-222E5D78",
5 "cold": false,
6 "onenote": "14.0.4763.1000"
7}
corpus/styles/cold/read/hierarchy.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{271A6096-75F2-4406-AB00-6825469A9737}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-10-01T02:33:10.000Z" color="#B49EDE"><one:Section name="OneNote" ID="{E3C86EF0-C031-09EE-1345-96A18B2CCE8A}{1}{B0}" path="C:\one-tests\runs\capture\notebook\OneNote.one" lastModifiedTime="2026-10-01T02:33:10.000Z" color="#8AA8E4"><one:Page ID="{20A092AA-72F9-0EF1-1B68-BAD626A1C2D1}{1}{B0}" name="Heading 1 in OneNote" dateTime="2026-10-01T02:30:16.000Z" lastModifiedTime="2026-10-01T02:33:10.000Z" pageLevel="1"/></one:Section><one:Section name="Manuscript" ID="{93F9DCCA-6F22-0CBF-3CA8-63D0B1903566}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Manuscript.one" lastModifiedTime="2026-10-01T02:33:10.000Z" color="#8AA8E4"><one:Page ID="{1B38B1D2-2CD4-06B9-39CE-B67CE50AF7B2}{1}{B0}" name="Heading 1 in Manuscript" dateTime="2026-10-01T02:30:16.000Z" lastModifiedTime="2026-10-01T02:33:10.000Z" pageLevel="1"/></one:Section><one:Section name="Editorial" ID="{37BA4275-BBA9-09E8-1F3F-655C55899D0A}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Editorial.one" lastModifiedTime="2026-10-01T02:33:10.000Z" color="#8AA8E4"><one:Page ID="{7DD8EF42-6F86-057F-01F3-CC4506AB6443}{1}{B0}" name="Heading 1 in Editorial" dateTime="2026-10-01T02:30:16.000Z" lastModifiedTime="2026-10-01T02:33:10.000Z" pageLevel="1"/></one:Section><one:Section name="Modern" ID="{349DBF5A-9B2C-06CE-32B2-EEF937D83CD2}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Modern.one" lastModifiedTime="2026-10-01T02:33:10.000Z" color="#8AA8E4"><one:Page ID="{9A9B6AA5-46CF-05FF-3E17-6A76C011C454}{1}{B0}" name="Heading 1 in Modern" dateTime="2026-10-01T02:30:16.000Z" lastModifiedTime="2026-10-01T02:33:10.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/styles/cold/read/page-000.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Heading 1 in Manuscript - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/styles/cold/read/page-000.png created
Binary files /dev/null and b/corpus/styles/cold/read/page-000.png differ
corpus/styles/cold/read/page-000.xml created+7
...@@ -0,0 +1,7 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{1B38B1D2-2CD4-06B9-39CE-B67CE50AF7B2}{1}{B0}" name="Heading 1 in Manuscript" dateTime="2026-10-01T02:30:16.000Z" lastModifiedTime="2026-10-01T02:33:10.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="h1" fontColor="#3B2F2A" highlightColor="automatic" font="Georgia" fontSize="20.0" spaceBefore="0.333333343267441" spaceAfter="0.111111111938953"/><one:QuickStyleDef index="1" name="h2" fontColor="#3B2F2A" highlightColor="automatic" font="Georgia" fontSize="16.0" bold="true" spaceBefore="0.277777791023254" spaceAfter="0.0555555559694767"/><one:QuickStyleDef index="2" name="h3" fontColor="#3B2F2A" highlightColor="automatic" font="Georgia" fontSize="13.0" bold="true" spaceBefore="0.222222223877907" spaceAfter="0.0555555559694767"/><one:QuickStyleDef index="3" name="h4" fontColor="#3B2F2A" highlightColor="automatic" font="Georgia" fontSize="12.0" bold="true" italic="true" spaceBefore="0.16666667163372" spaceAfter="0.0"/><one:QuickStyleDef index="4" name="h5" fontColor="#3B2F2A" highlightColor="automatic" font="Georgia" fontSize="12.0" spaceBefore="0.16666667163372" spaceAfter="0.0"/><one:QuickStyleDef index="5" name="h6" fontColor="#3B2F2A" highlightColor="automatic" font="Georgia" fontSize="12.0" italic="true" spaceBefore="0.16666667163372" spaceAfter="0.0"/><one:QuickStyleDef index="6" name="PageTitle" fontColor="#1F1A17" highlightColor="automatic" font="Georgia" fontSize="26.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="7" name="cite" fontColor="#7A6E66" highlightColor="automatic" font="Georgia" fontSize="9.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="8" name="blockquote" fontColor="#6B5E55" highlightColor="automatic" font="Georgia" fontSize="12.0" italic="true" spaceBefore="0.0" spaceAfter="0.111111111938953"/><one:QuickStyleDef index="9" name="code" fontColor="#2B2B2B" highlightColor="automatic" font="Courier New" fontSize="10.5" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="10" name="p" fontColor="#2B2B2B" highlightColor="automatic" font="Georgia" fontSize="12.0" spaceBefore="0.0" spaceAfter="0.111111111938953"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{93F9DCCA-6F22-0CBF-3CA8-63D0B1903566}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="321.6620483398437" height="215.5119476318359"/><one:OEChildren><one:OE creationTime="2026-10-01T02:30:16.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{93F9DCCA-6F22-0CBF-3CA8-63D0B1903566}{24}{B0}" alignment="left" spaceBefore="12.0" spaceAfter="4.0" quickStyleIndex="0"><one:T><![CDATA[Heading 1 in Manuscript]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{3A13956E-4E22-0BA4-32E9-AFBC2547266F}{44}{B0}" alignment="left" spaceBefore="10.0" spaceAfter="2.0" quickStyleIndex="1"><one:T><![CDATA[<span
3style='font-weight:bold'>Heading 2 in Manuscript</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{3A13956E-4E22-0BA4-32E9-AFBC2547266F}{48}{B0}" alignment="left" spaceBefore="8.0" spaceAfter="2.0" quickStyleIndex="2"><one:T><![CDATA[<span
4style='font-weight:bold'>Heading 3 in Manuscript</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{3A13956E-4E22-0BA4-32E9-AFBC2547266F}{51}{B0}" alignment="left" spaceBefore="6.0" quickStyleIndex="3"><one:T><![CDATA[<span
5style='font-weight:bold;font-style:italic'>Heading 4 in Manuscript</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{3A13956E-4E22-0BA4-32E9-AFBC2547266F}{54}{B0}" alignment="left" spaceBefore="6.0" quickStyleIndex="4"><one:T><![CDATA[Heading 5 in Manuscript]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{3A13956E-4E22-0BA4-32E9-AFBC2547266F}{57}{B0}" alignment="left" spaceBefore="6.0" quickStyleIndex="5"><one:T><![CDATA[<span
6style='font-style:italic'>Heading 6 in Manuscript</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{3A13956E-4E22-0BA4-32E9-AFBC2547266F}{60}{B0}" alignment="left" quickStyleIndex="6"><one:T><![CDATA[Page Title in Manuscript]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{3A13956E-4E22-0BA4-32E9-AFBC2547266F}{63}{B0}" alignment="left" quickStyleIndex="7"><one:T><![CDATA[Citation in Manuscript]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{3A13956E-4E22-0BA4-32E9-AFBC2547266F}{66}{B0}" alignment="left" spaceAfter="4.0" quickStyleIndex="8"><one:T><![CDATA[<span
7style='font-style:italic'>Quote in Manuscript</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{3A13956E-4E22-0BA4-32E9-AFBC2547266F}{69}{B0}" alignment="left" quickStyleIndex="9"><one:T><![CDATA[Code in Manuscript]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{3A13956E-4E22-0BA4-32E9-AFBC2547266F}{72}{B0}" alignment="left" spaceAfter="4.0" quickStyleIndex="10"><one:T><![CDATA[Normal in Manuscript]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/styles/cold/read/page-001.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Heading 1 in OneNote - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/styles/cold/read/page-001.png created
Binary files /dev/null and b/corpus/styles/cold/read/page-001.png differ
corpus/styles/cold/read/page-001.xml created+8
...@@ -0,0 +1,8 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{20A092AA-72F9-0EF1-1B68-BAD626A1C2D1}{1}{B0}" name="Heading 1 in OneNote" dateTime="2026-10-01T02:30:16.000Z" lastModifiedTime="2026-10-01T02:33:10.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="h1" fontColor="#17365D" highlightColor="automatic" font="Calibri" fontSize="16.0" bold="true" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="1" name="h2" fontColor="#366092" highlightColor="automatic" font="Calibri" fontSize="13.0" bold="true" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="2" name="h3" fontColor="#366092" highlightColor="automatic" font="Calibri" fontSize="11.0" bold="true" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="3" name="h4" fontColor="#366092" highlightColor="automatic" font="Calibri" fontSize="11.0" bold="true" italic="true" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="4" name="h5" fontColor="#366092" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="5" name="h6" fontColor="#366092" highlightColor="automatic" font="Calibri" fontSize="11.0" italic="true" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="6" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="7" name="cite" fontColor="#595959" highlightColor="automatic" font="Calibri" fontSize="9.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="8" name="blockquote" fontColor="#595959" highlightColor="automatic" font="Calibri" fontSize="11.0" italic="true" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="9" name="code" fontColor="automatic" highlightColor="automatic" font="Courier New" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="10" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{E3C86EF0-C031-09EE-1345-96A18B2CCE8A}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="190.4754333496094" height="160.1658935546875"/><one:OEChildren><one:OE creationTime="2026-10-01T02:30:16.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{E3C86EF0-C031-09EE-1345-96A18B2CCE8A}{24}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[<span
3style='font-weight:bold'>Heading 1 in OneNote</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{D6883BEE-0C5A-0E00-3B42-7868532FE0E5}{2}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[<span
4style='font-weight:bold'>Heading 2 in OneNote</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{D6883BEE-0C5A-0E00-3B42-7868532FE0E5}{6}{B0}" alignment="left" quickStyleIndex="2"><one:T><![CDATA[<span
5style='font-weight:bold'>Heading 3 in OneNote</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{D6883BEE-0C5A-0E00-3B42-7868532FE0E5}{9}{B0}" alignment="left" quickStyleIndex="3"><one:T><![CDATA[<span
6style='font-weight:bold;font-style:italic'>Heading 4 in OneNote</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{D6883BEE-0C5A-0E00-3B42-7868532FE0E5}{12}{B0}" alignment="left" quickStyleIndex="4"><one:T><![CDATA[Heading 5 in OneNote]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{D6883BEE-0C5A-0E00-3B42-7868532FE0E5}{15}{B0}" alignment="left" quickStyleIndex="5"><one:T><![CDATA[<span
7style='font-style:italic'>Heading 6 in OneNote</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{D6883BEE-0C5A-0E00-3B42-7868532FE0E5}{18}{B0}" alignment="left" quickStyleIndex="6"><one:T><![CDATA[Page Title in OneNote]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{D6883BEE-0C5A-0E00-3B42-7868532FE0E5}{21}{B0}" alignment="left" quickStyleIndex="7"><one:T><![CDATA[Citation in OneNote]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{D6883BEE-0C5A-0E00-3B42-7868532FE0E5}{24}{B0}" alignment="left" quickStyleIndex="8"><one:T><![CDATA[<span
8style='font-style:italic'>Quote in OneNote</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{D6883BEE-0C5A-0E00-3B42-7868532FE0E5}{27}{B0}" alignment="left" quickStyleIndex="9"><one:T><![CDATA[Code in OneNote]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{D6883BEE-0C5A-0E00-3B42-7868532FE0E5}{30}{B0}" alignment="left" quickStyleIndex="10"><one:T><![CDATA[Normal in OneNote]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/styles/cold/read/page-002.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Heading 1 in Editorial - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/styles/cold/read/page-002.png created
Binary files /dev/null and b/corpus/styles/cold/read/page-002.png differ
corpus/styles/cold/read/page-002.xml created+8
...@@ -0,0 +1,8 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{7DD8EF42-6F86-057F-01F3-CC4506AB6443}{1}{B0}" name="Heading 1 in Editorial" dateTime="2026-10-01T02:30:16.000Z" lastModifiedTime="2026-10-01T02:33:10.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="h1" fontColor="#9A3B1F" highlightColor="automatic" font="Georgia" fontSize="18.0" bold="true" spaceBefore="0.333333343267441" spaceAfter="0.0555555559694767"/><one:QuickStyleDef index="1" name="h2" fontColor="#202020" highlightColor="automatic" font="Georgia" fontSize="14.0" bold="true" spaceBefore="0.277777791023254" spaceAfter="0.0555555559694767"/><one:QuickStyleDef index="2" name="h3" fontColor="#202020" highlightColor="automatic" font="Georgia" fontSize="12.0" bold="true" spaceBefore="0.222222223877907" spaceAfter="0.0"/><one:QuickStyleDef index="3" name="h4" fontColor="#202020" highlightColor="automatic" font="Georgia" fontSize="11.0" bold="true" italic="true" spaceBefore="0.16666667163372" spaceAfter="0.0"/><one:QuickStyleDef index="4" name="h5" fontColor="#9A3B1F" highlightColor="automatic" font="Georgia" fontSize="11.0" spaceBefore="0.16666667163372" spaceAfter="0.0"/><one:QuickStyleDef index="5" name="h6" fontColor="#9A3B1F" highlightColor="automatic" font="Georgia" fontSize="11.0" italic="true" spaceBefore="0.16666667163372" spaceAfter="0.0"/><one:QuickStyleDef index="6" name="PageTitle" fontColor="#202020" highlightColor="automatic" font="Georgia" fontSize="24.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="7" name="cite" fontColor="#707070" highlightColor="automatic" font="Calibri" fontSize="9.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="8" name="blockquote" fontColor="#555555" highlightColor="automatic" font="Georgia" fontSize="12.0" italic="true" spaceBefore="0.0" spaceAfter="0.0833333358168602"/><one:QuickStyleDef index="9" name="code" fontColor="#262626" highlightColor="automatic" font="Courier New" fontSize="10.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="10" name="p" fontColor="#262626" highlightColor="automatic" font="Calibri" fontSize="11.5" spaceBefore="0.0" spaceAfter="0.0833333358168602"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{37BA4275-BBA9-09E8-1F3F-655C55899D0A}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="271.0546264648437" height="203.7465362548828"/><one:OEChildren><one:OE creationTime="2026-10-01T02:30:16.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{37BA4275-BBA9-09E8-1F3F-655C55899D0A}{24}{B0}" alignment="left" spaceBefore="12.0" spaceAfter="2.0" quickStyleIndex="0"><one:T><![CDATA[<span
3style='font-weight:bold'>Heading 1 in Editorial</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{335B5180-0F87-0634-03C5-159AE450CA99}{86}{B0}" alignment="left" spaceBefore="10.0" spaceAfter="2.0" quickStyleIndex="1"><one:T><![CDATA[<span
4style='font-weight:bold'>Heading 2 in Editorial</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{335B5180-0F87-0634-03C5-159AE450CA99}{90}{B0}" alignment="left" spaceBefore="8.0" quickStyleIndex="2"><one:T><![CDATA[<span
5style='font-weight:bold'>Heading 3 in Editorial</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{335B5180-0F87-0634-03C5-159AE450CA99}{93}{B0}" alignment="left" spaceBefore="6.0" quickStyleIndex="3"><one:T><![CDATA[<span
6style='font-weight:bold;font-style:italic'>Heading 4 in Editorial</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{335B5180-0F87-0634-03C5-159AE450CA99}{96}{B0}" alignment="left" spaceBefore="6.0" quickStyleIndex="4"><one:T><![CDATA[Heading 5 in Editorial]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{335B5180-0F87-0634-03C5-159AE450CA99}{99}{B0}" alignment="left" spaceBefore="6.0" quickStyleIndex="5"><one:T><![CDATA[<span
7style='font-style:italic'>Heading 6 in Editorial</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{335B5180-0F87-0634-03C5-159AE450CA99}{102}{B0}" alignment="left" quickStyleIndex="6"><one:T><![CDATA[Page Title in Editorial]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{335B5180-0F87-0634-03C5-159AE450CA99}{105}{B0}" alignment="left" quickStyleIndex="7"><one:T><![CDATA[Citation in Editorial]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{335B5180-0F87-0634-03C5-159AE450CA99}{108}{B0}" alignment="left" spaceAfter="3.0" quickStyleIndex="8"><one:T><![CDATA[<span
8style='font-style:italic'>Quote in Editorial</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{335B5180-0F87-0634-03C5-159AE450CA99}{111}{B0}" alignment="left" quickStyleIndex="9"><one:T><![CDATA[Code in Editorial]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{335B5180-0F87-0634-03C5-159AE450CA99}{114}{B0}" alignment="left" spaceAfter="3.0" quickStyleIndex="10"><one:T><![CDATA[Normal in Editorial]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/styles/cold/read/page-003.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Heading 1 in Modern - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/styles/cold/read/page-003.png created
Binary files /dev/null and b/corpus/styles/cold/read/page-003.png differ
corpus/styles/cold/read/page-003.xml created+10
...@@ -0,0 +1,10 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{9A9B6AA5-46CF-05FF-3E17-6A76C011C454}{1}{B0}" name="Heading 1 in Modern" dateTime="2026-10-01T02:30:16.000Z" lastModifiedTime="2026-10-01T02:33:10.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="h1" fontColor="#0E6E6E" highlightColor="automatic" font="Arial" fontSize="16.0" bold="true" spaceBefore="0.277777791023254" spaceAfter="0.0555555559694767"/><one:QuickStyleDef index="1" name="h2" fontColor="#1B2631" highlightColor="automatic" font="Arial" fontSize="13.0" bold="true" spaceBefore="0.222222223877907" spaceAfter="0.0555555559694767"/><one:QuickStyleDef index="2" name="h3" fontColor="#1B2631" highlightColor="automatic" font="Arial" fontSize="11.0" bold="true" spaceBefore="0.16666667163372" spaceAfter="0.0"/><one:QuickStyleDef index="3" name="h4" fontColor="#1B2631" highlightColor="automatic" font="Arial" fontSize="10.5" bold="true" italic="true" spaceBefore="0.16666667163372" spaceAfter="0.0"/><one:QuickStyleDef index="4" name="h5" fontColor="#0E6E6E" highlightColor="automatic" font="Arial" fontSize="10.5" bold="true" spaceBefore="0.16666667163372" spaceAfter="0.0"/><one:QuickStyleDef index="5" name="h6" fontColor="#0E6E6E" highlightColor="automatic" font="Arial" fontSize="10.5" italic="true" spaceBefore="0.16666667163372" spaceAfter="0.0"/><one:QuickStyleDef index="6" name="PageTitle" fontColor="#1B2631" highlightColor="automatic" font="Arial" fontSize="22.0" bold="true" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="7" name="cite" fontColor="#7B868C" highlightColor="automatic" font="Arial" fontSize="8.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="8" name="blockquote" fontColor="#5F6B73" highlightColor="automatic" font="Arial" fontSize="10.5" italic="true" spaceBefore="0.0" spaceAfter="0.0833333358168602"/><one:QuickStyleDef index="9" name="code" fontColor="#1B2631" highlightColor="automatic" font="Courier New" fontSize="10.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="10" name="p" fontColor="#2E2E2E" highlightColor="automatic" font="Arial" fontSize="10.5" spaceBefore="0.0" spaceAfter="0.0833333358168602"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{349DBF5A-9B2C-06CE-32B2-EEF937D83CD2}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="254.8837738037109" height="183.18359375"/><one:OEChildren><one:OE creationTime="2026-10-01T02:30:16.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{349DBF5A-9B2C-06CE-32B2-EEF937D83CD2}{24}{B0}" alignment="left" spaceBefore="10.0" spaceAfter="2.0" quickStyleIndex="0"><one:T><![CDATA[<span
3style='font-weight:bold'>Heading 1 in Modern</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{20F69636-7FD9-0F77-1647-12215AF3AB25}{128}{B0}" alignment="left" spaceBefore="8.0" spaceAfter="2.0" quickStyleIndex="1"><one:T><![CDATA[<span
4style='font-weight:bold'>Heading 2 in Modern</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{20F69636-7FD9-0F77-1647-12215AF3AB25}{132}{B0}" alignment="left" spaceBefore="6.0" quickStyleIndex="2"><one:T><![CDATA[<span
5style='font-weight:bold'>Heading 3 in Modern</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{20F69636-7FD9-0F77-1647-12215AF3AB25}{135}{B0}" alignment="left" spaceBefore="6.0" quickStyleIndex="3"><one:T><![CDATA[<span
6style='font-weight:bold;font-style:italic'>Heading 4 in Modern</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{20F69636-7FD9-0F77-1647-12215AF3AB25}{138}{B0}" alignment="left" spaceBefore="6.0" quickStyleIndex="4"><one:T><![CDATA[<span
7style='font-weight:bold'>Heading 5 in Modern</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{20F69636-7FD9-0F77-1647-12215AF3AB25}{141}{B0}" alignment="left" spaceBefore="6.0" quickStyleIndex="5"><one:T><![CDATA[<span
8style='font-style:italic'>Heading 6 in Modern</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{20F69636-7FD9-0F77-1647-12215AF3AB25}{144}{B0}" alignment="left" quickStyleIndex="6"><one:T><![CDATA[<span
9style='font-weight:bold'>Page Title in Modern</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{20F69636-7FD9-0F77-1647-12215AF3AB25}{147}{B0}" alignment="left" quickStyleIndex="7"><one:T><![CDATA[Citation in Modern]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{20F69636-7FD9-0F77-1647-12215AF3AB25}{150}{B0}" alignment="left" spaceAfter="3.0" quickStyleIndex="8"><one:T><![CDATA[<span
10style='font-style:italic'>Quote in Modern</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{20F69636-7FD9-0F77-1647-12215AF3AB25}{153}{B0}" alignment="left" quickStyleIndex="9"><one:T><![CDATA[Code in Modern]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{20F69636-7FD9-0F77-1647-12215AF3AB25}{156}{B0}" alignment="left" spaceAfter="3.0" quickStyleIndex="10"><one:T><![CDATA[Normal in Modern]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/styles/cold/read/payloads.json created+3
...@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/styles/cold/run.json created+19
...@@ -0,0 +1,19 @@
1{
2 "notebook": "/private/tmp/snowbound-styles/candidate",
3 "expected_pages": 4,
4 "author": "/Users/clo/dev/one/tools/native/styles.ps1",
5 "author_timeout_seconds": 600,
6 "inspect": false,
7 "collect_notebook": true,
8 "base": {
9 "file": "win7-office-base.qcow2",
10 "format": "qcow2",
11 "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
12 "virtual_size": 68719476736
13 },
14 "scripts": {
15 "author.ps1": "fee6de8f8db4c294d9b39f5cf6428fa6031b52b9158470a3f0aeaf82ef64bfa8",
16 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
17 "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
18 }
19}
corpus/styles/cold/scripts/author.ps1 created+7
...@@ -0,0 +1,7 @@
1param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost)
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost
5# Snowbound makes its folder hidden, which the transfer's archive does not keep.
6$sidecar = Get-Item -LiteralPath (Join-Path $Root 'notebook\.snowbound') -Force
7$sidecar.Attributes = $sidecar.Attributes -bor [IO.FileAttributes]::Hidden
corpus/styles/cold/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/styles/cold/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/styles/cold/source.json created+38
...@@ -0,0 +1,38 @@
1[
2 {
3 "path": ".snowbound/themes.json",
4 "bytes": 669,
5 "sha256": "0ede06c023cbdfa06fe307225baeb461f5a70e6ce368ae69dec9d405ef5822ac",
6 "mtime_ns": 1790821816933321351
7 },
8 {
9 "path": "Editorial.one",
10 "bytes": 12096,
11 "sha256": "0f65b622cdbeee22b146a6259074aeb4fd2b398d2bf982c7d712db31d190b6d1",
12 "mtime_ns": 1790821816932162894
13 },
14 {
15 "path": "Manuscript.one",
16 "bytes": 12120,
17 "sha256": "109d595430cf434fabcab11bfe233a34b5182a4b48f8cc5146885ee22936f636",
18 "mtime_ns": 1790821816931895020
19 },
20 {
21 "path": "Modern.one",
22 "bytes": 12000,
23 "sha256": "9ea5680d90a9b44bd036d4d50f5bcaae5b8f8ac505af4623e0ac3d0488b09ce2",
24 "mtime_ns": 1790821816932649894
25 },
26 {
27 "path": "OneNote.one",
28 "bytes": 12072,
29 "sha256": "5b019d8246db5a2728de5502d30b2a1bfe7a0a5d376c1a5d15ce98ac551ed210",
30 "mtime_ns": 1790821816932870935
31 },
32 {
33 "path": "Open Notebook.onetoc2",
34 "bytes": 2036,
35 "sha256": "9f613cf0b5db95b8809fe7d325cae9c37e8b9ae57fa3fff3b7d669649f1b416c",
36 "mtime_ns": 1790821816932392186
37 }
38]
corpus/styles/cold/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
corpus/styles/onenote-edit/Manuscript.one created
Binary files /dev/null and b/corpus/styles/onenote-edit/Manuscript.one differ
corpus/styles/onenote-edit/Manuscript.png created
Binary files /dev/null and b/corpus/styles/onenote-edit/Manuscript.png differ
corpus/styles/onenote-edit/Manuscript.xml created+8
...@@ -0,0 +1,8 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{C9EC67ED-F2EC-0F39-02F7-55375077357D}{1}{B0}" name="Heading 1 in Manuscript" dateTime="2026-10-01T02:30:16.000Z" lastModifiedTime="2026-10-01T02:31:35.000Z" pageLevel="1" isCurrentlyViewed="true" lang="en-US"><one:QuickStyleDef index="0" name="h1" fontColor="#3B2F2A" highlightColor="automatic" font="Georgia" fontSize="20.0" spaceBefore="0.333333343267441" spaceAfter="0.111111111938953"/><one:QuickStyleDef index="1" name="h2" fontColor="#3B2F2A" highlightColor="automatic" font="Georgia" fontSize="16.0" bold="true" spaceBefore="0.277777791023254" spaceAfter="0.0555555559694767"/><one:QuickStyleDef index="2" name="h3" fontColor="#3B2F2A" highlightColor="automatic" font="Georgia" fontSize="13.0" bold="true" spaceBefore="0.222222223877907" spaceAfter="0.0555555559694767"/><one:QuickStyleDef index="3" name="h4" fontColor="#3B2F2A" highlightColor="automatic" font="Georgia" fontSize="12.0" bold="true" italic="true" spaceBefore="0.16666667163372" spaceAfter="0.0"/><one:QuickStyleDef index="4" name="h5" fontColor="#3B2F2A" highlightColor="automatic" font="Georgia" fontSize="12.0" spaceBefore="0.16666667163372" spaceAfter="0.0"/><one:QuickStyleDef index="5" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="6" name="h6" fontColor="#3B2F2A" highlightColor="automatic" font="Georgia" fontSize="12.0" italic="true" spaceBefore="0.16666667163372" spaceAfter="0.0"/><one:QuickStyleDef index="7" name="PageTitle" fontColor="#1F1A17" highlightColor="automatic" font="Georgia" fontSize="26.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="8" name="cite" fontColor="#7A6E66" highlightColor="automatic" font="Georgia" fontSize="9.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="9" name="blockquote" fontColor="#6B5E55" highlightColor="automatic" font="Georgia" fontSize="12.0" italic="true" spaceBefore="0.0" spaceAfter="0.111111111938953"/><one:QuickStyleDef index="10" name="code" fontColor="#2B2B2B" highlightColor="automatic" font="Courier New" fontSize="10.5" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="11" name="h2" fontColor="#366092" highlightColor="automatic" font="Calibri" fontSize="13.0" bold="true" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" lastModifiedTime="2026-10-01T02:31:35.000Z" objectID="{412D0AF5-B11A-053F-0791-809B04EDF7A9}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="321.6620483398437" height="244.6016998291016"/><one:OEChildren><one:OE creationTime="2026-10-01T02:30:16.000Z" lastModifiedTime="2026-10-01T02:31:15.000Z" objectID="{412D0AF5-B11A-053F-0791-809B04EDF7A9}{24}{B0}" alignment="left" spaceBefore="12.0" spaceAfter="4.0" quickStyleIndex="0"><one:T><![CDATA[Heading 1 in Manuscript]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2026-10-01T02:31:15.000Z" objectID="{E8C74351-901A-0224-09D0-4CF7903AE4A0}{44}{B0}" alignment="left" spaceBefore="10.0" spaceAfter="2.0" quickStyleIndex="1"><one:T><![CDATA[<span
3style='font-weight:bold'>Heading 2 in Manuscript</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2026-10-01T02:31:15.000Z" objectID="{E8C74351-901A-0224-09D0-4CF7903AE4A0}{48}{B0}" alignment="left" spaceBefore="8.0" spaceAfter="2.0" quickStyleIndex="2"><one:T><![CDATA[<span
4style='font-weight:bold'>Heading 3 in Manuscript</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2026-10-01T02:31:15.000Z" objectID="{E8C74351-901A-0224-09D0-4CF7903AE4A0}{51}{B0}" alignment="left" spaceBefore="6.0" quickStyleIndex="3"><one:T><![CDATA[<span
5style='font-weight:bold;font-style:italic'>Heading 4 in Manuscript</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2026-10-01T02:31:15.000Z" objectID="{E8C74351-901A-0224-09D0-4CF7903AE4A0}{54}{B0}" alignment="left" spaceBefore="6.0" quickStyleIndex="4"><one:T><![CDATA[Heading 5 in Manuscript]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-10-01T02:31:35.000Z" lastModifiedTime="2026-10-01T02:31:35.000Z" objectID="{3A1FBC7F-49F8-4BBF-ABBF-F8660D35F607}{17}{B0}" alignment="left" quickStyleIndex="5"><one:T><![CDATA[Typed after themed h5]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2026-10-01T02:31:15.000Z" objectID="{E8C74351-901A-0224-09D0-4CF7903AE4A0}{57}{B0}" alignment="left" spaceBefore="6.0" quickStyleIndex="6"><one:T><![CDATA[<span
6style='font-style:italic'>Heading 6 in Manuscript</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2026-10-01T02:31:15.000Z" objectID="{E8C74351-901A-0224-09D0-4CF7903AE4A0}{60}{B0}" alignment="left" quickStyleIndex="7"><one:T><![CDATA[Page Title in Manuscript]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2026-10-01T02:31:15.000Z" objectID="{E8C74351-901A-0224-09D0-4CF7903AE4A0}{63}{B0}" alignment="left" quickStyleIndex="8"><one:T><![CDATA[Citation in Manuscript]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2026-10-01T02:31:15.000Z" objectID="{E8C74351-901A-0224-09D0-4CF7903AE4A0}{66}{B0}" alignment="left" spaceAfter="4.0" quickStyleIndex="9"><one:T><![CDATA[<span
7style='font-style:italic'>Quote in Manuscript</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2026-10-01T02:31:15.000Z" objectID="{E8C74351-901A-0224-09D0-4CF7903AE4A0}{69}{B0}" alignment="left" quickStyleIndex="10"><one:T><![CDATA[Code in Manuscript]]></one:T></one:OE><one:OE lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2026-10-01T02:31:34.000Z" objectID="{E8C74351-901A-0224-09D0-4CF7903AE4A0}{72}{B0}" alignment="left" quickStyleIndex="11"><one:T><![CDATA[<span
8style='font-weight:bold'>Normal in Manuscript</span>]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-10-01T02:31:35.000Z" lastModifiedTime="2026-10-01T02:31:35.000Z" objectID="{3A1FBC7F-49F8-4BBF-ABBF-F8660D35F607}{14}{B0}" alignment="left" quickStyleIndex="5"><one:T><![CDATA[Typed after OneNote h2]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/styles/onenote/Styles.one created
Binary files /dev/null and b/corpus/styles/onenote/Styles.one differ
corpus/styles/onenote/applied.xml created+16
...@@ -0,0 +1,16 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{3DD74380-295A-46CD-8408-7E7B91469B66}{1}{B0}" name="Styles probe" dateTime="2026-09-30T22:25:42.000Z" lastModifiedTime="2026-09-30T22:34:13.000Z" pageLevel="1" isCurrentlyViewed="true" lang="en-US"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="1" name="h1" fontColor="#17365D" highlightColor="automatic" font="Calibri" fontSize="16.0" bold="true" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="2" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="3" name="h2" fontColor="#366092" highlightColor="automatic" font="Calibri" fontSize="13.0" bold="true" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="4" name="h3" fontColor="#366092" highlightColor="automatic" font="Calibri" fontSize="11.0" bold="true" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="5" name="h4" fontColor="#366092" highlightColor="automatic" font="Calibri" fontSize="11.0" bold="true" italic="true" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="6" name="h5" fontColor="#366092" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="7" name="h6" fontColor="#366092" highlightColor="automatic" font="Calibri" fontSize="11.0" italic="true" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="8" name="cite" fontColor="#595959" highlightColor="automatic" font="Calibri" fontSize="9.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="9" name="blockquote" fontColor="#595959" highlightColor="automatic" font="Calibri" fontSize="11.0" italic="true" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="10" name="code" fontColor="automatic" highlightColor="automatic" font="Courier New" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T22:31:17.000Z" lastModifiedTime="2026-09-30T22:31:18.000Z" objectID="{655568C0-7633-4A10-A41C-3537BB8C5E79}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Styles probe]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T22:34:13.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{11}{B0}"><one:Position x="108.0" y="122.400001525879" z="0"/><one:Size width="190.436767578125" height="436.2985229492187"/><one:OEChildren><one:OE creationTime="2026-09-30T22:32:10.000Z" lastModifiedTime="2026-09-30T22:32:10.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{12}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[<span
3style='font-weight:bold'>Heading 1 text</span>]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:10.000Z" lastModifiedTime="2026-09-30T22:32:10.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{16}{B0}" alignment="left" quickStyleIndex="2"><one:T><![CDATA[After 1]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:10.000Z" lastModifiedTime="2026-09-30T22:32:10.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{19}{B0}" alignment="left" quickStyleIndex="3"><one:T><![CDATA[<span
4style='font-weight:bold'>Heading 2 text</span>]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:11.000Z" lastModifiedTime="2026-09-30T22:32:11.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{22}{B0}" alignment="left" quickStyleIndex="2"><one:T><![CDATA[After 2]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:11.000Z" lastModifiedTime="2026-09-30T22:32:11.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{24}{B0}" alignment="left" quickStyleIndex="4"><one:T><![CDATA[<span
5style='font-weight:bold'>Heading 3 text</span>]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:11.000Z" lastModifiedTime="2026-09-30T22:32:11.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{27}{B0}" alignment="left" quickStyleIndex="2"><one:T><![CDATA[After 3]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:11.000Z" lastModifiedTime="2026-09-30T22:32:11.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{29}{B0}" alignment="left" quickStyleIndex="5"><one:T><![CDATA[<span
6style='font-weight:bold;font-style:italic'>Heading 4 text</span>]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:12.000Z" lastModifiedTime="2026-09-30T22:32:12.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{32}{B0}" alignment="left" quickStyleIndex="2"><one:T><![CDATA[After 4]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:12.000Z" lastModifiedTime="2026-09-30T22:32:12.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{34}{B0}" alignment="left" quickStyleIndex="6"><one:T><![CDATA[Heading 5 text]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:12.000Z" lastModifiedTime="2026-09-30T22:32:12.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{37}{B0}" alignment="left" quickStyleIndex="2"><one:T><![CDATA[After 5]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:12.000Z" lastModifiedTime="2026-09-30T22:32:12.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{39}{B0}" alignment="left" quickStyleIndex="7"><one:T><![CDATA[<span
7style='font-style:italic'>Heading 6 text</span>]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:13.000Z" lastModifiedTime="2026-09-30T22:32:13.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{42}{B0}" alignment="left" quickStyleIndex="2"><one:T><![CDATA[After 6]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:14.000Z" lastModifiedTime="2026-09-30T22:32:14.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{44}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Page Title text]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:14.000Z" lastModifiedTime="2026-09-30T22:32:15.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{46}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[After Page Title]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:15.000Z" lastModifiedTime="2026-09-30T22:32:15.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{48}{B0}" alignment="left" quickStyleIndex="8"><one:T><![CDATA[Citation text]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:16.000Z" lastModifiedTime="2026-09-30T22:32:16.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{51}{B0}" alignment="left" quickStyleIndex="8"><one:T><![CDATA[After Citation]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:17.000Z" lastModifiedTime="2026-09-30T22:32:17.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{53}{B0}" alignment="left" quickStyleIndex="9"><one:T><![CDATA[<span
8style='font-style:italic'>Quote text</span>]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:17.000Z" lastModifiedTime="2026-09-30T22:32:17.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{56}{B0}" alignment="left" quickStyleIndex="9"><one:T><![CDATA[<span
9style='font-style:italic'>After Quote</span>]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:18.000Z" lastModifiedTime="2026-09-30T22:32:18.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{58}{B0}" alignment="left" quickStyleIndex="10"><one:T><![CDATA[Code text]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:32:19.000Z" lastModifiedTime="2026-09-30T22:32:19.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{61}{B0}" alignment="left" quickStyleIndex="10"><one:T><![CDATA[After Code]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:33:08.000Z" lastModifiedTime="2026-09-30T22:33:08.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{66}{B0}" alignment="left" quickStyleIndex="2"><one:T><![CDATA[20]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:33:08.000Z" lastModifiedTime="2026-09-30T22:33:08.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{69}{B0}" alignment="left" quickStyleIndex="3"><one:T><![CDATA[]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:33:09.000Z" lastModifiedTime="2026-09-30T22:33:10.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{71}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[<span
10style='font-weight:bold'>H1 then italic </span><span style='font-weight:bold;
11font-style:italic'>word</span>]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:33:09.000Z" lastModifiedTime="2026-09-30T22:33:10.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{74}{B0}" alignment="left" quickStyleIndex="2"><one:T><![CDATA[H1 then clear]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:33:10.000Z" lastModifiedTime="2026-09-30T22:33:10.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{76}{B0}" alignment="left" quickStyleIndex="4"><one:T><![CDATA[<span
12style='font-weight:bold'>Split heading </span>]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:33:10.000Z" lastModifiedTime="2026-09-30T22:33:10.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{78}{B0}" alignment="left" quickStyleIndex="4"><one:T><![CDATA[<span
13style='font-weight:bold'>Here</span>]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:33:10.000Z" lastModifiedTime="2026-09-30T22:33:10.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{80}{B0}" alignment="left" quickStyleIndex="4"><one:T><![CDATA[]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:33:10.000Z" lastModifiedTime="2026-09-30T22:33:10.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{82}{B0}" alignment="left" quickStyleIndex="4"><one:T><![CDATA[<span
14style='font-weight:bold'>Start enter heading</span>]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:34:12.000Z" lastModifiedTime="2026-09-30T22:34:12.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{84}{B0}" alignment="left" quickStyleIndex="3"><one:T><![CDATA[<span
15style='font-weight:bold'>Explicit big bold red then h2</span>]]></one:T></one:OE><one:OE creationTime="2026-09-30T22:34:13.000Z" lastModifiedTime="2026-09-30T22:34:13.000Z" objectID="{818EA090-024D-4D4A-B946-EBE76A245DBA}{89}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[<span
16style='font-weight:bold'>Quote then h1</span>]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/table-widths/README.md created+17
...@@ -0,0 +1,17 @@
1# Table column widths through the editor
2
3`candidate/` is the output of `typed_and_dragged_columns_store_onenote_s_widths` in
4`crates/canvas/tests/table_widths.rs`, editing `corpus/table-tags/native`. On "Check a
5table" a three-by-three table is typed after the text a keystroke per revision, each
6storing its column's fitted width with its text; on "Edit a tagged table" a native cell
7is typed into, widening its column; on "Delete and undo" a new table's first column is
8dragged to 96 pt, locking it, and a line long enough to reach the outline's width is
9typed in the second.
10
11`cold/` is a fresh OneNote 2010 read: the typed table's columns are the widths OneNote
12gives the same table typed in it (60.76, 37.11 and 139.09 pt), the widened native column
13keeps its width, the dragged column stays 96 pt and locked, and the long line wraps in
14its column at the outline's edge. `tools/test_table_widths.py` checks this without a VM.
15Regenerate with `SNOWBOUND_TABLE_WIDTHS_EXPORT` set to a new absolute directory while
16running the test, then cold-open it with
17`tools/native_runner.py OUTPUT COLD --expected-pages 3 --collect-notebook --screenshots`.
corpus/table-widths/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/table-widths/candidate/Open Notebook.onetoc2 differ
corpus/table-widths/candidate/Tables.one created
Binary files /dev/null and b/corpus/table-widths/candidate/Tables.one differ
corpus/table-widths/cold/commands.jsonl created+3
...@@ -0,0 +1,3 @@
1{"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}
2{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-F8389DC9 -ExpectedPages 3 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 3 pages.\r\n", "stderr": "", "error": null}
3{"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}
corpus/table-widths/cold/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-f8389dc9", "hostname": "ONE-M6-F8389DC9"}
corpus/table-widths/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/table-widths/cold/notebook/Open Notebook.onetoc2 differ
corpus/table-widths/cold/notebook/Tables.one created
Binary files /dev/null and b/corpus/table-widths/cold/notebook/Tables.one differ
corpus/table-widths/cold/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-F8389DC9",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/table-widths/cold/read/hierarchy.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{CB538ADE-527B-4666-A701-0D6D2EB4F273}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-10-01T02:08:01.000Z" color="#B49EDE"><one:Section name="Tables" ID="{50D98C6A-6315-0B5D-13E9-371E75D6E8D8}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Tables.one" lastModifiedTime="2026-10-01T02:08:01.000Z" color="#91BAAE"><one:Page ID="{F77AECA3-F77A-0634-2313-F7734E45953D}{1}{B0}" name="Check a table" dateTime="2026-09-30T03:42:55.000Z" lastModifiedTime="2026-10-01T02:07:51.000Z" pageLevel="1"/><one:Page ID="{5749080B-A1A1-0F66-06D4-3D7F2FCA5C62}{1}{B0}" name="Edit a tagged table" dateTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-10-01T02:07:51.000Z" pageLevel="1"/><one:Page ID="{2C18A65F-888B-0AF2-38F8-4DBE934D9BB9}{1}{B0}" name="Delete and undo" dateTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-10-01T02:07:51.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/table-widths/cold/read/page-000.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Delete and undo - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/table-widths/cold/read/page-000.png created
Binary files /dev/null and b/corpus/table-widths/cold/read/page-000.png differ
corpus/table-widths/cold/read/page-000.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{2C18A65F-888B-0AF2-38F8-4DBE934D9BB9}{1}{B0}" name="Delete and undo" dateTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-10-01T02:07:51.000Z" pageLevel="1" lang="en-US"><one:TagDef index="0" type="1" symbol="13" fontColor="automatic" highlightColor="none" name="Important"/><one:TagDef index="1" type="0" symbol="3" fontColor="automatic" highlightColor="none" name="To Do"/><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{C102F5FF-A5A8-04B9-09C0-612BEA30546E}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Delete and undo]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{C102F5FF-A5A8-04B9-09C0-612BEA30546E}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="455.1275939941406" height="79.2708740234375"/><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{C102F5FF-A5A8-04B9-09C0-612BEA30546E}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Before]]></one:T></one:OE><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{C102F5FF-A5A8-04B9-09C0-612BEA30546E}{45}{B0}" alignment="left"><one:Tag index="0" completed="true" disabled="false" creationDate="2020-01-02T03:04:05.000Z" completionDate="2026-09-30T03:42:57.000Z"/><one:Table bordersVisible="true" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{C102F5FF-A5A8-04B9-09C0-612BEA30546E}{46}{B0}"><one:Columns><one:Column index="0" width="37.11000061035156"/><one:Column index="1" width="100.8300018310547"/></one:Columns><one:Row objectID="{C102F5FF-A5A8-04B9-09C0-612BEA30546E}{47}{B0}" lastModifiedTime="2026-09-30T03:42:57.000Z"><one:Cell lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{C102F5FF-A5A8-04B9-09C0-612BEA30546E}{48}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{C102F5FF-A5A8-04B9-09C0-612BEA30546E}{51}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Outer]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{C102F5FF-A5A8-04B9-09C0-612BEA30546E}{56}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{C102F5FF-A5A8-04B9-09C0-612BEA30546E}{59}{B0}" alignment="left"><one:Tag index="1" completed="false" disabled="false" creationDate="2020-01-02T03:04:05.000Z"/><one:Table bordersVisible="true" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{C102F5FF-A5A8-04B9-09C0-612BEA30546E}{60}{B0}"><one:Columns><one:Column index="0" width="37.11000061035156"/><one:Column index="1" width="37.11000061035156"/></one:Columns><one:Row objectID="{C102F5FF-A5A8-04B9-09C0-612BEA30546E}{61}{B0}" lastModifiedTime="2026-09-30T03:42:57.000Z"><one:Cell lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{C102F5FF-A5A8-04B9-09C0-612BEA30546E}{62}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{C102F5FF-A5A8-04B9-09C0-612BEA30546E}{65}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Inner A]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{C102F5FF-A5A8-04B9-09C0-612BEA30546E}{70}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{C102F5FF-A5A8-04B9-09C0-612BEA30546E}{73}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Inner B]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2025-08-18T14:14:46.000Z" objectID="{C102F5FF-A5A8-04B9-09C0-612BEA30546E}{80}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[After]]></one:T></one:OE><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2025-08-18T14:14:46.000Z" lastModifiedTime="2025-08-18T14:14:48.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{37}{B0}" alignment="left"><one:Table bordersVisible="true" lastModifiedTime="2025-08-18T14:14:48.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{38}{B0}"><one:Columns><one:Column index="0" width="96.0" isLocked="true"/><one:Column index="1" width="350.9975891113281"/></one:Columns><one:Row objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{39}{B0}" lastModifiedTime="2025-08-18T14:14:48.000Z"><one:Cell lastModifiedTime="2025-08-18T14:14:46.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{42}{B0}" lastModifiedByInitials="A"><one:OEChildren><one:OE creationTime="2025-08-18T14:14:46.000Z" lastModifiedTime="2025-08-18T14:14:46.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Dragged]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2025-08-18T14:14:48.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{45}{B0}" lastModifiedByInitials="A"><one:OEChildren><one:OE creationTime="2025-08-18T14:14:46.000Z" lastModifiedTime="2025-08-18T14:14:48.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{43}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Fitted and then a line long enough to reach the outline's edge and wrap there]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/table-widths/cold/read/page-001.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Edit a tagged table - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/table-widths/cold/read/page-001.png created
Binary files /dev/null and b/corpus/table-widths/cold/read/page-001.png differ
corpus/table-widths/cold/read/page-001.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{5749080B-A1A1-0F66-06D4-3D7F2FCA5C62}{1}{B0}" name="Edit a tagged table" dateTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-10-01T02:07:51.000Z" pageLevel="1" lang="en-US"><one:TagDef index="0" type="1" symbol="13" fontColor="automatic" highlightColor="none" name="Important"/><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{43CFB1A1-B3CA-0737-25D0-E5DE5157DB62}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Edit a tagged table]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{43CFB1A1-B3CA-0737-25D0-E5DE5157DB62}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="225.4903259277344" height="67.21086883544922"/><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{43CFB1A1-B3CA-0737-25D0-E5DE5157DB62}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Before]]></one:T></one:OE><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{43CFB1A1-B3CA-0737-25D0-E5DE5157DB62}{45}{B0}" alignment="left"><one:Tag index="0" completed="true" disabled="false" creationDate="2020-01-02T03:04:05.000Z" completionDate="2026-09-30T03:42:57.000Z"/><one:Table bordersVisible="true" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{43CFB1A1-B3CA-0737-25D0-E5DE5157DB62}{46}{B0}"><one:Columns><one:Column index="0" width="180.2503204345703"/><one:Column index="1" width="37.11000061035156"/></one:Columns><one:Row objectID="{43CFB1A1-B3CA-0737-25D0-E5DE5157DB62}{47}{B0}" lastModifiedTime="2026-09-30T03:42:57.000Z"><one:Cell lastModifiedTime="2025-08-18T14:14:45.000Z" objectID="{43CFB1A1-B3CA-0737-25D0-E5DE5157DB62}{48}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2025-08-18T14:14:45.000Z" objectID="{43CFB1A1-B3CA-0737-25D0-E5DE5157DB62}{51}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Typed into and typed on in Snowbound]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{43CFB1A1-B3CA-0737-25D0-E5DE5157DB62}{56}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{43CFB1A1-B3CA-0737-25D0-E5DE5157DB62}{59}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[B]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row><one:Row objectID="{43CFB1A1-B3CA-0737-25D0-E5DE5157DB62}{64}{B0}" lastModifiedTime="2026-09-30T03:42:57.000Z"><one:Cell lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{43CFB1A1-B3CA-0737-25D0-E5DE5157DB62}{68}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{43CFB1A1-B3CA-0737-25D0-E5DE5157DB62}{71}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[C]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{43CFB1A1-B3CA-0737-25D0-E5DE5157DB62}{65}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{43CFB1A1-B3CA-0737-25D0-E5DE5157DB62}{76}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[D]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{43CFB1A1-B3CA-0737-25D0-E5DE5157DB62}{82}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[After]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/table-widths/cold/read/page-002.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Check a table - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/table-widths/cold/read/page-002.png created
Binary files /dev/null and b/corpus/table-widths/cold/read/page-002.png differ
corpus/table-widths/cold/read/page-002.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{F77AECA3-F77A-0634-2313-F7734E45953D}{1}{B0}" name="Check a table" dateTime="2026-09-30T03:42:55.000Z" lastModifiedTime="2026-10-01T02:07:51.000Z" pageLevel="1" lang="en-US"><one:TagDef index="0" type="0" symbol="3" fontColor="automatic" highlightColor="none" name="To Do"/><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{6EC89AF8-5FD8-078C-12F7-A1997C0B7ABC}{17}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Check a table]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{6EC89AF8-5FD8-078C-12F7-A1997C0B7ABC}{23}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="250.0658264160156" height="107.5663146972656"/><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{6EC89AF8-5FD8-078C-12F7-A1997C0B7ABC}{24}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Before]]></one:T></one:OE><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{6EC89AF8-5FD8-078C-12F7-A1997C0B7ABC}{29}{B0}" alignment="left"><one:Tag index="0" completed="false" disabled="false" creationDate="2020-01-02T03:04:05.000Z"/><one:Table bordersVisible="true" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{6EC89AF8-5FD8-078C-12F7-A1997C0B7ABC}{30}{B0}"><one:Columns><one:Column index="0" width="85.20857238769531"/><one:Column index="1" width="37.11000061035156"/></one:Columns><one:Row objectID="{6EC89AF8-5FD8-078C-12F7-A1997C0B7ABC}{31}{B0}" lastModifiedTime="2026-09-30T03:42:57.000Z"><one:Cell lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{6EC89AF8-5FD8-078C-12F7-A1997C0B7ABC}{32}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{6EC89AF8-5FD8-078C-12F7-A1997C0B7ABC}{35}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Checked by a click]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{6EC89AF8-5FD8-078C-12F7-A1997C0B7ABC}{40}{B0}" lastModifiedByInitials="V"><one:OEChildren><one:OE creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2026-09-30T03:42:57.000Z" objectID="{6EC89AF8-5FD8-078C-12F7-A1997C0B7ABC}{43}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[B]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-09-30T03:42:57.000Z" lastModifiedTime="2025-08-18T14:13:21.000Z" objectID="{6EC89AF8-5FD8-078C-12F7-A1997C0B7ABC}{49}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[After]]></one:T></one:OE><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2025-08-18T14:13:21.000Z" lastModifiedTime="2025-08-18T14:14:44.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{3}{B0}" alignment="left"><one:Table bordersVisible="true" lastModifiedTime="2025-08-18T14:14:44.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{4}{B0}"><one:Columns><one:Column index="0" width="60.75944900512695"/><one:Column index="1" width="37.11000061035156"/><one:Column index="2" width="139.0863647460937"/></one:Columns><one:Row objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{5}{B0}" lastModifiedTime="2025-08-18T14:14:31.000Z"><one:Cell lastModifiedTime="2025-08-18T14:13:26.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{8}{B0}" lastModifiedByInitials="A"><one:OEChildren><one:OE creationTime="2025-08-18T14:13:21.000Z" lastModifiedTime="2025-08-18T14:13:26.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{6}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Fruit]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2025-08-18T14:13:33.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{11}{B0}" lastModifiedByInitials="A"><one:OEChildren><one:OE creationTime="2025-08-18T14:13:27.000Z" lastModifiedTime="2025-08-18T14:13:33.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{9}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Colour]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2025-08-18T14:13:39.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{14}{B0}" lastModifiedByInitials="A"><one:OEChildren><one:OE creationTime="2025-08-18T14:13:34.000Z" lastModifiedTime="2025-08-18T14:13:39.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{12}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Notes]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row><one:Row objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{24}{B0}" lastModifiedTime="2025-08-18T14:14:31.000Z"><one:Cell lastModifiedTime="2025-08-18T14:13:45.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{17}{B0}" lastModifiedByInitials="A"><one:OEChildren><one:OE creationTime="2025-08-18T14:13:40.000Z" lastModifiedTime="2025-08-18T14:13:45.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Apple]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2025-08-18T14:13:49.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{20}{B0}" lastModifiedByInitials="A"><one:OEChildren><one:OE creationTime="2025-08-18T14:13:40.000Z" lastModifiedTime="2025-08-18T14:13:49.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{18}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Red]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2025-08-18T14:14:20.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{23}{B0}" lastModifiedByInitials="A"><one:OEChildren><one:OE creationTime="2025-08-18T14:13:40.000Z" lastModifiedTime="2025-08-18T14:14:20.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{21}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Crisp and sweet, good for pies]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row><one:Row objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{34}{B0}" lastModifiedTime="2025-08-18T14:14:44.000Z"><one:Cell lastModifiedTime="2025-08-18T14:14:31.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{27}{B0}" lastModifiedByInitials="A"><one:OEChildren><one:OE creationTime="2025-08-18T14:14:21.000Z" lastModifiedTime="2025-08-18T14:14:31.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{25}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Watermelon]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2025-08-18T14:14:37.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{30}{B0}" lastModifiedByInitials="A"><one:OEChildren><one:OE creationTime="2025-08-18T14:14:21.000Z" lastModifiedTime="2025-08-18T14:14:37.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{28}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Green]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2025-08-18T14:14:44.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{33}{B0}" lastModifiedByInitials="A"><one:OEChildren><one:OE creationTime="2025-08-18T14:14:21.000Z" lastModifiedTime="2025-08-18T14:14:44.000Z" objectID="{BF277B9C-48CB-0E35-015F-0F89CC3240F9}{31}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Summer]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/table-widths/cold/read/payloads.json created+3
...@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/table-widths/cold/run.json created+18
...@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/snowbound-tables/candidate",
3 "expected_pages": 3,
4 "author": null,
5 "author_timeout_seconds": 600,
6 "inspect": false,
7 "collect_notebook": true,
8 "base": {
9 "file": "win7-office-base.qcow2",
10 "format": "qcow2",
11 "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
12 "virtual_size": 68719476736
13 },
14 "scripts": {
15 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
16 "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
17 }
18}
corpus/table-widths/cold/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/table-widths/cold/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/table-widths/cold/source.json created+14
...@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1604,
5 "sha256": "78250a5c9a50d89ba0b9a0cb725c8afe0c9cbc538a8f39e8a3cde75d2aebed65",
6 "mtime_ns": 1790820308278651723
7 },
8 {
9 "path": "Tables.one",
10 "bytes": 35192,
11 "sha256": "b45ea79ddcb9c9f30992e9beee4919993578c295cc81605ca3d97fbbfc4d2eac",
12 "mtime_ns": 1790820308259896484
13 }
14]
corpus/table-widths/cold/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
crates/canvas/examples/print_pdf.rs+10-2
...@@ -33,8 +33,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {...@@ -33,8 +33,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
33 .file_stem()33 .file_stem()
34 .and_then(|stem| stem.to_str())34 .and_then(|stem| stem.to_str())
35 .unwrap_or_default();35 .unwrap_or_default();
36 let paper = if a4 { print::A4 } else { print::LETTER };36 let setup = print::Setup {
37 let pdf = print::pdf(pages, &mut TextEngine::default(), paper, name)?;37 paper: if a4 { print::A4 } else { print::LETTER },
38 fit_width: true,
39 footer: print::Footer::SectionAndPage,
40 };
41 let pdf = print::pdf(
42 vec![(name.to_owned(), pages)],
43 &mut TextEngine::default(),
44 &setup,
45 )?;
38 fs::write(destination, pdf)?;46 fs::write(destination, pdf)?;
39 Ok(())47 Ok(())
40}48}
crates/canvas/examples/scratch_eqref.rs deleted-667
...@@ -1,667 +0,0 @@
1//! Every place the canvas shows a page's content as a placeholder, draws it read-only, or
2//! refuses an ordinary edit as content it cannot edit, across the active pages of every
3//! section under the given directories, grouped by kind as Markdown. Identical sections
4//! count once.
5//!
6//! `cargo run --release -p canvas --features gpu --example unsupported_inventory -- corpus`
7
8use canvas::{
9 document::TextPosition,
10 editor::{CanvasEditor, EditorError, TextOutline},
11 layout::{LayoutError, TextEngine},
12 outline::Arrange,
13};
14use onestore::{
15 Arena, ExGuid, Section,
16 page::{
17 Image, Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent,
18 text::{Affinity, EditError},
19 },
20};
21use std::{
22 collections::{BTreeMap, BTreeSet, hash_map::DefaultHasher},
23 hash::{Hash, Hasher},
24 path::{Path, PathBuf},
25};
26
27#[derive(Default)]
28struct Inventory {
29 /// Kind to the places it occurs, as `section — page`.
30 kinds: BTreeMap<(Stage, String), Vec<String>>,
31 sections: usize,
32 pages: usize,
33}
34
35#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
36enum Stage {
37 /// Drawn as an "Unsupported content" (or "Image unavailable") box.
38 Placeholder,
39 /// Drawn as stored, but the editor cannot hold it.
40 ReadOnly,
41 /// An ordinary edit the editor refuses as content it cannot edit.
42 Refused,
43 /// The page or section does not open at all.
44 Failure,
45}
46
47impl Inventory {
48 fn add(&mut self, stage: Stage, kind: impl Into<String>, place: &str) {
49 self.kinds
50 .entry((stage, kind.into()))
51 .or_default()
52 .push(place.to_owned());
53 }
54}
55
56/// MS-ONE's names for the object kinds it documents, and the observed undocumented ones.
57fn jcid_name(jcid: u32) -> String {
58 let name = match jcid {
59 0x60007 => "section",
60 0x60008 => "page series",
61 0x6000b => "page",
62 0x6000c => "outline",
63 0x6000d => "outline element",
64 0x6000e => "rich text",
65 0x60011 => "picture",
66 0x60012 => "number list",
67 0x60014 => "ink container",
68 0x60019 => "outline group",
69 0x60022 => "table",
70 0x60023 => "table row",
71 0x60024 => "table cell",
72 0x6002c => "title",
73 0x60035 => "embedded file",
74 0x60037 => "page manifest",
75 0x6003c => "version history",
76 0x6003d => "version proxy",
77 _ => "undocumented",
78 };
79 format!("jcid 0x{jcid:05x} ({name})")
80}
81
82fn unsupported_error(error: &EditorError) -> bool {
83 matches!(
84 error,
85 EditorError::Layout(LayoutError::UnsupportedContent)
86 | EditorError::Edit(EditError::UnsupportedContent)
87 )
88}
89
90/// What a text paragraph holds that bears on editing it.
91fn traits(text: &Paragraph) -> Vec<&'static str> {
92 let spans = text.spans();
93 let mut traits = Vec::new();
94 if text.text().contains('\u{fffc}')
95 || spans.iter().any(|s| s.format.embedded_object == Some(true))
96 {
97 traits.push("embedded object in text");
98 }
99 if spans.iter().any(|s| s.format.math == Some(true)) {
100 traits.push("equation");
101 }
102 if spans.iter().any(|s| s.format.hyperlink == Some(true)) {
103 traits.push("link");
104 }
105 if spans.iter().any(|s| s.format.hidden == Some(true)) {
106 traits.push("hidden text");
107 }
108 if text.text().is_empty() {
109 traits.push("empty");
110 }
111 traits
112}
113
114/// Why `node`, alone in an outline, does not lay out as content the canvas understands.
115fn culprit(
116 engine: &mut TextEngine,
117 outline: &Outline,
118 node: &PageParagraph,
119 page: &Page,
120) -> Option<String> {
121 let alone = Outline {
122 paragraphs: vec![PageParagraph {
123 parent: None,
124 level: 1,
125 ..node.clone()
126 }],
127 unsupported: Vec::new(),
128 ..outline.clone()
129 };
130 match alone.layout(engine, &page.definitions) {
131 Err(LayoutError::UnsupportedContent) => {}
132 _ => return None,
133 }
134 Some(match &node.content {
135 ParagraphContent::Unsupported(u) => format!("holds {}", jcid_name(u.jcid)),
136 ParagraphContent::Table(table) if !table.tags.is_empty() => "table with note tags".into(),
137 ParagraphContent::Table(table) => {
138 let cells = || table.rows.iter().flat_map(|row| &row.cells);
139 if let Some(cell) = cells().find(|cell| !cell.unsupported.is_empty()) {
140 format!(
141 "table cell holding {}",
142 cell.unsupported
143 .iter()
144 .map(|u| jcid_name(u.jcid))
145 .collect::<Vec<_>>()
146 .join(", ")
147 )
148 } else {
149 let inner = cells()
150 .flat_map(|cell| &cell.paragraphs)
151 .find_map(|inner| culprit(engine, outline, inner, page));
152 format!(
153 "table cell: {}",
154 inner.unwrap_or_else(|| "nested content".into())
155 )
156 }
157 }
158 ParagraphContent::Image(_) => "picture without a size".into(),
159 ParagraphContent::Ink(_) => "handwriting without strokes".into(),
160 ParagraphContent::Attachment(_) => "file".into(),
161 ParagraphContent::Text(text) => {
162 let lists: Vec<String> = node
163 .lists
164 .iter()
165 .filter_map(|id| page.definitions.get(id))
166 .map(|definition| format!("{:?}", definition.kind))
167 .collect();
168 if !lists.is_empty() {
169 format!("list marker {}", lists.join(", "))
170 } else if text
171 .text
172 .spans()
173 .iter()
174 .any(|s| s.format.math == Some(true))
175 {
176 "equation the math layout does not parse".into()
177 } else {
178 format!("text ({})", traits(&text.text).join(", "))
179 }
180 }
181 })
182}
183
184/// Why an outline the page shows is not editable.
185fn diagnose(engine: &mut TextEngine, outline: &Outline, page: &Page) -> Vec<String> {
186 let mut reasons: Vec<String> = outline
187 .unsupported
188 .iter()
189 .map(|u| format!("outline child {}", jcid_name(u.jcid)))
190 .collect();
191 reasons.extend(
192 outline
193 .paragraphs
194 .iter()
195 .filter_map(|node| culprit(engine, outline, node, page)),
196 );
197 if reasons.is_empty() {
198 let text = outline.paragraphs.iter().any(|node| {
199 node.text().is_some()
200 || matches!(&node.content, ParagraphContent::Table(t)
201 if t.rows.iter().flat_map(|r| &r.cells).any(|c| !c.paragraphs.is_empty()))
202 });
203 reasons.push(if text {
204 match TextOutline::from_outline(engine, outline, &page.definitions) {
205 Err(error) => format!("editor: {error}"),
206 Ok(_) => "outline on its own is editable".into(),
207 }
208 } else {
209 let kinds: BTreeSet<&str> = outline
210 .paragraphs
211 .iter()
212 .map(|node| match node.content {
213 ParagraphContent::Image(_) => "pictures",
214 ParagraphContent::Attachment(_) => "files",
215 ParagraphContent::Ink(_) => "handwriting",
216 _ => "other",
217 })
218 .collect();
219 format!(
220 "no paragraph of text (only {})",
221 kinds.into_iter().collect::<Vec<_>>().join(", ")
222 )
223 });
224 }
225 reasons
226}
227
228/// A picture that draws as "Image unavailable": no data, or (with `--features gpu`) data
229/// the renderer does not decode, named by its leading bytes.
230fn picture(image: &Image, at: &str, inventory: &mut Inventory, place: &str) {
231 let Some(bytes) = &image.bytes else {
232 return inventory.add(
233 Stage::Placeholder,
234 format!("{at} picture without data"),
235 place,
236 );
237 };
238 #[cfg(feature = "gpu")]
239 if [Some(bytes), image.display.as_ref()]
240 .into_iter()
241 .flatten()
242 .all(|bytes| draw::RasterImage::measure(bytes).is_err())
243 {
244 let format = match &bytes[..bytes.len().min(4)] {
245 [0x50, 0x4b, 3, 4] => "a zip package (XPS printout)",
246 [1, 0, 0, 0] => "EMF",
247 [0xd7, 0xcd, 0xc6, 0x9a] | [1, 0, 9, 0] => "WMF",
248 [b'B', b'M', ..] => "BMP",
249 _ => "an unknown format",
250 };
251 inventory.add(
252 Stage::Placeholder,
253 format!("{at} picture stored as {format}"),
254 place,
255 );
256 }
257 #[cfg(not(feature = "gpu"))]
258 let _ = bytes;
259}
260
261/// Pictures that draw as "Image unavailable" and objects the model keeps aside.
262fn scan_paragraphs(nodes: &[PageParagraph], at: &str, inventory: &mut Inventory, place: &str) {
263 for node in nodes {
264 match &node.content {
265 ParagraphContent::Unsupported(u) => inventory.add(
266 Stage::Placeholder,
267 format!("{at} paragraph holding {}", jcid_name(u.jcid)),
268 place,
269 ),
270 ParagraphContent::Image(image) => picture(image, at, inventory, place),
271 ParagraphContent::Table(table) => {
272 for cell in table.rows.iter().flat_map(|row| &row.cells) {
273 for u in &cell.unsupported {
274 inventory.add(
275 Stage::Placeholder,
276 format!("table cell child {}", jcid_name(u.jcid)),
277 place,
278 );
279 }
280 scan_paragraphs(&cell.paragraphs, "table cell", inventory, place);
281 }
282 }
283 _ => {}
284 }
285 }
286}
287
288/// Ordinary edits at every text paragraph of every editable outline: typing at its start,
289/// middle and end, Enter, Backspace at its start, Delete at its end, and deleting from its
290/// middle into the next.
291fn probe(
292 editor: &mut CanvasEditor,
293 engine: &mut TextEngine,
294 inventory: &mut Inventory,
295 place: &str,
296) {
297 let ids: Vec<ExGuid> = editor.outlines().iter().map(|o| o.id).collect();
298 for id in ids {
299 if editor.focus_outline(id).is_err() {
300 continue;
301 }
302 let document = editor.active_outline().document();
303 let containers = containers(document.nodes());
304 let leaves: Vec<(Option<ExGuid>, Paragraph)> = document
305 .text_nodes()
306 .map(|node| {
307 (
308 containers.get(&node.id).copied().flatten(),
309 node.text().unwrap().text.clone(),
310 )
311 })
312 .collect();
313 for (index, (container, text)) in leaves.iter().enumerate() {
314 let length = text.text().encode_utf16().count() as u32;
315 // The middle of what shows, where a click could put the caret.
316 let Ok(projection) = text.project() else {
317 continue;
318 };
319 let shown = projection.text();
320 let middle = shown
321 .text()
322 .char_indices()
323 .map(|(at, _)| at)
324 .nth(shown.text().chars().count() / 2)
325 .and_then(|at| shown.utf16_offset(at).ok())
326 .and_then(|at| projection.source_offset(at, Affinity::Downstream).ok())
327 .unwrap_or(length);
328 let at = |offset| TextPosition {
329 paragraph: index,
330 offset,
331 };
332 let next = leaves.get(index + 1);
333 let mut probes: Vec<(&str, [TextPosition; 2], Probe)> = vec![
334 ("typing at a paragraph's start", [at(0); 2], Probe::Type),
335 ("typing inside a paragraph", [at(middle); 2], Probe::Type),
336 ("typing at a paragraph's end", [at(length); 2], Probe::Type),
337 ("Enter inside a paragraph", [at(middle); 2], Probe::Enter),
338 (
339 "Delete at a paragraph's end",
340 [at(length); 2],
341 Probe::Delete,
342 ),
343 ];
344 if index > 0 {
345 probes.push((
346 "Backspace at a paragraph's start",
347 [at(0); 2],
348 Probe::Backspace,
349 ));
350 }
351 if let Some((_, next_text)) = next {
352 let end = next_text.text().encode_utf16().count() as u32 / 2;
353 probes.push((
354 "deleting a selection into the next paragraph",
355 [
356 at(middle),
357 TextPosition {
358 paragraph: index + 1,
359 offset: end,
360 },
361 ],
362 Probe::Backspace,
363 ));
364 }
365 for (name, positions, kind) in probes {
366 if editor.select(positions.into()).is_err() {
367 continue;
368 }
369 let result = match kind {
370 Probe::Type => editor.insert(engine, "x").map(|()| true),
371 Probe::Enter => editor.enter(engine, false).map(|()| true),
372 Probe::Delete => editor.delete(engine, false),
373 Probe::Backspace => editor.delete(engine, true),
374 };
375 match result {
376 Ok(true) => {
377 editor.undo(engine).ok();
378 }
379 Ok(false) => {}
380 Err(error) if unsupported_error(&error) => {
381 let show = |t: &Paragraph, o: u32| {
382 let b = t.byte_offset(o).unwrap_or(0);
383 let v = |s: &str| s.replace('\u{fdd0}', "⟨").replace('\u{fdef}', "⟩").replace('\u{fdee}', "|").replace('\u{fffc}', "¤");
384 format!("{}‸{}", v(&t.text()[..b]), v(&t.text()[b..]))
385 };
386 let a = show(text, positions[0].offset);
387 let b = if positions[1].paragraph != index { show(&leaves[positions[1].paragraph].1, positions[1].offset) } else { String::new() };
388 eprintln!("REFUSED {name} @ {place}\n A: {a}\n B: {b}");
389 let crossing = positions[0].paragraph != positions[1].paragraph
390 || name.starts_with("Backspace")
391 || name.starts_with("Delete");
392 let neighbour = match name {
393 n if n.starts_with("Backspace") => leaves.get(index - 1),
394 _ if crossing => next,
395 _ => None,
396 };
397 let mut why: Vec<&str> = traits(text);
398 if let Some((other, other_text)) = neighbour {
399 if other != container {
400 why.push("across a table cell boundary");
401 }
402 why.extend(traits(other_text).into_iter().map(|t| match t {
403 "embedded object in text" => "next to an embedded object",
404 "equation" => "next to an equation",
405 "link" => "next to a link",
406 t => t,
407 }));
408 }
409 if container.is_some() {
410 why.push("in a table cell");
411 }
412 why.sort();
413 why.dedup();
414 inventory.add(
415 Stage::Refused,
416 format!("{name} [{}]", why.join(", ")),
417 place,
418 );
419 }
420 Err(_) => {}
421 }
422 }
423 }
424 }
425}
426
427#[derive(Clone, Copy)]
428enum Probe {
429 Type,
430 Enter,
431 Delete,
432 Backspace,
433}
434
435/// Each paragraph's table cell, `None` at the outline's top.
436fn containers(nodes: &[PageParagraph]) -> BTreeMap<ExGuid, Option<ExGuid>> {
437 let mut map = BTreeMap::new();
438 let mut pending = vec![(None, nodes)];
439 while let Some((container, nodes)) = pending.pop() {
440 for node in nodes {
441 map.insert(node.id, container);
442 if let ParagraphContent::Table(table) = &node.content {
443 for cell in table.rows.iter().flat_map(|row| &row.cells) {
444 pending.push((Some(cell.id), cell.paragraphs.as_slice()));
445 }
446 }
447 }
448 }
449 map
450}
451
452fn page(engine: &mut TextEngine, page: Page, place: &str, inventory: &mut Inventory) {
453 for object in &page.objects {
454 match object {
455 PageObject::Unsupported(u) => inventory.add(
456 Stage::Placeholder,
457 format!("page object {}", jcid_name(u.jcid)),
458 place,
459 ),
460 PageObject::Image(image) => picture(image, "page", inventory, place),
461 PageObject::Outline(outline) => {
462 for u in &outline.unsupported {
463 inventory.add(
464 Stage::Placeholder,
465 format!("outline child {}", jcid_name(u.jcid)),
466 place,
467 );
468 }
469 scan_paragraphs(&outline.paragraphs, "outline", inventory, place);
470 }
471 PageObject::Title(title) => {
472 for outline in &title.outlines {
473 scan_paragraphs(&outline.paragraphs, "title", inventory, place);
474 }
475 }
476 _ => {}
477 }
478 }
479 let mut editor = match CanvasEditor::from_page(page.clone(), engine) {
480 Ok(editor) => editor,
481 Err(error) => {
482 inventory.add(
483 Stage::Failure,
484 format!("page does not open: {error}"),
485 place,
486 );
487 return;
488 }
489 };
490 for object in &page.objects {
491 let (outlines, title) = match object {
492 PageObject::Outline(outline) => (std::slice::from_ref(outline), None),
493 PageObject::Title(title) => (title.outlines.as_slice(), Some(title)),
494 _ => continue,
495 };
496 for outline in outlines {
497 if editor.has_page_outline(outline.id)
498 || editor.outlines().iter().any(|o| o.id == outline.id)
499 {
500 continue;
501 }
502 if let Some(title) = title {
503 if title.date == Some(outline.id) {
504 if editor.date().is_none() {
505 inventory.add(Stage::ReadOnly, "page date the date editor declines", place);
506 }
507 continue;
508 }
509 if !outline.title {
510 continue;
511 }
512 }
513 let placeholder = matches!(
514 outline.layout(engine, &page.definitions),
515 Err(LayoutError::UnsupportedContent)
516 );
517 let at = if title.is_some() { "title" } else { "outline" };
518 for reason in diagnose(engine, outline, &page) {
519 let stage = if placeholder {
520 Stage::Placeholder
521 } else {
522 Stage::ReadOnly
523 };
524 let whole = if placeholder {
525 format!("whole {at} as a box: {reason}")
526 } else {
527 format!("{at} read-only: {reason}")
528 };
529 inventory.add(stage, whole, place);
530 }
531 }
532 }
533 probe(&mut editor, engine, inventory, place);
534}
535
536fn section(engine: &mut TextEngine, path: &Path, root: &Path, inventory: &mut Inventory) {
537 let Ok(image) = std::fs::read(path) else {
538 return;
539 };
540 let name = path
541 .strip_prefix(root)
542 .unwrap_or(path)
543 .display()
544 .to_string();
545 let arena = Arena::default();
546 let mut section = match Section::open(&arena, image) {
547 Ok(section) => section,
548 Err(error) => {
549 inventory.add(
550 Stage::Failure,
551 format!("section does not open: {}", error.message),
552 &name,
553 );
554 return;
555 }
556 };
557 inventory.sections += 1;
558 let pages = match section.pages() {
559 Ok(pages) => pages,
560 Err(error) => {
561 inventory.add(
562 Stage::Failure,
563 format!("page list does not read: {}", error.message),
564 &name,
565 );
566 return;
567 }
568 };
569 for (space, title, _) in pages {
570 inventory.pages += 1;
571 let place = format!("{name} — {title}");
572 match section.page(space) {
573 Ok(model) => page(engine, model, &place, inventory),
574 Err(error) => inventory.add(
575 Stage::Failure,
576 format!("page does not read: {}", error.message),
577 &place,
578 ),
579 }
580 }
581}
582
583fn sections(directory: &Path, out: &mut Vec<PathBuf>) {
584 let Ok(entries) = std::fs::read_dir(directory) else {
585 return;
586 };
587 let mut entries: Vec<PathBuf> = entries.flatten().map(|entry| entry.path()).collect();
588 entries.sort();
589 for path in entries {
590 if path.is_dir() {
591 sections(&path, out);
592 } else if path.extension().is_some_and(|e| e == "one") {
593 out.push(path);
594 }
595 }
596}
597
598fn main() {
599 let roots: Vec<PathBuf> = std::env::args().skip(1).map(PathBuf::from).collect();
600 let mut engine = TextEngine::default();
601 let mut inventory = Inventory::default();
602 let mut seen = BTreeSet::new();
603 let mut duplicates = 0;
604 for root in &roots {
605 let mut paths = Vec::new();
606 sections(root, &mut paths);
607 for path in paths {
608 let Ok(bytes) = std::fs::read(&path) else {
609 continue;
610 };
611 let mut hasher = DefaultHasher::new();
612 bytes.hash(&mut hasher);
613 if !seen.insert(hasher.finish()) {
614 duplicates += 1;
615 continue;
616 }
617 section(&mut engine, &path, root, &mut inventory);
618 }
619 }
620 println!("# Unsupported content inventory\n");
621 println!(
622 "{} distinct sections ({duplicates} identical copies skipped), {} pages, under {}.\n",
623 inventory.sections,
624 inventory.pages,
625 roots
626 .iter()
627 .map(|r| format!("`{}`", r.display()))
628 .collect::<Vec<_>>()
629 .join(", ")
630 );
631 let headings = [
632 (Stage::Placeholder, "Drawn as a placeholder box"),
633 (Stage::ReadOnly, "Drawn as stored, read-only"),
634 (
635 Stage::Refused,
636 "Edits refused as content the editor cannot edit",
637 ),
638 (Stage::Failure, "Does not open"),
639 ];
640 for (stage, heading) in headings {
641 let mut rows: Vec<(&String, &Vec<String>)> = inventory
642 .kinds
643 .iter()
644 .filter(|((s, _), _)| *s == stage)
645 .map(|((_, kind), places)| (kind, places))
646 .collect();
647 rows.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then(a.0.cmp(b.0)));
648 println!("## {heading}\n");
649 if rows.is_empty() {
650 println!("None.\n");
651 continue;
652 }
653 println!("| Kind | Count | Pages | Examples |\n| --- | ---: | ---: | --- |");
654 for (kind, places) in rows {
655 let pages: BTreeSet<&String> = places.iter().collect();
656 let examples: Vec<String> = pages.iter().take(3).map(|p| format!("`{p}`")).collect();
657 println!(
658 "| {} | {} | {} | {} |",
659 kind.replace('|', "\\|"),
660 places.len(),
661 pages.len(),
662 examples.join("<br>").replace('|', "\\|")
663 );
664 }
665 println!();
666 }
667}
crates/canvas/examples/scratch_files.rs deleted-15
...@@ -1,15 +0,0 @@
1use onestore::{RevisionIndex, Store, document::{Document, Kind}};
2fn main() {
3 let bytes = std::fs::read(std::env::args().nth(1).unwrap()).unwrap();
4 let store = Store::parse(&bytes).unwrap();
5 let index = RevisionIndex::parse(&store).unwrap();
6 let document = Document::parse(&index).unwrap();
7 for (space, _) in document.pages().unwrap() {
8 let revision = document.active(space).unwrap();
9 for (id, node) in &revision.nodes {
10 if let Kind::File { extension, payload, .. } = &node.kind {
11 println!("{id:?} {extension} {:?}", payload.map(|p| (p.len(), &p[..4.min(p.len())])));
12 }
13 }
14 }
15}
crates/canvas/examples/scratch_runs.rs deleted-23
...@@ -1,23 +0,0 @@
1use onestore::{Arena, Section, page::{PageObject}};
2fn main() {
3 let path = std::env::args().nth(1).unwrap();
4 let want = std::env::args().nth(2).unwrap_or_default();
5 let arena = Arena::default();
6 let mut section = Section::open(&arena, std::fs::read(path).unwrap()).unwrap();
7 for (space, _, _) in section.pages().unwrap() {
8 let page = section.page(space).unwrap();
9 for object in &page.objects {
10 let PageObject::Outline(outline) = object else { continue };
11 for p in &outline.paragraphs {
12 let Some(text) = p.text() else { continue };
13 if !text.text.text().contains(want.as_str()) { continue }
14 let mut start = 0;
15 println!("-- {:?}", p.id);
16 for span in text.text.spans() {
17 println!(" {:?} math={:?} obj={:?} emb={:?}", &text.text.text()[start..span.end], span.format.math, span.format.math_object, span.format.embedded_object);
18 start = span.end;
19 }
20 }
21 }
22 }
23}
crates/canvas/examples/unsupported_inventory.rs+19
...@@ -468,6 +468,25 @@ fn page(engine: &mut TextEngine, page: Page, place: &str, inventory: &mut Invent...@@ -468,6 +468,25 @@ fn page(engine: &mut TextEngine, page: Page, place: &str, inventory: &mut Invent
468 _ => {}468 _ => {}
469 }469 }
470 }470 }
471 // Pictures with pixels the scene still shows as placeholders, for want of a size or the
472 // like; undecodable ones are counted above.
473 #[cfg(feature = "gpu")]
474 if let Ok((scene, shown)) = canvas::gpu::page::PageScene::from_page(page.clone(), engine) {
475 for object in scene.read_only(Some(&shown)) {
476 if let PageObject::Image(image) = &object.source
477 && [image.bytes.as_ref(), image.display.as_ref()]
478 .into_iter()
479 .flatten()
480 .any(|bytes| draw::RasterImage::measure(bytes).is_ok())
481 {
482 inventory.add(
483 Stage::Placeholder,
484 "page picture with pixels drawn as \"Image unavailable\"",
485 place,
486 );
487 }
488 }
489 }
471 let mut editor = match CanvasEditor::from_page(page.clone(), engine) {490 let mut editor = match CanvasEditor::from_page(page.clone(), engine) {
472 Ok(editor) => editor,491 Ok(editor) => editor,
473 Err(error) => {492 Err(error) => {
crates/canvas/src/editor.rs+396-47
...@@ -36,7 +36,8 @@ mod recording;...@@ -36,7 +36,8 @@ mod recording;
36mod space;36mod space;
37mod table;37mod table;
38pub use format::{38pub use format::{
39 Alignment, BULLET_LIBRARY, FormatState, Formatting, ListStyle, NUMBER_LIBRARY, NoteTag, Toggle,39 Alignment, BULLET_LIBRARY, DefaultFont, FormatState, Formatting, ListStyle, NUMBER_LIBRARY,
40 NoteTag, Toggle,
40};41};
41pub use ink::{FAVORITES, Pen};42pub use ink::{FAVORITES, Pen};
42pub use link::{Link, shown_urls};43pub use link::{Link, shown_urls};
...@@ -81,6 +82,47 @@ pub struct Selection {...@@ -81,6 +82,47 @@ pub struct Selection {
81 pub affinities: [Affinity; 2],82 pub affinities: [Affinity; 2],
82}83}
8384
85/// A piece of pasted content, in the order it comes.
86pub enum Piece {
87 Text(String),
88 /// An encoded picture and its size in points.
89 Picture(Vec<u8>, [f32; 2]),
90 /// A picture still on its way, which ends the paragraph and goes in where
91 /// [`CanvasEditor::paste_pieces`] says once it arrives.
92 Awaited,
93}
94
95/// Where an awaited picture goes: before paragraph `before` of outline `outline`.
96#[derive(Clone, Copy, Debug, PartialEq)]
97pub struct Awaited {
98 outline: ExGuid,
99 before: ExGuid,
100}
101
102/// A new picture of encoded `bytes`, `size` points large.
103pub(crate) fn picture(
104 bytes: Vec<u8>,
105 size: [f32; 2],
106) -> Result<onestore::page::Image, EditError> {
107 Ok(onestore::page::Image {
108 id: onestore::page::text::new_id()?,
109 layout: onestore::document::Layout {
110 max_width: Some(size[0]),
111 max_height: Some(size[1]),
112 ..Default::default()
113 },
114 size: Some(size),
115 bytes: Some(bytes.into()),
116 display: None,
117 alt: None,
118 background: false,
119 printout: None,
120 tags: Vec::new(),
121 link: None,
122 text: None,
123 })
124}
125
84/// What OneNote 2010's Ctrl+A selects beyond text: the focused outline as an object, then every126/// What OneNote 2010's Ctrl+A selects beyond text: the focused outline as an object, then every
85/// body outline on the page.127/// body outline on the page.
86#[derive(Clone, Copy, Debug, PartialEq, Eq)]128#[derive(Clone, Copy, Debug, PartialEq, Eq)]
...@@ -102,7 +144,7 @@ pub struct CanvasEditor {...@@ -102,7 +144,7 @@ pub struct CanvasEditor {
102 pub(crate) objects: Vec<page::Content>,144 pub(crate) objects: Vec<page::Content>,
103 date: Option<PageDate>,145 date: Option<PageDate>,
104 outlines: Vec<TextOutline>,146 outlines: Vec<TextOutline>,
105 definitions: BTreeMap<ExGuid, Definition>,147 pub(crate) definitions: BTreeMap<ExGuid, Definition>,
106 header: PageHeader,148 header: PageHeader,
107 active: Focus,149 active: Focus,
108 undo: Vec<History>,150 undo: Vec<History>,
...@@ -130,6 +172,12 @@ pub struct CanvasEditor {...@@ -130,6 +172,12 @@ pub struct CanvasEditor {
130 /// and whether it is stored: the first op that reaches it stores it, and undoing back to it172 /// and whether it is stored: the first op that reaches it stores it, and undoing back to it
131 /// empty and alone removes it again, as OneNote 2010's undo does.173 /// empty and alone removes it again, as OneNote 2010's undo does.
132 provisional: BTreeMap<ExGuid, (PageParagraph, bool)>,174 provisional: BTreeMap<ExGuid, (PageParagraph, bool)>,
175 /// What new outlines' text is set in; `PageView::replace` hands it to the next page's
176 /// editor.
177 pub default_font: DefaultFont,
178 /// The paragraph styles the page's theme gives, by stored name: new text takes its `p`,
179 /// and Enter a NextStyle the page lacks from here.
180 pub styles: BTreeMap<String, Definition>,
133}181}
134182
135/// Imported page state the editable content does not carry.183/// Imported page state the editable content does not carry.
...@@ -847,6 +895,11 @@ enum History {...@@ -847,6 +895,11 @@ enum History {
847 index: usize,895 index: usize,
848 ink: Option<Box<onestore::page::Ink>>,896 ink: Option<Box<onestore::page::Ink>>,
849 },897 },
898 /// Gives a picture or file on the page back the note tags it had.
899 Tags {
900 object: ExGuid,
901 tags: Vec<onestore::document::Tag>,
902 },
850 /// Gives a drawing back the strokes it had.903 /// Gives a drawing back the strokes it had.
851 Strokes {904 Strokes {
852 ink: ExGuid,905 ink: ExGuid,
...@@ -912,6 +965,8 @@ impl CanvasEditor {...@@ -912,6 +965,8 @@ impl CanvasEditor {
912 recording: None,965 recording: None,
913 ink_drag: None,966 ink_drag: None,
914 provisional: BTreeMap::new(),967 provisional: BTreeMap::new(),
968 default_font: DefaultFont::default(),
969 styles: BTreeMap::new(),
915 })970 })
916 }971 }
917972
...@@ -1110,6 +1165,7 @@ impl CanvasEditor {...@@ -1110,6 +1165,7 @@ impl CanvasEditor {
1110 History::Date(_) => date_changed,1165 History::Date(_) => date_changed,
1111 History::Paper { .. } => false,1166 History::Paper { .. } => false,
1112 History::Image { .. }1167 History::Image { .. }
1168 | History::Tags { .. }
1113 | History::Picture { .. }1169 | History::Picture { .. }
1114 | History::File { .. }1170 | History::File { .. }
1115 | History::Ink { .. }1171 | History::Ink { .. }
...@@ -1153,6 +1209,8 @@ impl CanvasEditor {...@@ -1153,6 +1209,8 @@ impl CanvasEditor {
1153 fresh.preferred_x = self.preferred_x;1209 fresh.preferred_x = self.preferred_x;
1154 fresh.ops = std::mem::replace(&mut self.ops, Ok(Vec::new()));1210 fresh.ops = std::mem::replace(&mut self.ops, Ok(Vec::new()));
1155 fresh.stored = self.stored.take();1211 fresh.stored = self.stored.take();
1212 fresh.default_font = std::mem::take(&mut self.default_font);
1213 fresh.styles = std::mem::take(&mut self.styles);
1156 *self = fresh;1214 *self = fresh;
1157 Ok(true)1215 Ok(true)
1158 }1216 }
...@@ -1230,6 +1288,12 @@ impl CanvasEditor {...@@ -1230,6 +1288,12 @@ impl CanvasEditor {
1230 // OneNote stores it, holds only those its paragraphs reference.1288 // OneNote stores it, holds only those its paragraphs reference.
1231 let mut referenced = BTreeSet::new();1289 let mut referenced = BTreeSet::new();
1232 for object in &objects {1290 for object in &objects {
1291 let tags = match object {
1292 PageObject::Image(image) => image.tags.as_slice(),
1293 PageObject::Attachment(file) => file.tags.as_slice(),
1294 _ => &[],
1295 };
1296 referenced.extend(tags.iter().filter_map(|tag| tag.definition));
1233 let outlines = match object {1297 let outlines = match object {
1234 PageObject::Outline(outline) => std::slice::from_ref(outline),1298 PageObject::Outline(outline) => std::slice::from_ref(outline),
1235 PageObject::Title(title) => title.outlines.as_slice(),1299 PageObject::Title(title) => title.outlines.as_slice(),
...@@ -1327,6 +1391,11 @@ impl CanvasEditor {...@@ -1327,6 +1391,11 @@ impl CanvasEditor {
1327 self.header.color1391 self.header.color
1328 }1392 }
13291393
1394 /// The page's notebook-management identity, which internal links name.
1395 pub fn identity(&self) -> Option<[u8; 16]> {
1396 self.header.identity
1397 }
1398
1330 /// The page's rule lines (View, Rule Lines).1399 /// The page's rule lines (View, Rule Lines).
1331 pub fn rule_lines(&self) -> Option<onestore::page::RuleLines> {1400 pub fn rule_lines(&self) -> Option<onestore::page::RuleLines> {
1332 self.header.rule_lines1401 self.header.rule_lines
...@@ -1408,16 +1477,8 @@ impl CanvasEditor {...@@ -1408,16 +1477,8 @@ impl CanvasEditor {
1408 page::Content::Image(image) if !image.background => {1477 page::Content::Image(image) if !image.background => {
1409 let x = image.layout.x.unwrap_or(0.0);1478 let x = image.layout.x.unwrap_or(0.0);
1410 let y = image.layout.y.unwrap_or(0.0);1479 let y = image.layout.y.unwrap_or(0.0);
1411 Some((1480 let [width, height] = crate::outline::image_size(image)?;
1412 image.id,1481 Some((image.id, &image.layout, [x, y, x + width, y + height]))
1413 &image.layout,
1414 [
1415 x,
1416 y,
1417 x + image.layout.max_width?,
1418 y + image.layout.max_height?,
1419 ],
1420 ))
1421 }1482 }
1422 page::Content::ReadOnly(object)1483 page::Content::ReadOnly(object)
1423 if !matches!(object.source, onestore::page::PageObject::Title(_)) =>1484 if !matches!(object.source, onestore::page::PageObject::Title(_)) =>
...@@ -1508,6 +1569,8 @@ impl CanvasEditor {...@@ -1508,6 +1569,8 @@ impl CanvasEditor {
1508 recording: None,1569 recording: None,
1509 ink_drag: None,1570 ink_drag: None,
1510 provisional: BTreeMap::new(),1571 provisional: BTreeMap::new(),
1572 default_font: DefaultFont::default(),
1573 styles: BTreeMap::new(),
1511 })1574 })
1512 }1575 }
15131576
...@@ -1709,7 +1772,7 @@ impl CanvasEditor {...@@ -1709,7 +1772,7 @@ impl CanvasEditor {
1709 position: [f32; 2],1772 position: [f32; 2],
1710 width: f32,1773 width: f32,
1711 ) -> Result<(), EditorError> {1774 ) -> Result<(), EditorError> {
1712 let document = TextDocument::new(vec![Paragraph::new(String::new(), Default::default())])?;1775 let document = TextDocument::from_nodes(vec![self.body_paragraph()?])?;
1713 let outline = TextOutline::new(engine, document, width, position)?;1776 let outline = TextOutline::new(engine, document, width, position)?;
1714 self.finish_composition();1777 self.finish_composition();
1715 self.active = Focus::Caret {1778 self.active = Focus::Caret {
...@@ -1736,6 +1799,34 @@ impl CanvasEditor {...@@ -1736,6 +1799,34 @@ impl CanvasEditor {
1736 })1799 })
1737 }1800 }
17381801
1802 /// A new outline's first paragraph as OneNote 2010 starts one: in the page's `p` quick
1803 /// style of the Default font, which a page whose `p` is in another font gains beside it.
1804 fn body_paragraph(&mut self) -> Result<PageParagraph, EditError> {
1805 let definition = match self.styles.get("p") {
1806 Some(themed) => themed.clone(),
1807 None => self.default_font.body_style(),
1808 };
1809 let known = self.definitions.iter().find(|(_, known)| {
1810 matches!(&known.kind, Kind::Style { name: Some(name), .. } if name == "p")
1811 && known.format.font == definition.format.font
1812 && known.format.font_size == definition.format.font_size
1813 });
1814 let style = match known {
1815 Some((id, _)) => *id,
1816 None => {
1817 let id = onestore::page::text::new_id()?;
1818 self.definitions.insert(id, definition.clone());
1819 id
1820 }
1821 };
1822 let mut node = crate::document::node(
1823 Paragraph::new(String::new(), definition.format),
1824 onestore::document::Format::default(),
1825 )?;
1826 node.style = Some(style);
1827 Ok(node)
1828 }
1829
1739 /// An empty paragraph in `base`'s style, whose runs keep `base`'s language as OneNote's do.1830 /// An empty paragraph in `base`'s style, whose runs keep `base`'s language as OneNote's do.
1740 fn blank_paragraph(&self, base: &PageParagraph) -> Result<PageParagraph, EditError> {1831 fn blank_paragraph(&self, base: &PageParagraph) -> Result<PageParagraph, EditError> {
1741 let mut format = self.style_format(base.style)?;1832 let mut format = self.style_format(base.style)?;
...@@ -1844,10 +1935,7 @@ impl CanvasEditor {...@@ -1844,10 +1935,7 @@ impl CanvasEditor {
1844 position: [f32; 2],1935 position: [f32; 2],
1845 width: f32,1936 width: f32,
1846 ) -> Result<onestore::ExGuid, EditorError> {1937 ) -> Result<onestore::ExGuid, EditorError> {
1847 let document = TextDocument::new(vec![Paragraph::new(1938 let document = TextDocument::from_nodes(vec![self.body_paragraph()?])?;
1848 String::new(),
1849 onestore::document::Format::default(),
1850 )])?;
1851 let outline = TextOutline::new(engine, document, width, position)?;1939 let outline = TextOutline::new(engine, document, width, position)?;
1852 let id = outline.id;1940 let id = outline.id;
1853 self.finish_composition();1941 self.finish_composition();
...@@ -1935,7 +2023,7 @@ impl CanvasEditor {...@@ -1935,7 +2023,7 @@ impl CanvasEditor {
1935 let layout = &image.layout;2023 let layout = &image.layout;
1936 return Some((2024 return Some((
1937 [layout.x.unwrap_or(0.0), layout.y.unwrap_or(0.0)],2025 [layout.x.unwrap_or(0.0), layout.y.unwrap_or(0.0)],
1938 [layout.max_width?, layout.max_height?],2026 crate::outline::image_size(image)?,
1939 ));2027 ));
1940 }2028 }
1941 let (outline, ..) = self.outline_picture(id)?;2029 let (outline, ..) = self.outline_picture(id)?;
...@@ -1963,6 +2051,18 @@ impl CanvasEditor {...@@ -1963,6 +2051,18 @@ impl CanvasEditor {
1963 })2051 })
1964 }2052 }
19652053
2054 /// The address picture `id` links to, in an outline or on the page.
2055 pub fn picture_link(&self, id: ExGuid) -> Option<&str> {
2056 let image = match self.image(id) {
2057 Some(image) => image,
2058 None => match &self.outline_picture(id)?.3.content {
2059 ParagraphContent::Image(image) => image,
2060 _ => return None,
2061 },
2062 };
2063 image.link.as_deref()
2064 }
2065
1966 /// The file a paragraph holds or the page shows, which the host opens and saves.2066 /// The file a paragraph holds or the page shows, which the host opens and saves.
1967 pub fn attachment(&self, id: ExGuid) -> Option<&onestore::page::Attachment> {2067 pub fn attachment(&self, id: ExGuid) -> Option<&onestore::page::Attachment> {
1968 if let Some((file, _)) = self.page_file(id) {2068 if let Some((file, _)) = self.page_file(id) {
...@@ -2024,6 +2124,36 @@ impl CanvasEditor {...@@ -2024,6 +2124,36 @@ impl CanvasEditor {
2024 })2124 })
2025 }2125 }
20262126
2127 fn object_tags_mut(&mut self, id: ExGuid) -> Option<&mut Vec<onestore::document::Tag>> {
2128 self.objects.iter_mut().find_map(|object| match object {
2129 page::Content::Image(image) if image.id == id => Some(&mut image.tags),
2130 page::Content::File { source, .. } if source.id == id => Some(&mut source.tags),
2131 _ => None,
2132 })
2133 }
2134
2135 /// Gives picture or file `id` on the page `tags`, returning those it had.
2136 fn swap_object_tags(
2137 &mut self,
2138 id: ExGuid,
2139 tags: Vec<onestore::document::Tag>,
2140 ) -> Vec<onestore::document::Tag> {
2141 let previous = std::mem::replace(self.object_tags_mut(id).unwrap(), tags);
2142 let tags = self.object_tags_mut(id).unwrap().clone();
2143 self.record(Ok(ops::tags(id, tags, &self.definitions)));
2144 previous
2145 }
2146
2147 /// Gives picture or file `id` on the page `tags`, as one undo step.
2148 pub(super) fn set_object_tags(&mut self, id: ExGuid, tags: Vec<onestore::document::Tag>) {
2149 let previous = self.swap_object_tags(id, tags);
2150 self.undo.push(History::Tags {
2151 object: id,
2152 tags: previous,
2153 });
2154 self.redo.clear();
2155 }
2156
2027 fn image_mut(&mut self, id: ExGuid) -> Option<&mut onestore::page::Image> {2157 fn image_mut(&mut self, id: ExGuid) -> Option<&mut onestore::page::Image> {
2028 self.objects.iter_mut().find_map(|object| match object {2158 self.objects.iter_mut().find_map(|object| match object {
2029 page::Content::Image(image) if image.id == id && !image.background => Some(image),2159 page::Content::Image(image) if image.id == id && !image.background => Some(image),
...@@ -2079,7 +2209,7 @@ impl CanvasEditor {...@@ -2079,7 +2209,7 @@ impl CanvasEditor {
2079 let image = self.image(id).ok_or(EditError::InvalidRange)?;2209 let image = self.image(id).ok_or(EditError::InvalidRange)?;
2080 let mut layout = image.layout.clone();2210 let mut layout = image.layout.clone();
2081 [layout.x, layout.y] = origin.map(Some);2211 [layout.x, layout.y] = origin.map(Some);
2082 if [layout.max_width, layout.max_height] != size.map(Some) {2212 if crate::outline::image_size(image) != Some(size) {
2083 [layout.max_width, layout.max_height] = size.map(Some);2213 [layout.max_width, layout.max_height] = size.map(Some);
2084 layout.width_set_by_user = Some(true);2214 layout.width_set_by_user = Some(true);
2085 }2215 }
...@@ -2180,12 +2310,39 @@ impl CanvasEditor {...@@ -2180,12 +2310,39 @@ impl CanvasEditor {
21802310
2181 /// Puts `image` at the caret as OneNote 2010 pastes and inserts a picture: in an outline2311 /// Puts `image` at the caret as OneNote 2010 pastes and inserts a picture: in an outline
2182 /// as [`Self::insert_attachment`] puts a file, and on blank page at the caret with the2312 /// as [`Self::insert_attachment`] puts a file, and on blank page at the caret with the
2183 /// caret moving to the grid row below it, where typing starts a new outline.2313 /// caret moving to the grid row below it, where typing starts a new outline. From the
2314 /// title it goes at the end of an outline where the body starts, in a new one there on a
2315 /// page without body content, and otherwise on the page two grid rows below the content
2316 /// (lab, 2026-09-30).
2184 pub fn insert_picture(2317 pub fn insert_picture(
2185 &mut self,2318 &mut self,
2186 engine: &mut TextEngine,2319 engine: &mut TextEngine,
2187 mut image: onestore::page::Image,2320 mut image: onestore::page::Image,
2188 ) -> Result<(), EditorError> {2321 ) -> Result<(), EditorError> {
2322 let margin = self.margin_origin();
2323 if self.active_outline().title {
2324 let start = self.body_start_of(self.active_outline());
2325 let first = self
2326 .outlines
2327 .iter()
2328 .find(|outline| !outline.title && outline.origin() == start)
2329 .map(|outline| outline.id);
2330 if let Some(id) = first {
2331 self.focus_outline(id)?;
2332 self.move_selection(engine, Movement::DocumentEnd, false)?;
2333 return self.insert_in_flow(engine, ParagraphContent::Image(image));
2334 }
2335 let Some(bottom) = self.body_bottom() else {
2336 self.place_caret(engine, start, DEFAULT_OUTLINE_WIDTH)?;
2337 return self.insert_in_flow(engine, ParagraphContent::Image(image));
2338 };
2339 let row = ((bottom - margin[1]) / 18.0).floor() + 2.0;
2340 self.place_caret(
2341 engine,
2342 [start[0], margin[1] + row * 18.0],
2343 DEFAULT_OUTLINE_WIDTH,
2344 )?;
2345 }
2189 let Focus::Caret { outline, .. } = &self.active else {2346 let Focus::Caret { outline, .. } = &self.active else {
2190 return self.insert_in_flow(engine, ParagraphContent::Image(image));2347 return self.insert_in_flow(engine, ParagraphContent::Image(image));
2191 };2348 };
...@@ -2198,9 +2355,123 @@ impl CanvasEditor {...@@ -2198,9 +2355,123 @@ impl CanvasEditor {
2198 self.add_picture(index, image);2355 self.add_picture(index, image);
2199 self.undo.push(History::Picture { index, image: None });2356 self.undo.push(History::Picture { index, image: None });
2200 self.redo.clear();2357 self.redo.clear();
2201 let margin = self.margin_origin()[1];2358 let row = ((y + height - margin[1]) / 18.0).floor() + 1.0;
2202 let row = ((y + height - margin) / 18.0).floor() + 1.0;2359 self.place_caret(engine, [x, margin[1] + row * 18.0], DEFAULT_OUTLINE_WIDTH)
2203 self.place_caret(engine, [x, margin + row * 18.0], DEFAULT_OUTLINE_WIDTH)2360 }
2361
2362 /// Pastes text in `language` and pictures in order, as one undo step, as OneNote 2010
2363 /// pastes a web page's text with its pictures; the places of the awaited ones, in order.
2364 pub fn paste_pieces(
2365 &mut self,
2366 engine: &mut TextEngine,
2367 pieces: Vec<Piece>,
2368 language: u32,
2369 ) -> Result<Vec<Awaited>, EditorError> {
2370 let depth = self.undo.len();
2371 let mut result = Ok(());
2372 let mut awaited = Vec::new();
2373 for piece in pieces {
2374 result = match piece {
2375 Piece::Text(text) => self.paste(engine, &text, language),
2376 Piece::Picture(bytes, size) => picture(bytes, size)
2377 .map_err(EditorError::from)
2378 .and_then(|image| self.insert_picture(engine, image)),
2379 Piece::Awaited => self.enter(engine, false).and_then(|()| {
2380 let caret = self.selection().positions[1];
2381 let outline = self.active_outline();
2382 let (.., node) = outline
2383 .document
2384 .leaf(caret.paragraph)
2385 .ok_or(EditError::InvalidRange)?;
2386 awaited.push(Awaited {
2387 outline: outline.id,
2388 before: node.id,
2389 });
2390 Ok(())
2391 }),
2392 };
2393 if result.is_err() {
2394 break;
2395 }
2396 }
2397 let entries = self.undo.split_off(depth);
2398 if entries.len() > 1 {
2399 self.undo.push(History::Group {
2400 entries,
2401 page: false,
2402 });
2403 } else {
2404 self.undo.extend(entries);
2405 }
2406 result.map(|()| awaited)
2407 }
2408
2409 /// Puts an awaited picture in its place as an undo step of its own, keeping the focused
2410 /// outline's selection; false where the place has gone.
2411 pub fn insert_awaited(
2412 &mut self,
2413 engine: &mut TextEngine,
2414 at: Awaited,
2415 image: onestore::page::Image,
2416 ) -> Result<bool, EditorError> {
2417 let Some(outline) = self.outlines.iter().find(|outline| outline.id == at.outline) else {
2418 return Ok(false);
2419 };
2420 let Some((container, index, node)) = descendants(outline.document.nodes(), None)
2421 .find(|(.., node)| node.id == at.before)
2422 .map(|(container, index, node)| (container, index, node.clone()))
2423 else {
2424 return Ok(false);
2425 };
2426 let picture = PageParagraph {
2427 id: onestore::page::text::new_id()?,
2428 content: ParagraphContent::Image(image),
2429 style: None,
2430 lists: Vec::new(),
2431 tags: Vec::new(),
2432 collapsed: false,
2433 ..node
2434 };
2435 // A caret on blank page is let go; picture paragraphs take no text positions.
2436 let focused = match self.active {
2437 Focus::Outline(index) => Some((self.outlines[index].id, self.selection())),
2438 _ => None,
2439 };
2440 self.focus_outline(at.outline)?;
2441 let selection = self.selection();
2442 self.commit(
2443 engine,
2444 DocumentEdit {
2445 columns: BTreeMap::new(),
2446 container,
2447 range: index..index,
2448 replacement: vec![picture],
2449 },
2450 selection,
2451 )?;
2452 if let Some((id, selection)) = focused {
2453 self.focus_outline(id)?;
2454 self.select(selection)?;
2455 }
2456 Ok(true)
2457 }
2458
2459 /// The lowest edge of the page's body content, in page points; none on an empty page.
2460 fn body_bottom(&self) -> Option<f32> {
2461 let outlines = self
2462 .outlines
2463 .iter()
2464 .filter(|outline| !outline.title)
2465 .map(|outline| outline.bounds().y1 as f32);
2466 let objects = self
2467 .objects
2468 .iter()
2469 .filter(|object| !object.picture().is_some_and(|image| image.background))
2470 .filter_map(|object| {
2471 let (_, layout) = object.layout()?;
2472 Some(layout.y? + layout.max_height?)
2473 });
2474 outlines.chain(objects).reduce(f32::max)
2204 }2475 }
22052476
2206 /// Attaches `file` at the caret as OneNote 2010 does (`corpus/attachment-insert`): the2477 /// Attaches `file` at the caret as OneNote 2010 does (`corpus/attachment-insert`): the
...@@ -2316,19 +2587,7 @@ impl CanvasEditor {...@@ -2316,19 +2587,7 @@ impl CanvasEditor {
2316 })2587 })
2317 .ok_or(EditError::InvalidRange)?;2588 .ok_or(EditError::InvalidRange)?;
2318 self.finish_composition();2589 self.finish_composition();
2319 let mut image = self.remove_picture(index);2590 let image = self.remove_picture(index);
2320 // A printout page's XPS package cannot be written back, so undoing its deletion
2321 // restores the picture OneNote showed of it.
2322 if image.printout.is_some()
2323 && image
2324 .bytes
2325 .as_deref()
2326 .is_some_and(|bytes| bytes.starts_with(b"PK\x03\x04"))
2327 && let Some(shown) = image.display.take()
2328 {
2329 image.bytes = Some(shown);
2330 image.printout = None;
2331 }
2332 self.undo.push(History::Picture {2591 self.undo.push(History::Picture {
2333 index,2592 index,
2334 image: Some(Box::new(image)),2593 image: Some(Box::new(image)),
...@@ -3401,8 +3660,13 @@ impl CanvasEditor {...@@ -3401,8 +3660,13 @@ impl CanvasEditor {
3401 tail.tags = std::mem::take(&mut head.tags);3660 tail.tags = std::mem::take(&mut head.tags);
3402 tail.text_mut().unwrap().tags = std::mem::take(&mut head.text_mut().unwrap().tags);3661 tail.text_mut().unwrap().tags = std::mem::take(&mut head.text_mut().unwrap().tags);
3403 }3662 }
3404 let style = |id: Option<ExGuid>| match &self.definitions.get(&id?)?.kind {3663 // Enter at a paragraph's end gives the new one its style's NextStyle, as OneNote 2010
3405 Kind::Style { name } => name.as_deref(),3664 // follows a heading with Normal.
3665 let following = match head.style.and_then(|id| self.definitions.get(&id)) {
3666 Some(Definition {
3667 kind: Kind::Style { name, next },
3668 ..
3669 }) => next.clone().filter(|next| Some(next) != name.as_ref()),
3406 _ => None,3670 _ => None,
3407 };3671 };
3408 if empty(head) && empty(tail) {3672 if empty(head) && empty(tail) {
...@@ -3411,16 +3675,9 @@ impl CanvasEditor {...@@ -3411,16 +3675,9 @@ impl CanvasEditor {
3411 tail.lists.clear();3675 tail.lists.clear();
3412 }3676 }
3413 } else if empty(tail)3677 } else if empty(tail)
3414 && matches!(3678 && let Some(following) = following
3415 style(head.style),
3416 Some("h1" | "h2" | "h3" | "h4" | "h5" | "h6")
3417 )
3418 {3679 {
3419 tail.style = self3680 tail.style = self.style_named(&following)?;
3420 .definitions
3421 .keys()
3422 .copied()
3423 .find(|id| style(Some(*id)) == Some("p"));
3424 // The heading's run formatting carries over under the body style (`c9-h1-*`).3681 // The heading's run formatting carries over under the body style (`c9-h1-*`).
3425 let run = format.over(&self.style_format(head.style)?);3682 let run = format.over(&self.style_format(head.style)?);
3426 tail.text_mut().unwrap().text =3683 tail.text_mut().unwrap().text =
...@@ -3730,6 +3987,26 @@ impl CanvasEditor {...@@ -3730,6 +3987,26 @@ impl CanvasEditor {
3730 !self.redo.is_empty()3987 !self.redo.is_empty()
3731 }3988 }
37323989
3990 /// How many steps Undo and Redo hold.
3991 pub fn history_depth(&self) -> [usize; 2] {
3992 [self.undo.len(), self.redo.len()]
3993 }
3994
3995 /// Takes up `parked`, the editor this page had when it was last left, history and all,
3996 /// in place of this one: what storage changed since reaches it as `refresh` would.
3997 pub fn resume(
3998 &mut self,
3999 mut parked: CanvasEditor,
4000 engine: &mut TextEngine,
4001 ) -> Result<(), EditorError> {
4002 if let Some((page, _)) = &self.stored {
4003 parked.refresh(page.clone(), engine)?;
4004 parked.default_font = std::mem::take(&mut self.default_font);
4005 *self = parked;
4006 }
4007 Ok(())
4008 }
4009
3733 pub fn undo(&mut self, engine: &mut TextEngine) -> Result<bool, EditorError> {4010 pub fn undo(&mut self, engine: &mut TextEngine) -> Result<bool, EditorError> {
3734 if self.composition.is_some() {4011 if self.composition.is_some() {
3735 return self.cancel_composition(engine);4012 return self.cancel_composition(engine);
...@@ -3842,6 +4119,7 @@ impl CanvasEditor {...@@ -3842,6 +4119,7 @@ impl CanvasEditor {
3842 .iter()4119 .iter()
3843 .all(|id| self.outlines.iter().any(|item| item.id == **id)),4120 .all(|id| self.outlines.iter().any(|item| item.id == **id)),
3844 History::Image { image, .. } => self.image(*image).is_some(),4121 History::Image { image, .. } => self.image(*image).is_some(),
4122 History::Tags { object, .. } => self.object_tags_mut(*object).is_some(),
3845 History::Picture {4123 History::Picture {
3846 index,4124 index,
3847 image: Some(_),4125 image: Some(_),
...@@ -4073,6 +4351,10 @@ impl CanvasEditor {...@@ -4073,6 +4351,10 @@ impl CanvasEditor {
4073 layout: previous,4351 layout: previous,
4074 }4352 }
4075 }4353 }
4354 History::Tags { object, tags } => History::Tags {
4355 object,
4356 tags: self.swap_object_tags(object, tags),
4357 },
4076 History::Image { image, layout } => {4358 History::Image { image, layout } => {
4077 let previous =4359 let previous =
4078 std::mem::replace(&mut self.image_mut(image).unwrap().layout, layout);4360 std::mem::replace(&mut self.image_mut(image).unwrap().layout, layout);
...@@ -4262,6 +4544,7 @@ impl CanvasEditor {...@@ -4262,6 +4544,7 @@ impl CanvasEditor {
4262 .replace(range.clone(), replacement)?;4544 .replace(range.clone(), replacement)?;
4263 self.own_lists(&mut edit)?;4545 self.own_lists(&mut edit)?;
4264 self.link_notes(&mut edit)?;4546 self.link_notes(&mut edit)?;
4547 self.fit_columns(engine, &mut edit)?;
4265 let inverse = self.apply(4548 let inverse = self.apply(
4266 engine,4549 engine,
4267 edit,4550 edit,
...@@ -4361,6 +4644,7 @@ impl CanvasEditor {...@@ -4361,6 +4644,7 @@ impl CanvasEditor {
4361 ) -> Result<(), EditorError> {4644 ) -> Result<(), EditorError> {
4362 self.own_lists(&mut edit)?;4645 self.own_lists(&mut edit)?;
4363 self.link_notes(&mut edit)?;4646 self.link_notes(&mut edit)?;
4647 self.fit_columns(engine, &mut edit)?;
4364 let inverse = self.apply(engine, edit, selection, true, None)?;4648 let inverse = self.apply(engine, edit, selection, true, None)?;
4365 self.record_change(inverse);4649 self.record_change(inverse);
4366 Ok(())4650 Ok(())
...@@ -6152,6 +6436,9 @@ mod tests {...@@ -6152,6 +6436,9 @@ mod tests {
6152 alt: None,6436 alt: None,
6153 background,6437 background,
6154 printout: None,6438 printout: None,
6439 tags: Vec::new(),
6440 link: None,
6441 text: None,
6155 });6442 });
6156 let (id, background_id) = (picture.id, background.id);6443 let (id, background_id) = (picture.id, background.id);
6157 let mut editor = CanvasEditor::from_page(6444 let mut editor = CanvasEditor::from_page(
...@@ -6260,6 +6547,9 @@ mod tests {...@@ -6260,6 +6547,9 @@ mod tests {
6260 alt: None,6547 alt: None,
6261 background,6548 background,
6262 printout: None,6549 printout: None,
6550 tags: Vec::new(),
6551 link: None,
6552 text: None,
6263 };6553 };
6264 let [old, picture] = [image(true, b"old art"), image(false, b"picture")];6554 let [old, picture] = [image(true, b"old art"), image(false, b"picture")];
6265 let mut editor = CanvasEditor::from_page(6555 let mut editor = CanvasEditor::from_page(
...@@ -6349,6 +6639,9 @@ mod tests {...@@ -6349,6 +6639,9 @@ mod tests {
6349 alt: None,6639 alt: None,
6350 background: false,6640 background: false,
6351 printout: None,6641 printout: None,
6642 tags: Vec::new(),
6643 link: None,
6644 text: None,
6352 };6645 };
6353 let id = picture.id;6646 let id = picture.id;
6354 let objects = vec![6647 let objects = vec![
...@@ -6414,6 +6707,9 @@ mod tests {...@@ -6414,6 +6707,9 @@ mod tests {
6414 alt: None,6707 alt: None,
6415 background: false,6708 background: false,
6416 printout: None,6709 printout: None,
6710 tags: Vec::new(),
6711 link: None,
6712 text: None,
6417 };6713 };
6418 picture.content = ParagraphContent::Image(image.clone());6714 picture.content = ParagraphContent::Image(image.clone());
6419 source.paragraphs.insert(1, picture);6715 source.paragraphs.insert(1, picture);
...@@ -6562,6 +6858,7 @@ mod tests {...@@ -6562,6 +6858,7 @@ mod tests {
6562 bytes: Some(std::sync::Arc::from(b"payload".as_slice())),6858 bytes: Some(std::sync::Arc::from(b"payload".as_slice())),
6563 preview: None,6859 preview: None,
6564 recording: None,6860 recording: None,
6861 tags: Vec::new(),
6565 };6862 };
6566 let at = |paragraph, offset| Selection {6863 let at = |paragraph, offset| Selection {
6567 positions: [TextPosition { paragraph, offset }; 2],6864 positions: [TextPosition { paragraph, offset }; 2],
...@@ -6651,6 +6948,9 @@ mod tests {...@@ -6651,6 +6948,9 @@ mod tests {
6651 alt: None,6948 alt: None,
6652 background: false,6949 background: false,
6653 printout: None,6950 printout: None,
6951 tags: Vec::new(),
6952 link: None,
6953 text: None,
6654 });6954 });
6655 let (mixed_id, picture_id) = (mixed.id, picture_only.id);6955 let (mixed_id, picture_id) = (mixed.id, picture_only.id);
6656 let editor = CanvasEditor::from_page(6956 let editor = CanvasEditor::from_page(
...@@ -6736,6 +7036,9 @@ mod tests {...@@ -6736,6 +7036,9 @@ mod tests {
6736 alt: None,7036 alt: None,
6737 background,7037 background,
6738 printout: None,7038 printout: None,
7039 tags: Vec::new(),
7040 link: None,
7041 text: None,
6739 });7042 });
6740 let image_ids = images.each_ref().map(|image| image.id);7043 let image_ids = images.each_ref().map(|image| image.id);
6741 let image_bytes = images7044 let image_bytes = images
...@@ -6990,6 +7293,7 @@ mod tests {...@@ -6990,6 +7293,7 @@ mod tests {
6990 Definition {7293 Definition {
6991 kind: onestore::document::Kind::Style {7294 kind: onestore::document::Kind::Style {
6992 name: Some("p".into()),7295 name: Some("p".into()),
7296 next: None,
6993 },7297 },
6994 format: base.clone(),7298 format: base.clone(),
6995 },7299 },
...@@ -7057,6 +7361,7 @@ mod tests {...@@ -7057,6 +7361,7 @@ mod tests {
7057 Definition {7361 Definition {
7058 kind: onestore::document::Kind::Style {7362 kind: onestore::document::Kind::Style {
7059 name: Some("p".into()),7363 name: Some("p".into()),
7364 next: None,
7060 },7365 },
7061 format: base.clone(),7366 format: base.clone(),
7062 },7367 },
...@@ -7258,6 +7563,47 @@ mod tests {...@@ -7258,6 +7563,47 @@ mod tests {
7258 assert_eq!(editor.outlines[0].document, source);7563 assert_eq!(editor.outlines[0].document, source);
7259 }7564 }
72607565
7566 /// New text takes the page's `p` quick style in the Default font, and a page whose `p`
7567 /// is in another font gains a second `p` beside it, as OneNote 2010 does
7568 /// (`corpus/default-font`).
7569 #[test]
7570 fn new_outlines_take_a_body_style_in_the_default_font() {
7571 let mut engine = TextEngine::default();
7572 let source =
7573 TextDocument::new(vec![Paragraph::new("body".into(), Format::default())]).unwrap();
7574 let mut editor = CanvasEditor::new(&mut engine, source, 240.0).unwrap();
7575 let style = |editor: &CanvasEditor, outline: usize| {
7576 let node = &editor.outlines[outline].document.nodes()[0];
7577 let definition = &editor.definitions[&node.style.unwrap()];
7578 assert_eq!(
7579 node.text().unwrap().text.spans()[0].format,
7580 definition.format
7581 );
7582 (node.style.unwrap(), definition.clone())
7583 };
7584 editor.default_font = DefaultFont {
7585 face: "Georgia".into(),
7586 size: 14.0,
7587 color: None,
7588 };
7589 for (typed, at) in [("one", 300.0), ("two", 400.0)] {
7590 editor.place_caret(&mut engine, [300.0, at], 240.0).unwrap();
7591 editor.insert(&mut engine, typed).unwrap();
7592 }
7593 let (first, georgia) = style(&editor, 1);
7594 assert_eq!(style(&editor, 2).0, first, "one p per font");
7595 assert_eq!(georgia, editor.default_font.body_style());
7596 editor.default_font = DefaultFont::default();
7597 editor
7598 .place_caret(&mut engine, [300.0, 500.0], 240.0)
7599 .unwrap();
7600 editor.insert(&mut engine, "three").unwrap();
7601 let (third, calibri) = style(&editor, 3);
7602 assert_ne!(third, first);
7603 assert_eq!(calibri.format.font.as_deref(), Some("Calibri"));
7604 assert_eq!(calibri.format.font_size, Some(11.0));
7605 }
7606
7261 #[test]7607 #[test]
7262 fn deleting_through_provisional_paragraphs_removes_the_outline_and_restores_draft_on_undo() {7608 fn deleting_through_provisional_paragraphs_removes_the_outline_and_restores_draft_on_undo() {
7263 let mut engine = TextEngine::default();7609 let mut engine = TextEngine::default();
...@@ -9314,6 +9660,9 @@ mod tests {...@@ -9314,6 +9660,9 @@ mod tests {
9314 alt: None,9660 alt: None,
9315 background: false,9661 background: false,
9316 printout: None,9662 printout: None,
9663 tags: Vec::new(),
9664 link: None,
9665 text: None,
9317 });9666 });
9318 if objects {9667 if objects {
9319 let selection = [TextPosition {9668 let selection = [TextPosition {
crates/canvas/src/editor/evidence.rs+2-1
...@@ -47,6 +47,7 @@ fn open(engine: &mut TextEngine, summary: &str) -> CanvasEditor {...@@ -47,6 +47,7 @@ fn open(engine: &mut TextEngine, summary: &str) -> CanvasEditor {
47 Definition {47 Definition {
48 kind: Kind::Style {48 kind: Kind::Style {
49 name: Some(name.into()),49 name: Some(name.into()),
50 next: (name == "h1").then(|| "p".into()),
50 },51 },
51 format: calibri(if name == "p" { 11.0 } else { 16.0 }, name != "p"),52 format: calibri(if name == "p" { 11.0 } else { 16.0 }, name != "p"),
52 },53 },
...@@ -182,7 +183,7 @@ fn render(editor: &CanvasEditor) -> String {...@@ -182,7 +183,7 @@ fn render(editor: &CanvasEditor) -> String {
182 let style = node183 let style = node
183 .style184 .style
184 .map(|style| match &editor.definitions[&style].kind {185 .map(|style| match &editor.definitions[&style].kind {
185 Kind::Style { name } => {186 Kind::Style { name, .. } => {
186 format!("qs{}", usize::from(name.as_deref() == Some("h1")))187 format!("qs{}", usize::from(name.as_deref() == Some("h1")))
187 }188 }
188 kind => panic!("{kind:?}"),189 kind => panic!("{kind:?}"),
crates/canvas/src/editor/format.rs+401-46
...@@ -43,6 +43,10 @@ pub enum Formatting {...@@ -43,6 +43,10 @@ pub enum Formatting {
43 /// Format Painter: gives the selection the character formatting and alignment of text43 /// Format Painter: gives the selection the character formatting and alignment of text
44 /// picked up with [`CanvasEditor::painted_format`], as OneNote's does.44 /// picked up with [`CanvasEditor::painted_format`], as OneNote's does.
45 Paint(Format),45 Paint(Format),
46 /// Gives the selected paragraphs a paragraph style, as OneNote 2010's Styles gallery
47 /// does: the page's style of that definition, made where it has none, and their text
48 /// loses its character formatting but links, fields and language.
49 Style(Definition),
46}50}
4751
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]52#[derive(Clone, Copy, Debug, PartialEq, Eq)]
...@@ -164,6 +168,57 @@ impl ListStyle {...@@ -164,6 +168,57 @@ impl ListStyle {
164 }168 }
165}169}
166170
171/// OneNote's Default font (Options > General): what new outlines' text is set in, as the
172/// page's `p` quick style, and the face and colour new titles take.
173#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
174#[serde(default)]
175pub struct DefaultFont {
176 pub face: String,
177 pub size: f32,
178 /// COLORREF; none is Automatic.
179 pub color: Option<u32>,
180}
181
182impl Default for DefaultFont {
183 /// OneNote 2010's: Calibri 11, Automatic.
184 fn default() -> Self {
185 Self {
186 face: "Calibri".into(),
187 size: 11.0,
188 color: None,
189 }
190 }
191}
192
193impl DefaultFont {
194 /// The `p` quick style OneNote 2010 stores for new text in this font
195 /// (`corpus/default-font`): plain, in its colour, no highlight or paragraph spacing.
196 pub(crate) fn body_style(&self) -> Definition {
197 Definition {
198 kind: Kind::Style {
199 name: Some("p".into()),
200 next: None,
201 },
202 format: Format {
203 bold: Some(false),
204 italic: Some(false),
205 underline: Some(false),
206 strike: Some(false),
207 superscript: Some(false),
208 subscript: Some(false),
209 font: Some(self.face.clone()),
210 font_size: Some(self.size),
211 color: Some(self.color.unwrap_or(0xff00_0000)),
212 highlight: Some(0xff00_0000),
213 space_before: Some(0.0),
214 space_after: Some(0.0),
215 line_spacing: Some(0.0),
216 ..Format::default()
217 },
218 }
219 }
220}
221
167/// A tag of the user's list as the Customize Tags dialog edits it: what its definition222/// A tag of the user's list as the Customize Tags dialog edits it: what its definition
168/// stores besides its place in the list, which the page stores as its action type.223/// stores besides its place in the list, which the page stores as its action type.
169#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]224#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
...@@ -293,6 +348,8 @@ pub struct FormatState {...@@ -293,6 +348,8 @@ pub struct FormatState {
293 pub list: Option<ListStyle>,348 pub list: Option<ListStyle>,
294 /// Tags every selected paragraph has, with their action types.349 /// Tags every selected paragraph has, with their action types.
295 pub tags: Vec<(NoteTag, u16)>,350 pub tags: Vec<(NoteTag, u16)>,
351 /// The stored name of the paragraph style every selected paragraph shares.
352 pub style: Option<String>,
296}353}
297354
298impl Toggle {355impl Toggle {
...@@ -477,6 +534,35 @@ fn common<T: PartialEq>(mut values: impl Iterator<Item = Option<T>>) -> Option<T...@@ -477,6 +534,35 @@ fn common<T: PartialEq>(mut values: impl Iterator<Item = Option<T>>) -> Option<T
477 .then_some(first)534 .then_some(first)
478}535}
479536
537/// `format` moved from paragraph style `old` to `new`: what the old style gave, or nothing
538/// did, the new one gives.
539fn followed(format: &Format, old: &Format, new: &Format) -> Format {
540 let mut format = format.clone();
541 macro_rules! follow {
542 ($($field:ident),*) => {$(
543 if format.$field.is_none() || old.$field.is_some() && format.$field == old.$field {
544 format.$field = new.$field.clone();
545 }
546 )*};
547 }
548 follow!(
549 bold,
550 italic,
551 underline,
552 strike,
553 superscript,
554 subscript,
555 font,
556 font_size,
557 color,
558 highlight,
559 space_before,
560 space_after,
561 line_spacing
562 );
563 format
564}
565
480/// Seconds since 1980, as note tags date themselves.566/// Seconds since 1980, as note tags date themselves.
481fn time32() -> Option<u32> {567fn time32() -> Option<u32> {
482 let now = std::time::SystemTime::now()568 let now = std::time::SystemTime::now()
...@@ -629,6 +715,12 @@ impl CanvasEditor {...@@ -629,6 +715,12 @@ impl CanvasEditor {
629 shared.sort_by_key(|(_, action_type)| *action_type);715 shared.sort_by_key(|(_, action_type)| *action_type);
630 shared716 shared
631 }),717 }),
718 style: common(paragraphs.iter().map(|node| {
719 match &self.definitions.get(&node.style?)?.kind {
720 Kind::Style { name, .. } => name.clone(),
721 _ => None,
722 }
723 })),
632 })724 })
633 }725 }
634726
...@@ -765,36 +857,8 @@ impl CanvasEditor {...@@ -765,36 +857,8 @@ impl CanvasEditor {
765 let ParagraphContent::Text(text) = &mut node.content else {857 let ParagraphContent::Text(text) = &mut node.content else {
766 unreachable!()858 unreachable!()
767 };859 };
768 // An element holds one tag of each action type.860 let added = (!remove).then_some((id, tag.shape, created));
769 for tags in [&mut node.tags, &mut text.tags] {861 self.retag([&mut node.tags, &mut text.tags], &definition.kind, added);
770 tags.retain(|tag| match self.tag_kind(tag) {
771 Some(kind) if remove => *kind != definition.kind,
772 Some(Kind::TagDefinition {
773 action_type: other, ..
774 }) => *other != Some(*action_type),
775 _ => true,
776 });
777 }
778 if !remove {
779 let checkable = crate::outline::checkable(tag.shape);
780 // Stored newest first.
781 text.tags.insert(
782 0,
783 Tag {
784 definition: Some(id),
785 action_type: None,
786 shape: None,
787 property_status: None,
788 status: u16::from(!checkable),
789 created,
790 completed: if checkable { Some(0) } else { created },
791 start: None,
792 due: None,
793 task_id: None,
794 extra_set: 0,
795 },
796 );
797 }
798 });862 });
799 }863 }
800 Formatting::RemoveTags => leaves_mut(&mut replacement, &mut |node| {864 Formatting::RemoveTags => leaves_mut(&mut replacement, &mut |node| {
...@@ -804,6 +868,39 @@ impl CanvasEditor {...@@ -804,6 +868,39 @@ impl CanvasEditor {
804 }868 }
805 }),869 }),
806 Formatting::Check => self.check(&mut replacement, ranges, ends),870 Formatting::Check => self.check(&mut replacement, ranges, ends),
871 Formatting::Style(_) if title => return Ok(()),
872 Formatting::Style(definition) => {
873 let olds = bases(&replacement)?;
874 let style = self.define_style(definition)?;
875 leaves_mut(&mut replacement, &mut |node| {
876 if ranges.remove(&node.id).is_some() {
877 node.style = Some(style);
878 let old = &olds[&node.id];
879 let text = &mut node.text_mut().unwrap().text;
880 *text = restyle(text, 0..text.text().len(), |format| {
881 // An equation keeps its own formatting; what it took from the
882 // old style it takes from the new.
883 if [format.math, format.embedded_object].contains(&Some(true)) {
884 *format = followed(format, old, &definition.format);
885 return;
886 }
887 *format = Format {
888 hidden: format.hidden,
889 hyperlink: format.hyperlink,
890 hyperlink_label: format.hyperlink_label,
891 math: format.math,
892 embedded_object: format.embedded_object,
893 language: format.language,
894 alignment: format.alignment,
895 rtl: format.rtl,
896 list_spacing: format.list_spacing,
897 math_object: format.math_object.clone(),
898 ..definition.format.clone()
899 };
900 });
901 }
902 });
903 }
807 Formatting::Toggle(_)904 Formatting::Toggle(_)
808 | Formatting::Font(_)905 | Formatting::Font(_)
809 | Formatting::FontSize(_)906 | Formatting::FontSize(_)
...@@ -906,6 +1003,43 @@ impl CanvasEditor {...@@ -906,6 +1003,43 @@ impl CanvasEditor {
906 }1003 }
907}1004}
9081005
1006impl CanvasEditor {
1007 /// The page's paragraph style holding `definition`, made where it has none.
1008 pub(super) fn define_style(&mut self, definition: &Definition) -> Result<ExGuid, EditError> {
1009 if let Some((id, _)) = self
1010 .definitions
1011 .iter()
1012 .find(|(_, kept)| *kept == definition)
1013 {
1014 return Ok(*id);
1015 }
1016 let id = new_id()?;
1017 self.definitions.insert(id, definition.clone());
1018 Ok(id)
1019 }
1020
1021 /// A paragraph style named `name`: the page's in its theme's formatting, else the page's
1022 /// first of that name, else the theme's made on the page; Normal falls back to the
1023 /// Default font's. None where nothing names it.
1024 pub(super) fn style_named(&mut self, name: &str) -> Result<Option<ExGuid>, EditError> {
1025 let themed = self.styles.get(name).cloned();
1026 let named = |definition: &Definition| matches!(&definition.kind, Kind::Style { name: Some(own), .. } if own == name);
1027 let known = self
1028 .definitions
1029 .iter()
1030 .filter(|(_, kept)| named(kept))
1031 .min_by_key(|(_, kept)| Some(*kept) != themed.as_ref())
1032 .map(|(id, _)| *id);
1033 if known.is_some() {
1034 return Ok(known);
1035 }
1036 let definition = themed.or_else(|| (name == "p").then(|| self.default_font.body_style()));
1037 definition
1038 .map(|definition| self.define_style(&definition))
1039 .transpose()
1040 }
1041}
1042
909impl CanvasEditor {1043impl CanvasEditor {
910 /// A toolbar command on OneNote 2010's page selection: each outline takes it as if selected1044 /// A toolbar command on OneNote 2010's page selection: each outline takes it as if selected
911 /// alone, but a toggle turns on everywhere unless every outline already has it.1045 /// alone, but a toggle turns on everywhere unless every outline already has it.
...@@ -991,6 +1125,167 @@ impl CanvasEditor {...@@ -991,6 +1125,167 @@ impl CanvasEditor {
991 .find(|(_, _, node)| node.id == id)1125 .find(|(_, _, node)| node.id == id)
992 .ok_or(EditError::InvalidRange)?;1126 .ok_or(EditError::InvalidRange)?;
993 let mut node = node.clone();1127 let mut node = node.clone();
1128 let content = match &mut node.content {
1129 ParagraphContent::Table(table) => table.tags.as_mut_slice(),
1130 ParagraphContent::Image(image) => image.tags.as_mut_slice(),
1131 ParagraphContent::Attachment(file) => file.tags.as_mut_slice(),
1132 _ => &mut [],
1133 };
1134 self.toggle_checks(node.tags.iter_mut().chain(content).collect());
1135 let selection = outline.selection;
1136 self.commit(
1137 engine,
1138 DocumentEdit {
1139 columns: BTreeMap::new(),
1140 container,
1141 range: index..index + 1,
1142 replacement: vec![node],
1143 },
1144 selection,
1145 )
1146 }
1147
1148 /// A tag command on selected picture or file `id`, which OneNote 2010 tags itself rather
1149 /// than its paragraph (`corpus/object-tags`); false for other commands.
1150 pub fn format_object(
1151 &mut self,
1152 engine: &mut TextEngine,
1153 id: ExGuid,
1154 command: &Formatting,
1155 ) -> Result<bool, EditorError> {
1156 if !matches!(
1157 command,
1158 Formatting::Tag(..) | Formatting::RemoveTags | Formatting::Check
1159 ) {
1160 return Ok(false);
1161 }
1162 let node = self
1163 .outline_picture(id)
1164 .map(|(outline, _, _, node)| (outline, node.clone()));
1165 let mut tags = match &node {
1166 Some((_, node)) => match &node.content {
1167 ParagraphContent::Image(image) => image.tags.clone(),
1168 ParagraphContent::Attachment(file) => file.tags.clone(),
1169 _ => return Ok(false),
1170 },
1171 None => self
1172 .object_tags_mut(id)
1173 .ok_or(EditError::InvalidRange)?
1174 .clone(),
1175 };
1176 match command {
1177 Formatting::Tag(tag, action_type) => {
1178 let definition = tag.definition(*action_type);
1179 let existing = self
1180 .definitions
1181 .iter()
1182 .find(|(_, other)| other.kind == definition.kind)
1183 .map(|(id, _)| *id);
1184 let defined = match existing {
1185 Some(id) => id,
1186 None => {
1187 let id = new_id()?;
1188 self.definitions.insert(id, definition.clone());
1189 id
1190 }
1191 };
1192 let has = tags
1193 .iter()
1194 .any(|tag| self.tag_kind(tag) == Some(&definition.kind));
1195 let added = (!has).then_some((defined, tag.shape, time32()));
1196 self.retag([&mut tags], &definition.kind, added);
1197 }
1198 Formatting::RemoveTags => tags.clear(),
1199 _ => self.toggle_checks(tags.iter_mut().collect()),
1200 }
1201 self.finish_composition();
1202 let Some((outline, mut node)) = node else {
1203 self.set_object_tags(id, tags);
1204 return Ok(true);
1205 };
1206 match &mut node.content {
1207 ParagraphContent::Image(image) => image.tags = tags,
1208 ParagraphContent::Attachment(file) => file.tags = tags,
1209 _ => unreachable!(),
1210 }
1211 self.focus_outline(outline)?;
1212 let (container, index, _) = descendants(self.active_outline().document.nodes(), None)
1213 .find(|(_, _, other)| other.id == node.id)
1214 .ok_or(EditError::InvalidRange)?;
1215 let selection = self.active_outline().selection;
1216 self.commit(
1217 engine,
1218 DocumentEdit {
1219 columns: BTreeMap::new(),
1220 container,
1221 range: index..index + 1,
1222 replacement: vec![node],
1223 },
1224 selection,
1225 )?;
1226 Ok(true)
1227 }
1228
1229 /// A click on the check box of picture or file `id` on the page.
1230 pub fn click_object_check(&mut self, id: ExGuid) -> Result<(), EditorError> {
1231 let mut tags = self
1232 .objects
1233 .iter()
1234 .find_map(|object| object.tagged().filter(|tagged| tagged.0 == id))
1235 .ok_or(EditError::InvalidRange)?
1236 .1
1237 .to_vec();
1238 self.toggle_checks(tags.iter_mut().collect());
1239 self.finish_composition();
1240 self.set_object_tags(id, tags);
1241 Ok(())
1242 }
1243
1244 /// Takes tags of `kind` from `lists`, or with `added`, the definition's identity, its
1245 /// shape and when, gives the last list one in place of any of its action type; an element
1246 /// holds one tag of each action type, stored newest first.
1247 fn retag<const N: usize>(
1248 &self,
1249 mut lists: [&mut Vec<Tag>; N],
1250 kind: &Kind<'static>,
1251 added: Option<(ExGuid, u16, Option<u32>)>,
1252 ) {
1253 let Kind::TagDefinition { action_type, .. } = kind else {
1254 return;
1255 };
1256 for tags in lists.iter_mut() {
1257 tags.retain(|tag| match self.tag_kind(tag) {
1258 Some(other) if added.is_none() => other != kind,
1259 Some(Kind::TagDefinition {
1260 action_type: other, ..
1261 }) => other != action_type,
1262 _ => true,
1263 });
1264 }
1265 let (Some((id, shape, created)), Some(tags)) = (added, lists.last_mut()) else {
1266 return;
1267 };
1268 let checkable = crate::outline::checkable(shape);
1269 tags.insert(
1270 0,
1271 Tag {
1272 definition: Some(id),
1273 action_type: None,
1274 shape: None,
1275 property_status: None,
1276 status: u16::from(!checkable),
1277 created,
1278 completed: if checkable { Some(0) } else { created },
1279 start: None,
1280 due: None,
1281 task_id: None,
1282 extra_set: 0,
1283 },
1284 );
1285 }
1286
1287 /// Checks the check boxes among `tags`, or clears them once all are checked.
1288 fn toggle_checks(&self, mut tags: Vec<&mut Tag>) {
994 let checkable = |tag: &Tag| {1289 let checkable = |tag: &Tag| {
995 matches!(1290 matches!(
996 self.tag_kind(tag),1291 self.tag_kind(tag),
...@@ -998,11 +1293,6 @@ impl CanvasEditor {...@@ -998,11 +1293,6 @@ impl CanvasEditor {
998 if crate::outline::checkable(*shape)1293 if crate::outline::checkable(*shape)
999 )1294 )
1000 };1295 };
1001 let content = match &mut node.content {
1002 ParagraphContent::Table(table) => table.tags.as_mut_slice(),
1003 _ => &mut [],
1004 };
1005 let mut tags: Vec<&mut Tag> = node.tags.iter_mut().chain(content).collect();
1006 let checked = tags1296 let checked = tags
1007 .iter()1297 .iter()
1008 .filter(|tag| checkable(tag))1298 .filter(|tag| checkable(tag))
...@@ -1014,17 +1304,6 @@ impl CanvasEditor {...@@ -1014,17 +1304,6 @@ impl CanvasEditor {
1014 tag.completed = completed;1304 tag.completed = completed;
1015 }1305 }
1016 }1306 }
1017 let selection = outline.selection;
1018 self.commit(
1019 engine,
1020 DocumentEdit {
1021 columns: BTreeMap::new(),
1022 container,
1023 range: index..index + 1,
1024 replacement: vec![node],
1025 },
1026 selection,
1027 )
1028 }1307 }
10291308
1030 /// Checks the check boxes of the `covered` paragraphs, or clears them once all are1309 /// Checks the check boxes of the `covered` paragraphs, or clears them once all are
...@@ -1344,6 +1623,80 @@ mod tests {...@@ -1344,6 +1623,80 @@ mod tests {
1344 );1623 );
1345 }1624 }
13461625
1626 /// A style from the gallery replaces the paragraph's character formatting but keeps its
1627 /// language, as OneNote 2010's does; Enter at its end takes its NextStyle, from the
1628 /// theme where the page has none (lab, 2026-09-30).
1629 #[test]
1630 fn a_gallery_style_clears_formatting_and_enter_takes_its_next_style() {
1631 let mut engine = TextEngine::default();
1632 let body = Format {
1633 font: Some("Calibri".into()),
1634 font_size: Some(11.0),
1635 language: Some(0x40c),
1636 ..Format::default()
1637 };
1638 let source = TextDocument::new(vec![Paragraph::from_runs([
1639 ("Plain ".to_owned(), body.clone()),
1640 (
1641 "red".to_owned(),
1642 Format {
1643 color: Some(0xff),
1644 italic: Some(true),
1645 ..body.clone()
1646 },
1647 ),
1648 ])])
1649 .unwrap();
1650 let mut editor = CanvasEditor::new(&mut engine, source, 240.0).unwrap();
1651 let style = |name: &str, size, next: Option<&str>| Definition {
1652 kind: Kind::Style {
1653 name: Some(name.into()),
1654 next: next.map(Into::into),
1655 },
1656 format: Format {
1657 bold: Some(name != "p"),
1658 italic: Some(false),
1659 font: Some("Georgia".into()),
1660 font_size: Some(size),
1661 color: Some(0x0033_2211),
1662 space_before: Some(12.0),
1663 ..Format::default()
1664 },
1665 };
1666 let heading = style("h2", 16.0, Some("p"));
1667 editor.styles = BTreeMap::from([("p".to_owned(), style("p", 12.0, None))]);
1668 editor.select([at(0, 3); 2].into()).unwrap();
1669 editor
1670 .format(&mut engine, Formatting::Style(heading.clone()))
1671 .unwrap();
1672 let node = editor.active_outline().document.nodes()[0].clone();
1673 assert_eq!(editor.definitions[&node.style.unwrap()], heading);
1674 let spans = node.text().unwrap().text.spans().to_vec();
1675 assert_eq!(spans.len(), 1);
1676 assert_eq!(
1677 spans[0].format,
1678 Format {
1679 language: Some(0x40c),
1680 ..heading.format.clone()
1681 }
1682 );
1683 assert_eq!(editor.format_state().unwrap().style.as_deref(), Some("h2"));
1684 let length = node.text().unwrap().text.text().len() as u32;
1685 editor.select([at(0, length); 2].into()).unwrap();
1686 editor.enter(&mut engine, false).unwrap();
1687 let next = editor.active_outline().document.nodes()[1].style;
1688 assert_eq!(editor.definitions[&next.unwrap()], editor.styles["p"]);
1689 // Enter after Normal keeps Normal.
1690 editor.insert(&mut engine, "body").unwrap();
1691 editor.enter(&mut engine, false).unwrap();
1692 assert_eq!(editor.active_outline().document.nodes()[2].style, next);
1693 editor.undo(&mut engine).unwrap();
1694 editor.undo(&mut engine).unwrap();
1695 editor.undo(&mut engine).unwrap();
1696 editor.undo(&mut engine).unwrap();
1697 assert_eq!(editor.active_outline().document.nodes()[0].style, None);
1698 }
1699
1347 #[test]1700 #[test]
1348 fn clearing_and_removing_attributes_return_to_the_paragraph_style() {1701 fn clearing_and_removing_attributes_return_to_the_paragraph_style() {
1349 let mut engine = TextEngine::default();1702 let mut engine = TextEngine::default();
...@@ -1377,6 +1730,7 @@ mod tests {...@@ -1377,6 +1730,7 @@ mod tests {
1377 Definition {1730 Definition {
1378 kind: Kind::Style {1731 kind: Kind::Style {
1379 name: Some("h1".into()),1732 name: Some("h1".into()),
1733 next: None,
1380 },1734 },
1381 format: heading.clone(),1735 format: heading.clone(),
1382 },1736 },
...@@ -2199,6 +2553,7 @@ mod tests {...@@ -2199,6 +2553,7 @@ mod tests {
2199 (NoteTag::defaults()[0].clone(), 0),2553 (NoteTag::defaults()[0].clone(), 0),
2200 (NoteTag::defaults()[2].clone(), 2)2554 (NoteTag::defaults()[2].clone(), 2)
2201 ],2555 ],
2556 style: state.style.clone(),
2202 }2557 }
2203 );2558 );
2204 // Format Painter, a gallery tag and an inserted table write as well.2559 // Format Painter, a gallery tag and an inserted table write as well.
crates/canvas/src/editor/ops.rs+23
...@@ -46,6 +46,7 @@ fn reaches(op: &PageOp, outline: ExGuid, blank: &PageParagraph) -> bool {...@@ -46,6 +46,7 @@ fn reaches(op: &PageOp, outline: ExGuid, blank: &PageParagraph) -> bool {
46 | PageOp::Outline { object: id, .. }46 | PageOp::Outline { object: id, .. }
47 | PageOp::Paragraph { paragraph: id, .. }47 | PageOp::Paragraph { paragraph: id, .. }
48 | PageOp::Style { paragraph: id, .. }48 | PageOp::Style { paragraph: id, .. }
49 | PageOp::Unstyle { paragraph: id }
49 | PageOp::Media { paragraph: id, .. }50 | PageOp::Media { paragraph: id, .. }
50 | PageOp::List { paragraph: id, .. }51 | PageOp::List { paragraph: id, .. }
51 | PageOp::Tags { target: id, .. } => own(id),52 | PageOp::Tags { target: id, .. } => own(id),
...@@ -58,6 +59,8 @@ pub(super) fn references(node: &PageParagraph) -> impl Iterator<Item = ExGuid> +...@@ -58,6 +59,8 @@ pub(super) fn references(node: &PageParagraph) -> impl Iterator<Item = ExGuid> +
58 let content_tags = match &node.content {59 let content_tags = match &node.content {
59 ParagraphContent::Text(text) => text.tags.as_slice(),60 ParagraphContent::Text(text) => text.tags.as_slice(),
60 ParagraphContent::Table(table) => table.tags.as_slice(),61 ParagraphContent::Table(table) => table.tags.as_slice(),
62 ParagraphContent::Image(image) => image.tags.as_slice(),
63 ParagraphContent::Attachment(file) => file.tags.as_slice(),
61 _ => &[],64 _ => &[],
62 };65 };
63 node.lists.iter().copied().chain(node.style).chain(66 node.lists.iter().copied().chain(node.style).chain(
...@@ -283,6 +286,26 @@ pub(super) fn layout_ops(id: ExGuid, old: &Layout, new: &Layout) -> Lowered {...@@ -283,6 +286,26 @@ pub(super) fn layout_ops(id: ExGuid, old: &Layout, new: &Layout) -> Lowered {
283}286}
284287
285/// A page picture's stored position, size and description.288/// A page picture's stored position, size and description.
289/// The op giving `target` `tags`, with the definitions they name.
290pub(super) fn tags(
291 target: ExGuid,
292 tags: Vec<onestore::document::Tag>,
293 definitions: &BTreeMap<ExGuid, onestore::page::Definition>,
294) -> Vec<PageOp> {
295 let definitions = tags
296 .iter()
297 .filter_map(|tag| tag.definition)
298 .collect::<BTreeSet<_>>()
299 .into_iter()
300 .filter_map(|id| Some((id, definitions.get(&id)?.clone())))
301 .collect();
302 vec![PageOp::Tags {
303 target,
304 tags,
305 definitions,
306 }]
307}
308
286pub(super) fn picture_layout(image: &onestore::page::Image) -> Vec<PageOp> {309pub(super) fn picture_layout(image: &onestore::page::Image) -> Vec<PageOp> {
287 vec![PageOp::Picture {310 vec![PageOp::Picture {
288 picture: image.id,311 picture: image.id,
crates/canvas/src/editor/page.rs+21-15
...@@ -100,6 +100,22 @@ impl Content {...@@ -100,6 +100,22 @@ impl Content {
100 Some((source, [x + x0, y + y0, x + x1, y + y1]))100 Some((source, [x + x0, y + y0, x + x1, y + y1]))
101 }101 }
102102
103 /// A tagged picture or file: its identity, note tags and bounds in page points.
104 pub(crate) fn tagged(
105 &self,
106 ) -> Option<(onestore::ExGuid, &[onestore::document::Tag], [f32; 4])> {
107 let (id, tags, bounds) = match self {
108 Self::Image(image) => {
109 let [x, y] = [image.layout.x, image.layout.y].map(|v| v.unwrap_or(0.0));
110 let [width, height] = crate::outline::image_size(image)?;
111 (image.id, &image.tags, [x, y, x + width, y + height])
112 }
113 Self::File { source, .. } => (source.id, &source.tags, self.file()?.1),
114 _ => return None,
115 };
116 (!tags.is_empty()).then_some((id, tags.as_slice(), bounds))
117 }
118
103 pub(super) fn layout_mut(119 pub(super) fn layout_mut(
104 &mut self,120 &mut self,
105 ) -> Option<(onestore::ExGuid, &mut onestore::document::Layout)> {121 ) -> Option<(onestore::ExGuid, &mut onestore::document::Layout)> {
...@@ -354,27 +370,17 @@ pub(crate) fn build(...@@ -354,27 +370,17 @@ pub(crate) fn build(
354 }370 }
355 }371 }
356 PageObject::Image(source) => {372 PageObject::Image(source) => {
357 if source.bytes.is_none()373 // A picture without a layout size shows at its own, as COM inserts them.
358 || source.layout.max_width.is_none()374 let Some(size) =
359 || source.layout.max_height.is_none()375 crate::outline::image_size(source).filter(|_| source.bytes.is_some())
360 {376 else {
361 objects.push(Content::unavailable(object, engine)?);377 objects.push(Content::unavailable(object, engine)?);
362 continue;378 continue;
363 }379 };
364 let origin = [380 let origin = [
365 source.layout.x.unwrap_or(0.0),381 source.layout.x.unwrap_or(0.0),
366 source.layout.y.unwrap_or(0.0),382 source.layout.y.unwrap_or(0.0),
367 ];383 ];
368 let size = [
369 source
370 .layout
371 .max_width
372 .ok_or(EditorError::InvalidGeometry)?,
373 source
374 .layout
375 .max_height
376 .ok_or(EditorError::InvalidGeometry)?,
377 ];
378 if origin.iter().any(|v| !v.is_finite())384 if origin.iter().any(|v| !v.is_finite())
379 || size.iter().any(|v| !v.is_finite() || *v <= 0.0)385 || size.iter().any(|v| !v.is_finite() || *v <= 0.0)
380 || !(origin[0] + size[0]).is_finite()386 || !(origin[0] + size[0]).is_finite()
crates/canvas/src/editor/recording.rs+2-1
...@@ -22,6 +22,7 @@ fn cite() -> Definition {...@@ -22,6 +22,7 @@ fn cite() -> Definition {
22 Definition {22 Definition {
23 kind: Kind::Style {23 kind: Kind::Style {
24 name: Some("cite".into()),24 name: Some("cite".into()),
25 next: None,
25 },26 },
26 format: Format {27 format: Format {
27 bold: Some(false),28 bold: Some(false),
...@@ -107,7 +108,7 @@ impl CanvasEditor {...@@ -107,7 +108,7 @@ impl CanvasEditor {
107 vec![Paragraph::new(String::new(), format); parts],108 vec![Paragraph::new(String::new(), format); parts],
108 )?;109 )?;
109 let style = match self.definitions.iter().find(|(_, definition)| {110 let style = match self.definitions.iter().find(|(_, definition)| {
110 matches!(&definition.kind, Kind::Style { name: Some(name) } if name == "cite")111 matches!(&definition.kind, Kind::Style { name: Some(name), .. } if name == "cite")
111 }) {112 }) {
112 Some((style, _)) => *style,113 Some((style, _)) => *style,
113 None => {114 None => {
crates/canvas/src/editor/table.rs+522-5
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1use super::*;1use super::*;
2use onestore::page::text::new_id;2use onestore::page::text::new_id;
3use onestore::page::{ParagraphContent, TableCell, TableColumn, TableRow};3use onestore::page::{ParagraphContent, Table, TableCell, TableColumn, TableRow};
44
5struct CellLocation<'a> {5struct CellLocation<'a> {
6 container: Option<ExGuid>,6 container: Option<ExGuid>,
...@@ -35,6 +35,18 @@ fn locate(document: &TextDocument, id: ExGuid) -> Option<CellLocation<'_>> {...@@ -35,6 +35,18 @@ fn locate(document: &TextDocument, id: ExGuid) -> Option<CellLocation<'_>> {
35 None35 None
36}36}
3737
38/// Table `id`'s paragraph, its container and its index there.
39fn table_node(
40 document: &TextDocument,
41 id: ExGuid,
42) -> Result<(Option<ExGuid>, usize, &PageParagraph), EditError> {
43 descendants(document.nodes(), None)
44 .find(|(_, _, node)| {
45 matches!(&node.content, ParagraphContent::Table(table) if table.id == id)
46 })
47 .ok_or(EditError::InvalidRange)
48}
49
38/// A new cell like `source`, one empty paragraph in its first text's format; a cell holding50/// A new cell like `source`, one empty paragraph in its first text's format; a cell holding
39/// no text, only pictures or files, gives a plain one.51/// no text, only pictures or files, gives a plain one.
40fn empty_cell(source: &TableCell) -> Result<TableCell, EditError> {52fn empty_cell(source: &TableCell) -> Result<TableCell, EditError> {
...@@ -85,7 +97,270 @@ fn cell_range(document: &TextDocument, cell: &TableCell) -> Result<Range<TextPos...@@ -85,7 +97,270 @@ fn cell_range(document: &TextDocument, cell: &TableCell) -> Result<Range<TextPos
85 })97 })
86}98}
8799
100/// OneNote 2010's width for a new column, and the narrowest it fits or drags one to.
101const COLUMN_WIDTH: f32 = 37.11;
102/// What OneNote 2010 fits an unlocked column to beyond its widest line (lab, 2026-09-30).
103const COLUMN_ROOM: f32 = 4.347;
104
105/// `width` as it reads back from the file, which stores it in half inches.
106fn stored(width: f32) -> f32 {
107 width / 36.0 * 36.0
108}
109
110/// `table`'s widths with its unlocked columns in `only`, or all, fit to their widest cell
111/// as `natural` measures it, the table no wider than `room` unless its narrowest columns are.
112fn fitted(
113 table: &Table,
114 only: Option<usize>,
115 room: f32,
116 mut natural: impl FnMut(&TableCell) -> Result<f32, LayoutError>,
117) -> Result<Vec<f32>, LayoutError> {
118 let mut widths = table
119 .columns
120 .iter()
121 .map(|column| column.width)
122 .collect::<Vec<_>>();
123 for (index, column) in table.columns.iter().enumerate() {
124 if column.locked || only.is_some_and(|only| only != index) {
125 continue;
126 }
127 let mut widest = 0.0_f32;
128 for row in &table.rows {
129 widest = widest.max(natural(
130 row.cells.get(index).ok_or(LayoutError::InvalidWidth)?,
131 )?);
132 }
133 // Columns sit 4.98 pt apart, and the table ends 3.15 pt past the last one.
134 let others = widths.iter().map(|width| width + 4.98).sum::<f32>() - widths[index] - 1.83;
135 widths[index] = stored((widest + COLUMN_ROOM).min(room - others).max(COLUMN_WIDTH));
136 }
137 Ok(widths)
138}
139
140/// Fits the tables in `nodes`, innermost first, placed by `indents` in an outline `wrap` wide.
141fn fit_tables(
142 nodes: &mut [PageParagraph],
143 indents: &[f32],
144 wrap: f32,
145 natural: &mut impl FnMut(&TableCell) -> Result<f32, LayoutError>,
146) -> Result<(), LayoutError> {
147 for node in nodes {
148 let level = node.level;
149 if let ParagraphContent::Table(table) = &mut node.content {
150 for TableCell {
151 paragraphs,
152 indents,
153 ..
154 } in table.rows.iter_mut().flat_map(|row| &mut row.cells)
155 {
156 fit_tables(paragraphs, indents, wrap, natural)?;
157 }
158 let room = wrap - crate::outline::indentation(level, indents, wrap)?;
159 let widths = fitted(table, None, room, &mut *natural)?;
160 for (column, width) in table.columns.iter_mut().zip(widths) {
161 column.width = width;
162 }
163 }
164 }
165 Ok(())
166}
167
88impl CanvasEditor {168impl CanvasEditor {
169 /// Fits the unlocked columns `edit` writes in to their content, as OneNote 2010 widens
170 /// and narrows them while typing: the tables it adds or rewrites, and the column of each
171 /// table around the cell it edits. A table stops at the outline's width and wraps there.
172 pub(super) fn fit_columns(
173 &self,
174 engine: &mut TextEngine,
175 edit: &mut DocumentEdit,
176 ) -> Result<(), EditorError> {
177 let outline = self.active_outline();
178 if outline.title {
179 return Ok(());
180 }
181 let wrap = outline.wrap_width();
182 let mut natural = |cell: &TableCell, edit: Option<&DocumentEdit>| {
183 let flow = OutlineLayout::flow(
184 crate::document::edited_nodes(&cell.paragraphs, Some(cell.id), edit),
185 &cell.indents,
186 f32::from(u16::MAX),
187 false,
188 1,
189 edit,
190 &mut |node, previous, width, indents| {
191 ParagraphLayout::shape(
192 engine,
193 node,
194 previous,
195 width,
196 indents,
197 &self.definitions,
198 )
199 },
200 )?;
201 Ok::<_, LayoutError>(flow.content_width())
202 };
203 let indents = |container: Option<ExGuid>| match container {
204 Some(cell) => {
205 let location =
206 locate(&outline.document, cell).ok_or(EditError::InvalidStructure)?;
207 let ParagraphContent::Table(table) = &location.node.content else {
208 unreachable!()
209 };
210 Ok::<_, EditError>(&table.rows[location.row].cells[location.column].indents)
211 }
212 None => Ok(&outline.indents),
213 };
214 fit_tables(
215 &mut edit.replacement,
216 indents(edit.container)?,
217 wrap,
218 &mut |cell| natural(cell, None),
219 )?;
220 let mut container = edit.container;
221 while let Some(cell) = container {
222 let location = locate(&outline.document, cell).ok_or(EditError::InvalidStructure)?;
223 let ParagraphContent::Table(table) = &location.node.content else {
224 unreachable!()
225 };
226 if !edit.columns.contains_key(&table.id) {
227 let room = wrap
228 - crate::outline::indentation(
229 location.node.level,
230 indents(location.container)?,
231 wrap,
232 )?;
233 let widths = fitted(table, Some(location.column), room, |cell| {
234 natural(cell, Some(edit))
235 })?;
236 if widths
237 .iter()
238 .zip(&table.columns)
239 .any(|(width, column)| *width != column.width)
240 {
241 edit.columns.insert(table.id, widths);
242 }
243 }
244 container = location.container;
245 }
246 Ok(())
247 }
248}
249
250impl TextOutline {
251 /// The table column whose right border lies within `reach` of outline-local `point`, as
252 /// `(table, column, width)`.
253 pub fn column_border(&self, point: [f32; 2], reach: f32) -> Option<(ExGuid, usize, f32)> {
254 let columns = descendants(self.document.nodes(), None)
255 .filter_map(|(_, _, node)| match &node.content {
256 ParagraphContent::Table(table) => Some((table.id, table.columns.len())),
257 _ => None,
258 })
259 .collect::<BTreeMap<_, _>>();
260 // Nested tables follow the tables holding them.
261 self.shaped.tables.iter().rev().find_map(|table| {
262 let (first, last) = (table.cells.first()?, table.cells.last()?);
263 if !(first.rect[1]..=last.rect[3]).contains(&point[1]) {
264 return None;
265 }
266 let row = table.cells.get(..*columns.get(&table.id)?)?;
267 row.iter().enumerate().find_map(|(column, cell)| {
268 // A cell's box reaches 3.6 pt before its column and 1.38 pt past it.
269 ((point[0] - cell.rect[2]).abs() <= reach)
270 .then(|| (table.id, column, cell.rect[2] - cell.rect[0] - 4.98))
271 })
272 })
273 }
274}
275
276impl CanvasEditor {
277 /// The active outline with column `column` of `table` `width` wide, as a border drag
278 /// shows it before release.
279 pub fn preview_column(
280 &self,
281 engine: &mut TextEngine,
282 table: ExGuid,
283 column: usize,
284 width: f32,
285 ) -> Result<TextOutline, EditorError> {
286 let outline = self.active_outline();
287 let (_, _, node) = table_node(&outline.document, table)?;
288 let ParagraphContent::Table(source) = &node.content else {
289 unreachable!()
290 };
291 let mut widths = source
292 .columns
293 .iter()
294 .map(|column| column.width)
295 .collect::<Vec<_>>();
296 *widths.get_mut(column).ok_or(EditError::InvalidRange)? = width.max(COLUMN_WIDTH);
297 let edit = DocumentEdit {
298 container: None,
299 range: 0..0,
300 replacement: Vec::new(),
301 columns: [(table, widths)].into(),
302 };
303 let shaped = OutlineLayout::flow(
304 outline.document.nodes().iter(),
305 &outline.indents,
306 outline.wrap_width(),
307 outline.layout.width_set_by_user == Some(true),
308 0,
309 Some(&edit),
310 &mut |node, previous, width, indents| {
311 ParagraphLayout::shape(engine, node, previous, width, indents, &self.definitions)
312 },
313 )?;
314 let mut preview = outline.clone();
315 preview.document.apply(edit)?;
316 preview.shaped = shaped;
317 Ok(preview)
318 }
319
320 /// Drags a border to make column `column` of `table` `width` wide, as OneNote 2010 does:
321 /// no narrower than a new column, locked against fitting, the columns after it moving
322 /// with it. One edit and one undo step.
323 pub fn resize_column(
324 &mut self,
325 engine: &mut TextEngine,
326 table: ExGuid,
327 column: usize,
328 width: f32,
329 ) -> Result<(), EditorError> {
330 if !width.is_finite() {
331 return Err(LayoutError::InvalidWidth.into());
332 }
333 let outline = self.active_outline();
334 let (container, index, node) = table_node(&outline.document, table)?;
335 let mut node = node.clone();
336 let ParagraphContent::Table(source) = &mut node.content else {
337 unreachable!()
338 };
339 let resized = TableColumn {
340 width: stored(width.max(COLUMN_WIDTH)),
341 locked: true,
342 };
343 let slot = source
344 .columns
345 .get_mut(column)
346 .ok_or(EditError::InvalidRange)?;
347 if *slot == resized {
348 return Ok(());
349 }
350 *slot = resized;
351 let selection = outline.selection;
352 self.finish_composition();
353 self.commit(
354 engine,
355 DocumentEdit {
356 columns: BTreeMap::new(),
357 container,
358 range: index..index + 1,
359 replacement: vec![node],
360 },
361 selection,
362 )
363 }
89 /// Insert, Table: an empty table of `rows` by `columns` at the caret with the caret in364 /// Insert, Table: an empty table of `rows` by `columns` at the caret with the caret in
90 /// its first cell, as OneNote 2010 inserts one: in place of an empty paragraph, before365 /// its first cell, as OneNote 2010 inserts one: in place of an empty paragraph, before
91 /// or after the caret's paragraph at its start or end, and between its halves otherwise.366 /// or after the caret's paragraph at its start or end, and between its halves otherwise.
...@@ -137,7 +412,7 @@ impl CanvasEditor {...@@ -137,7 +412,7 @@ impl CanvasEditor {
137 id: new_id()?,412 id: new_id()?,
138 columns: vec![413 columns: vec![
139 TableColumn {414 TableColumn {
140 width: 37.11,415 width: COLUMN_WIDTH,
141 locked: false416 locked: false
142 };417 };
143 columns418 columns
...@@ -260,7 +535,7 @@ impl CanvasEditor {...@@ -260,7 +535,7 @@ impl CanvasEditor {
260 id: new_id()?,535 id: new_id()?,
261 columns: vec![536 columns: vec![
262 TableColumn {537 TableColumn {
263 width: 37.11,538 width: COLUMN_WIDTH,
264 locked: false539 locked: false
265 };540 };
266 2541 2
...@@ -341,7 +616,7 @@ impl CanvasEditor {...@@ -341,7 +616,7 @@ impl CanvasEditor {
341 };616 };
342 table.rows[0].cells.push(target);617 table.rows[0].cells.push(target);
343 table.columns.push(TableColumn {618 table.columns.push(TableColumn {
344 width: 37.11,619 width: COLUMN_WIDTH,
345 locked: false,620 locked: false,
346 });621 });
347 TextPosition {622 TextPosition {
...@@ -713,7 +988,9 @@ mod tests {...@@ -713,7 +988,9 @@ mod tests {
713 };988 };
714 assert_eq!(after.rows.len(), 3);989 assert_eq!(after.rows.len(), 3);
715 assert_eq!(&after.rows[..2], &before.rows);990 assert_eq!(&after.rows[..2], &before.rows);
716 assert_eq!(after.columns, before.columns);991 // The first column widens to the table placed in it unfitted.
992 assert!(after.columns[0].width > before.columns[0].width + 40.0);
993 assert_eq!(after.columns[1], before.columns[1]);
717 for cell in &after.rows[2].cells {994 for cell in &after.rows[2].cells {
718 assert_eq!(cell.paragraphs.len(), 1);995 assert_eq!(cell.paragraphs.len(), 1);
719 assert!(cell.paragraphs[0].text().unwrap().text.text().is_empty());996 assert!(cell.paragraphs[0].text().unwrap().text.text().is_empty());
...@@ -912,4 +1189,244 @@ mod tests {...@@ -912,4 +1189,244 @@ mod tests {
912 );1189 );
913 assert_eq!(editor.active_outline().shaped.paragraphs[0].origin[0], 27.0);1190 assert_eq!(editor.active_outline().shaped.paragraphs[0].origin[0], 27.0);
914 }1191 }
1192
1193 fn calibri(engine: &mut TextEngine) -> CanvasEditor {
1194 let format = Format {
1195 font: Some("Calibri".into()),
1196 font_size: Some(11.0),
1197 ..Format::default()
1198 };
1199 CanvasEditor::new(
1200 engine,
1201 TextDocument::new(vec![Paragraph::new(String::new(), format)]).unwrap(),
1202 468.0,
1203 )
1204 .unwrap()
1205 }
1206
1207 fn table(editor: &CanvasEditor) -> &onestore::page::Table {
1208 let ParagraphContent::Table(table) = &editor.active_outline().document.nodes()[0].content
1209 else {
1210 panic!()
1211 };
1212 table
1213 }
1214
1215 fn widths(editor: &CanvasEditor) -> Vec<f32> {
1216 table(editor)
1217 .columns
1218 .iter()
1219 .map(|column| column.width)
1220 .collect()
1221 }
1222
1223 fn type_rows(editor: &mut CanvasEditor, engine: &mut TextEngine, rows: &[&[&str]]) {
1224 for (index, row) in rows.iter().enumerate() {
1225 if index > 0 {
1226 editor.enter(engine, false).unwrap();
1227 }
1228 for (column, text) in row.iter().enumerate() {
1229 if column > 0 {
1230 editor.tab(engine, false).unwrap();
1231 }
1232 editor.insert(engine, text).unwrap();
1233 }
1234 }
1235 }
1236
1237 /// OneNote 2010's widths for the same table typed the same way (lab, 2026-09-30).
1238 #[test]
1239 fn typing_fits_unlocked_columns_to_their_widest_line() {
1240 let mut engine = TextEngine::default();
1241 let mut editor = calibri(&mut engine);
1242 type_rows(
1243 &mut editor,
1244 &mut engine,
1245 &[
1246 &["Fruit", "Colour", "Notes"],
1247 &["Apple", "Red", "Crisp and sweet, good for pies"],
1248 &["Watermelon", "Green", "Summer"],
1249 ],
1250 );
1251 for (width, onenote) in widths(&editor)
1252 .into_iter()
1253 .zip([60.75945, 37.11, 139.08636])
1254 {
1255 assert!((width - onenote).abs() < 0.01, "{width} against {onenote}");
1256 }
1257 // Deleting the widest line narrows the column to the next widest, here the minimum.
1258 let end = TextPosition {
1259 paragraph: 6,
1260 offset: 10,
1261 };
1262 editor
1263 .select([TextPosition { offset: 0, ..end }, end].into())
1264 .unwrap();
1265 editor.delete(&mut engine, true).unwrap();
1266 assert_eq!(widths(&editor)[0], COLUMN_WIDTH);
1267 assert!(editor.undo(&mut engine).unwrap());
1268 assert!((widths(&editor)[0] - 60.75945).abs() < 0.01);
1269 }
1270
1271 #[test]
1272 fn a_widening_keystroke_stores_its_width_with_its_text() {
1273 let mut engine = TextEngine::default();
1274 let mut editor = calibri(&mut engine);
1275 type_rows(&mut editor, &mut engine, &[&["A", ""]]);
1276 editor.take_ops().unwrap();
1277 let before = widths(&editor);
1278 editor.insert(&mut engine, "Watermelon").unwrap();
1279 let ops = editor.take_ops().unwrap();
1280 let columns = ops
1281 .iter()
1282 .filter_map(|op| match op {
1283 PageOp::Table {
1284 edit: onestore::op::TableEdit::Columns(columns),
1285 ..
1286 } => Some(
1287 columns
1288 .iter()
1289 .map(|column| column.width)
1290 .collect::<Vec<_>>(),
1291 ),
1292 _ => None,
1293 })
1294 .collect::<Vec<_>>();
1295 assert_eq!(columns, [widths(&editor)]);
1296 assert!(ops.len() > 1);
1297 assert!(widths(&editor)[1] > before[1]);
1298 // A keystroke the column already holds stores no width.
1299 editor.delete(&mut engine, true).unwrap();
1300 editor.insert(&mut engine, "n").unwrap();
1301 editor.take_ops().unwrap();
1302 editor
1303 .select(
1304 [TextPosition {
1305 paragraph: 0,
1306 offset: 1,
1307 }; 2]
1308 .into(),
1309 )
1310 .unwrap();
1311 editor.insert(&mut engine, "!").unwrap();
1312 assert!(!editor.take_ops().unwrap().iter().any(|op| matches!(
1313 op,
1314 PageOp::Table {
1315 edit: onestore::op::TableEdit::Columns(_),
1316 ..
1317 }
1318 )));
1319 }
1320
1321 /// OneNote 2010 stops a table at the outline's width and wraps the cell from there.
1322 #[test]
1323 fn a_table_stops_widening_at_the_outline_width() {
1324 let mut engine = TextEngine::default();
1325 let mut editor = calibri(&mut engine);
1326 type_rows(&mut editor, &mut engine, &[&["A", ""]]);
1327 for _ in 0..40 {
1328 editor.insert(&mut engine, "word ").unwrap();
1329 }
1330 let widths = widths(&editor);
1331 let table = widths.iter().map(|width| width + 4.98).sum::<f32>() - 1.83;
1332 assert!((table - 468.0).abs() < 0.01, "{table}");
1333 assert!(
1334 editor.active_outline().shaped.paragraphs[1]
1335 .text
1336 .lines()
1337 .count()
1338 > 1
1339 );
1340 }
1341
1342 #[test]
1343 fn a_dragged_column_locks_moves_the_columns_after_it_and_undoes_in_one_step() {
1344 let mut engine = TextEngine::default();
1345 let mut editor = calibri(&mut engine);
1346 type_rows(&mut editor, &mut engine, &[&["B", "Word"]]);
1347 let id = table(&editor).id;
1348 let fitted = widths(&editor);
1349 let cells = |editor: &CanvasEditor| {
1350 editor.active_outline().shaped.tables[0]
1351 .cells
1352 .iter()
1353 .map(|cell| cell.rect)
1354 .collect::<Vec<_>>()
1355 };
1356 let before = cells(&editor);
1357 // The border of the first column, from its cell's right edge.
1358 let border = [before[0][2], (before[0][1] + before[0][3]) / 2.0];
1359 assert_eq!(
1360 editor.active_outline().column_border(border, 2.0),
1361 Some((id, 0, fitted[0]))
1362 );
1363 assert_eq!(
1364 editor
1365 .active_outline()
1366 .column_border([border[0] - 10.0, border[1]], 2.0),
1367 None
1368 );
1369 let preview = editor.preview_column(&mut engine, id, 0, 88.86).unwrap();
1370 assert_eq!(widths(&editor), fitted);
1371 editor.take_ops().unwrap();
1372 let history = editor.undo.len();
1373 editor.resize_column(&mut engine, id, 0, 88.86).unwrap();
1374 assert_eq!(editor.undo.len(), history + 1);
1375 assert_eq!(
1376 table(&editor).columns[0],
1377 TableColumn {
1378 width: 88.86,
1379 locked: true
1380 }
1381 );
1382 assert_eq!(
1383 table(&editor).columns[1],
1384 TableColumn {
1385 width: fitted[1],
1386 locked: false
1387 }
1388 );
1389 assert_eq!(cells(&editor), {
1390 let ParagraphContent::Table(_) = &preview.document.nodes()[0].content else {
1391 panic!()
1392 };
1393 preview.shaped.tables[0]
1394 .cells
1395 .iter()
1396 .map(|cell| cell.rect)
1397 .collect::<Vec<_>>()
1398 });
1399 let moved = cells(&editor);
1400 assert!((moved[1][0] - before[1][0] - (88.86 - fitted[0])).abs() < 0.001);
1401 let ops = editor.take_ops().unwrap();
1402 assert!(
1403 matches!(
1404 ops.as_slice(),
1405 [PageOp::Table {
1406 edit: onestore::op::TableEdit::Columns(_),
1407 ..
1408 }]
1409 ),
1410 "{ops:?}"
1411 );
1412 // A locked column keeps its width while typing; dragging stops at a new column's width.
1413 editor
1414 .insert(&mut engine, " and a long line of text")
1415 .unwrap();
1416 assert_eq!(table(&editor).columns[0].width, 88.86);
1417 editor.resize_column(&mut engine, id, 1, 5.0).unwrap();
1418 assert_eq!(
1419 table(&editor).columns[1],
1420 TableColumn {
1421 width: COLUMN_WIDTH,
1422 locked: true
1423 }
1424 );
1425 for _ in 0..3 {
1426 editor.undo(&mut engine).unwrap();
1427 }
1428 assert_eq!(widths(&editor), fitted);
1429 assert!(table(&editor).columns.iter().all(|column| !column.locked));
1430 assert_eq!(cells(&editor), before);
1431 }
915}1432}
crates/canvas/src/gpu/page.rs+98-24
...@@ -24,6 +24,8 @@ use std::{...@@ -24,6 +24,8 @@ use std::{
24/// Retained drawing data in the source page's coordinate system.24/// Retained drawing data in the source page's coordinate system.
25pub struct PageScene {25pub struct PageScene {
26 reference: Option<Vec<Content>>,26 reference: Option<Vec<Content>>,
27 /// The definitions a page read without an editor names, as its objects' tags do.
28 definitions: std::collections::BTreeMap<onestore::ExGuid, onestore::page::Definition>,
27 /// Pictures and file icons, by the identity of the object that shows them.29 /// Pictures and file icons, by the identity of the object that shows them.
28 pictures: std::collections::BTreeMap<onestore::ExGuid, Picture>,30 pictures: std::collections::BTreeMap<onestore::ExGuid, Picture>,
29 /// Background pictures from OneNote's page templates, painted from their recreations.31 /// Background pictures from OneNote's page templates, painted from their recreations.
...@@ -80,6 +82,8 @@ pub enum SceneHit<T> {...@@ -80,6 +82,8 @@ pub enum SceneHit<T> {
80 ReadOnly(usize),82 ReadOnly(usize),
81 Image(onestore::ExGuid),83 Image(onestore::ExGuid),
82 File(onestore::ExGuid),84 File(onestore::ExGuid),
85 /// A check box tag on a picture or file on the page.
86 Check(onestore::ExGuid),
83}87}
8488
85fn outline_origin(89fn outline_origin(
...@@ -116,11 +120,12 @@ pub(crate) fn append_ink(...@@ -116,11 +120,12 @@ pub(crate) fn append_ink(
116 let size = stroke.width.max(stroke.height);120 let size = stroke.width.max(stroke.height);
117 let segment = |from: [f32; 2], to: [f32; 2], [t0, t1]: [f32; 2]| {121 let segment = |from: [f32; 2], to: [f32; 2], [t0, t1]: [f32; 2]| {
118 if !highlighter {122 if !highlighter {
119 if round && t0 != t1 {123 if t0 != t1 {
120 return Primitive::Taper {124 return Primitive::Taper {
121 from,125 from,
122 to,126 to,
123 widths: [size * t0, size * t1],127 widths: [size * t0, size * t1],
128 round,
124 color,129 color,
125 };130 };
126 }131 }
...@@ -253,6 +258,7 @@ impl PageScene {...@@ -253,6 +258,7 @@ impl PageScene {
253 pictures.mark_unavailable(&mut objects, engine)?;258 pictures.mark_unavailable(&mut objects, engine)?;
254 Ok(Self {259 Ok(Self {
255 reference: Some(objects),260 reference: Some(objects),
261 definitions: std::mem::take(&mut page.definitions),
256 ..pictures262 ..pictures
257 })263 })
258 }264 }
...@@ -398,6 +404,7 @@ impl PageScene {...@@ -398,6 +404,7 @@ impl PageScene {
398 }404 }
399 Ok(Self {405 Ok(Self {
400 reference: None,406 reference: None,
407 definitions: Default::default(),
401 pictures,408 pictures,
402 backgrounds,409 backgrounds,
403 })410 })
...@@ -438,6 +445,17 @@ impl PageScene {...@@ -438,6 +445,17 @@ impl PageScene {
438 }445 }
439 }446 }
440447
448 /// Bytes of the rasters the scene keeps decoded, for bounding what a cache of scenes holds.
449 pub fn raster_bytes(&self) -> u64 {
450 let size = |image: &draw::RasterImage| {
451 let [width, height] = image.size();
452 u64::from(width) * u64::from(height) * 4
453 };
454 let pictures = self.pictures.values().filter_map(Picture::image);
455 let art = self.backgrounds.values().flat_map(Background::images);
456 pictures.chain(art).map(size).sum()
457 }
458
441 /// The raster a picture or file icon shows, once decoded.459 /// The raster a picture or file icon shows, once decoded.
442 pub fn image(&self, id: onestore::ExGuid) -> Option<&draw::RasterImage> {460 pub fn image(&self, id: onestore::ExGuid) -> Option<&draw::RasterImage> {
443 self.pictures.get(&id)?.image()461 self.pictures.get(&id)?.image()
...@@ -463,9 +481,7 @@ impl PageScene {...@@ -463,9 +481,7 @@ impl PageScene {
463 source.layout.x.unwrap_or(0.0),481 source.layout.x.unwrap_or(0.0),
464 source.layout.y.unwrap_or(0.0),482 source.layout.y.unwrap_or(0.0),
465 ];483 ];
466 if let (Some(width), Some(height)) =484 if let Some([width, height]) = crate::outline::image_size(source) {
467 (source.layout.max_width, source.layout.max_height)
468 {
469 rects.push((source.id, [x, y, x + width, y + height]));485 rects.push((source.id, [x, y, x + width, y + height]));
470 }486 }
471 }487 }
...@@ -581,6 +597,38 @@ impl PageScene {...@@ -581,6 +597,38 @@ impl PageScene {
581 .ok_or(SceneError::MissingOutline)597 .ok_or(SceneError::MissingOutline)
582 }598 }
583599
600 /// The note tags of the pictures and files on the page, each with the object it marks,
601 /// in page points; `moving` shows one at a previewed rectangle.
602 fn object_tags(
603 &self,
604 editor: Option<&CanvasEditor>,
605 moving: Option<(onestore::ExGuid, [f32; 4])>,
606 ) -> Vec<(onestore::ExGuid, crate::outline::ParagraphTag)> {
607 let definitions = match (&self.reference, editor) {
608 (None, Some(editor)) => &editor.definitions,
609 _ => &self.definitions,
610 };
611 let Ok(objects) = self.objects(editor) else {
612 return Vec::new();
613 };
614 objects
615 .iter()
616 .filter_map(|object| {
617 let (id, tags, bounds) = object.tagged()?;
618 let bounds = match moving {
619 Some((moved, rect)) if moved == id => rect,
620 _ => bounds,
621 };
622 Some(
623 crate::outline::object_tags(tags, definitions, bounds)
624 .into_iter()
625 .map(move |tag| (id, tag)),
626 )
627 })
628 .flatten()
629 .collect()
630 }
631
584 pub fn read_only<'a>(632 pub fn read_only<'a>(
585 &'a self,633 &'a self,
586 editor: Option<&'a CanvasEditor>,634 editor: Option<&'a CanvasEditor>,
...@@ -650,15 +698,11 @@ impl PageScene {...@@ -650,15 +698,11 @@ impl PageScene {
650 true,698 true,
651 ))699 ))
652 }700 }
653 Content::Image(source) => Some((701 Content::Image(source) => {
654 [702 let [x, y] = [source.layout.x, source.layout.y].map(|v| v.unwrap_or(0.0));
655 source.layout.x.unwrap_or(0.0),703 let [width, height] = crate::outline::image_size(source)?;
656 source.layout.y.unwrap_or(0.0),704 Some(([x, y, x + width, y + height], false))
657 source.layout.x.unwrap_or(0.0) + source.layout.max_width?,705 }
658 source.layout.y.unwrap_or(0.0) + source.layout.max_height?,
659 ],
660 false,
661 )),
662 Content::File { .. } => Some((object.file()?.1, true)),706 Content::File { .. } => Some((object.file()?.1, true)),
663 Content::Ink(ink) => Some((crate::editor::page::ink_bounds(ink)?, true)),707 Content::Ink(ink) => Some((crate::editor::page::ink_bounds(ink)?, true)),
664 Content::ReadOnly(object) => Some((object.rect(), true)),708 Content::ReadOnly(object) => Some((object.rect(), true)),
...@@ -716,6 +760,14 @@ impl PageScene {...@@ -716,6 +760,14 @@ impl PageScene {
716 editor: Option<&CanvasEditor>,760 editor: Option<&CanvasEditor>,
717 mut outline: impl FnMut(onestore::ExGuid) -> Option<T>,761 mut outline: impl FnMut(onestore::ExGuid) -> Option<T>,
718 ) -> Option<SceneHit<T>> {762 ) -> Option<SceneHit<T>> {
763 let check = self.object_tags(editor, None).into_iter().find(|(_, tag)| {
764 tag.icon.checkable()
765 && (tag.origin[0]..=tag.origin[0] + tag.size).contains(&point[0])
766 && (tag.origin[1]..=tag.origin[1] + tag.size).contains(&point[1])
767 });
768 if let Some((id, _)) = check {
769 return Some(SceneHit::Check(id));
770 }
719 let mut readonly = self.read_only(editor).count();771 let mut readonly = self.read_only(editor).count();
720 for object in self.objects(editor).ok()?.iter().rev() {772 for object in self.objects(editor).ok()?.iter().rev() {
721 match object {773 match object {
...@@ -746,8 +798,7 @@ impl PageScene {...@@ -746,8 +798,7 @@ impl PageScene {
746 source.layout.x.unwrap_or(0.0),798 source.layout.x.unwrap_or(0.0),
747 source.layout.y.unwrap_or(0.0),799 source.layout.y.unwrap_or(0.0),
748 ];800 ];
749 if let (Some(width), Some(height)) =801 if let Some([width, height]) = crate::outline::image_size(source)
750 (source.layout.max_width, source.layout.max_height)
751 && (x..=x + width).contains(&point[0])802 && (x..=x + width).contains(&point[0])
752 && (y..=y + height).contains(&point[1])803 && (y..=y + height).contains(&point[1])
753 {804 {
...@@ -824,11 +875,7 @@ impl PageScene {...@@ -824,11 +875,7 @@ impl PageScene {
824 ];875 ];
825 let size = match self.backgrounds.get(&source.id) {876 let size = match self.backgrounds.get(&source.id) {
826 Some(art) => Some(art.size),877 Some(art) => Some(art.size),
827 None => source878 None => crate::outline::image_size(source),
828 .layout
829 .max_width
830 .zip(source.layout.max_height)
831 .map(|(width, height)| [width, height]),
832 };879 };
833 if let Some([width, height]) = size {880 if let Some([width, height]) = size {
834 cover([x, y, x + width, y + height], !source.background);881 cover([x, y, x + width, y + height], !source.background);
...@@ -975,10 +1022,8 @@ impl PageScene {...@@ -975,10 +1022,8 @@ impl PageScene {
975 .pictures1022 .pictures
976 .get(&source.id)1023 .get(&source.id)
977 .ok_or(SceneError::MissingImage)?;1024 .ok_or(SceneError::MissingImage)?;
978 let size = [1025 let size = crate::outline::image_size(source)
979 source.layout.max_width.ok_or(SceneError::MissingImage)?,1026 .ok_or(SceneError::MissingImage)?;
980 source.layout.max_height.ok_or(SceneError::MissingImage)?,
981 ];
982 if picture.failed() {1027 if picture.failed() {
983 append_placeholder(1028 append_placeholder(
984 [1029 [
...@@ -1059,6 +1104,16 @@ impl PageScene {...@@ -1059,6 +1104,16 @@ impl PageScene {
1059 | Content::File { .. } => unreachable!(),1104 | Content::File { .. } => unreachable!(),
1060 }1105 }
1061 }1106 }
1107 let art = super::TagArt::default();
1108 for (_, tag) in self.object_tags(editor, moving) {
1109 primitives.push(Primitive::Icon {
1110 sources: art.sources(&tag),
1111 origin: [tag.origin[0] + offset[0], tag.origin[1] + offset[1]],
1112 size: tag.size,
1113 tint: [1.0, 1.0, 1.0, if tag.disabled { 0.45 } else { 1.0 }],
1114 palette: draw::Palette::default(),
1115 });
1116 }
1062 Ok(())1117 Ok(())
1063 }1118 }
1064}1119}
...@@ -1547,6 +1602,7 @@ mod tests {...@@ -1547,6 +1602,7 @@ mod tests {
1547 onestore::page::Definition {1602 onestore::page::Definition {
1548 kind: onestore::document::Kind::Style {1603 kind: onestore::document::Kind::Style {
1549 name: Some("p".into()),1604 name: Some("p".into()),
1605 next: None,
1550 },1606 },
1551 format: onestore::document::Format {1607 format: onestore::document::Format {
1552 font_size: Some(base_size),1608 font_size: Some(base_size),
...@@ -1720,6 +1776,9 @@ mod tests {...@@ -1720,6 +1776,9 @@ mod tests {
1720 alt: None,1776 alt: None,
1721 background,1777 background,
1722 printout: None,1778 printout: None,
1779 tags: Vec::new(),
1780 link: None,
1781 text: None,
1723 }));1782 }));
1724 }1783 }
1725 let (mut scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap();1784 let (mut scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap();
...@@ -2082,6 +2141,9 @@ mod tests {...@@ -2082,6 +2141,9 @@ mod tests {
2082 alt: Some("missing diagram".into()),2141 alt: Some("missing diagram".into()),
2083 background: false,2142 background: false,
2084 printout: None,2143 printout: None,
2144 tags: Vec::new(),
2145 link: None,
2146 text: None,
2085 }),2147 }),
2086 PageObject::Unsupported(unknown.clone()),2148 PageObject::Unsupported(unknown.clone()),
2087 ],2149 ],
...@@ -2252,6 +2314,9 @@ mod tests {...@@ -2252,6 +2314,9 @@ mod tests {
2252 alt: None,2314 alt: None,
2253 background: false,2315 background: false,
2254 printout: None,2316 printout: None,
2317 tags: Vec::new(),
2318 link: None,
2319 text: None,
2255 }),2320 }),
2256 outline(50.0, "last"),2321 outline(50.0, "last"),
2257 ],2322 ],
...@@ -2545,6 +2610,9 @@ mod tests {...@@ -2545,6 +2610,9 @@ mod tests {
2545 alt: None,2610 alt: None,
2546 background: false,2611 background: false,
2547 printout: None,2612 printout: None,
2613 tags: Vec::new(),
2614 link: None,
2615 text: None,
2548 })],2616 })],
2549 };2617 };
2550 let mut engine = TextEngine::default();2618 let mut engine = TextEngine::default();
...@@ -2610,6 +2678,9 @@ mod tests {...@@ -2610,6 +2678,9 @@ mod tests {
2610 alt: None,2678 alt: None,
2611 background,2679 background,
2612 printout: None,2680 printout: None,
2681 tags: Vec::new(),
2682 link: None,
2683 text: None,
2613 };2684 };
2614 let [background, picture] = [image(0.0, true), image(50.0, false)];2685 let [background, picture] = [image(0.0, true), image(50.0, false)];
2615 let id = picture.id;2686 let id = picture.id;
...@@ -2681,6 +2752,9 @@ mod tests {...@@ -2681,6 +2752,9 @@ mod tests {
2681 alt: None,2752 alt: None,
2682 background: false,2753 background: false,
2683 printout: None,2754 printout: None,
2755 tags: Vec::new(),
2756 link: None,
2757 text: None,
2684 })2758 })
2685 .collect();2759 .collect();
2686 assert!(20 * u64::from(side * side) * 4 > draw::MAX_IMAGE_BYTES);2760 assert!(20 * u64::from(side * side) * 4 > draw::MAX_IMAGE_BYTES);
crates/canvas/src/gpu/page/background.rs+10
...@@ -429,6 +429,13 @@ struct Variant {...@@ -429,6 +429,13 @@ struct Variant {
429}429}
430430
431impl Background {431impl Background {
432 /// The rasters made so far, for light and for dark paper.
433 pub fn images(&self) -> impl Iterator<Item = &RasterImage> {
434 self.variants
435 .iter()
436 .filter_map(|variant| variant.shown.as_ref().map(|(image, _)| image))
437 }
438
432 /// Whether both recreate the same template picture at the same size.439 /// Whether both recreate the same template picture at the same size.
433 pub fn same(&self, other: &Self) -> bool {440 pub fn same(&self, other: &Self) -> bool {
434 self.svg == other.svg && self.size == other.size441 self.svg == other.svg && self.size == other.size
...@@ -757,6 +764,9 @@ mod tests {...@@ -757,6 +764,9 @@ mod tests {
757 alt: None,764 alt: None,
758 background: true,765 background: true,
759 printout: None,766 printout: None,
767 tags: Vec::new(),
768 link: None,
769 text: None,
760 }770 }
761 }771 }
762772
crates/canvas/src/gpu/profile.rs+3
...@@ -247,6 +247,9 @@ fn frame_cost() {...@@ -247,6 +247,9 @@ fn frame_cost() {
247 alt: None,247 alt: None,
248 background: false,248 background: false,
249 printout: None,249 printout: None,
250 tags: Vec::new(),
251 link: None,
252 text: None,
250 });253 });
251 let cell = || TableCell {254 let cell = || TableCell {
252 id: new_id().unwrap(),255 id: new_id().unwrap(),
crates/canvas/src/interaction/mod.rs+210-41
...@@ -16,7 +16,10 @@ mod tests;...@@ -16,7 +16,10 @@ mod tests;
16use crate::gpu::{Paper, Viewport, page::PageScene};16use crate::gpu::{Paper, Viewport, page::PageScene};
17use crate::{17use crate::{
18 date::DateField,18 date::DateField,
19 editor::{CanvasEditor, DEFAULT_OUTLINE_WIDTH, Formatting, Selection, TextOutline, Whole},19 editor::{
20 Awaited, CanvasEditor, DEFAULT_OUTLINE_WIDTH, Formatting, Piece, Selection, TextOutline,
21 Whole,
22 },
20 layout::TextEngine,23 layout::TextEngine,
21};24};
22use draw::{25use draw::{
...@@ -177,6 +180,15 @@ enum Drag {...@@ -177,6 +180,15 @@ enum Drag {
177 outline: Option<Box<TextOutline>>,180 outline: Option<Box<TextOutline>>,
178 grab: f32,181 grab: f32,
179 },182 },
183 /// A table column's right border from document x `press`, where the column was `width`
184 /// wide; the preview holds the width it has reached.
185 Column {
186 table: onestore::ExGuid,
187 column: usize,
188 width: f32,
189 press: f32,
190 preview: Option<(f32, Box<TextOutline>)>,
191 },
180 Outline {192 Outline {
181 id: onestore::ExGuid,193 id: onestore::ExGuid,
182 grab: [f32; 2],194 grab: [f32; 2],
...@@ -346,9 +358,14 @@ impl PageView {...@@ -346,9 +358,14 @@ impl PageView {
346 view358 view
347 }359 }
348360
349 /// Shows a page reloaded from storage in place of the edited one.361 /// Shows a page reloaded from storage in place of the edited one, whose editor it returns.
350 pub fn replace(&mut self, editor: CanvasEditor, scene: Option<(PageScene, [f32; 2])>) {362 pub fn replace(
351 self.editor = editor;363 &mut self,
364 mut editor: CanvasEditor,
365 scene: Option<(PageScene, [f32; 2])>,
366 ) -> CanvasEditor {
367 editor.default_font = std::mem::take(&mut self.editor.default_font);
368 let left = std::mem::replace(&mut self.editor, editor);
352 self.scene = scene;369 self.scene = scene;
353 self.drag = None;370 self.drag = None;
354 self.space = false;371 self.space = false;
...@@ -356,6 +373,7 @@ impl PageView {...@@ -356,6 +373,7 @@ impl PageView {
356 self.found.clear();373 self.found.clear();
357 self.played = None;374 self.played = None;
358 self.leave_ink();375 self.leave_ink();
376 left
359 }377 }
360378
361 /// Shows the stored page after a change made elsewhere in place of the one shown,379 /// Shows the stored page after a change made elsewhere in place of the one shown,
...@@ -376,23 +394,36 @@ impl PageView {...@@ -376,23 +394,36 @@ impl PageView {
376394
377 /// Shows another page as OneNote opens one, keeping the zoom: at `place` if the page was395 /// Shows another page as OneNote opens one, keeping the zoom: at `place` if the page was
378 /// left there earlier. OneNote keeps the scroll in device pixels across zoom changes and396 /// left there earlier. OneNote keeps the scroll in device pixels across zoom changes and
379 /// does not reveal the restored selection.397 /// does not reveal the restored selection. Returns the editor of the page left.
380 pub fn open(398 pub fn open(
381 &mut self,399 &mut self,
382 editor: CanvasEditor,400 editor: CanvasEditor,
383 scene: Option<(PageScene, [f32; 2])>,401 scene: Option<(PageScene, [f32; 2])>,
384 place: Option<Place>,402 place: Option<Place>,
385 ) {403 ) -> CanvasEditor {
386 self.replace(editor, scene);404 let left = self.replace(editor, scene);
387 self.reach = None;405 self.reach = None;
388 let Some(place) = place else {406 match place {
389 return self.place_opened();407 Some(place) => {
390 };408 self.viewport.origin = place.origin;
391 self.viewport.origin = place.origin;409 self.scroll().clamp(&mut self.viewport);
392 self.scroll().clamp(&mut self.viewport);410 if self.editor.focus_outline(place.outline).is_ok() {
393 if self.editor.focus_outline(place.outline).is_ok() {411 let _ = self.editor.select(place.selection);
394 let _ = self.editor.select(place.selection);412 }
413 }
414 None => self.place_opened(),
395 }415 }
416 left
417 }
418
419 /// Takes up `parked`, the editor the page `open` just showed was left with, history and
420 /// all, in place of the one it opened with.
421 pub fn resume(&mut self, parked: CanvasEditor) -> Result<()> {
422 self.editor.resume(parked, &mut self.engine)?;
423 if let Some((scene, _)) = &mut self.scene {
424 scene.refresh(&mut self.editor, &mut self.engine)?;
425 }
426 Ok(())
396 }427 }
397428
398 /// Where the page is left, for `open` to return to.429 /// Where the page is left, for `open` to return to.
...@@ -907,10 +938,16 @@ impl PageView {...@@ -907,10 +938,16 @@ impl PageView {
907 /// Everything to draw this frame.938 /// Everything to draw this frame.
908 pub fn primitives(&self, colors: TextColors) -> Result<Vec<Primitive<'_>>> {939 pub fn primitives(&self, colors: TextColors) -> Result<Vec<Primitive<'_>>> {
909 let preview = match &self.drag {940 let preview = match &self.drag {
910 Some(Drag::Resize {941 Some(
911 outline: Some(outline),942 Drag::Resize {
912 ..943 outline: Some(outline),
913 }) => Some(PointerFeedback::Resize(outline)),944 ..
945 }
946 | Drag::Column {
947 preview: Some((_, outline)),
948 ..
949 },
950 ) => Some(PointerFeedback::Resize(outline)),
914 _ => self951 _ => self
915 .drag_delta()952 .drag_delta()
916 .map(|(id, delta)| PointerFeedback::Move(id, delta))953 .map(|(id, delta)| PointerFeedback::Move(id, delta))
...@@ -958,6 +995,7 @@ impl PageView {...@@ -958,6 +995,7 @@ impl PageView {
958 Some(995 Some(
959 Drag::Outline { .. }996 Drag::Outline { .. }
960 | Drag::Resize { .. }997 | Drag::Resize { .. }
998 | Drag::Column { .. }
961 | Drag::Image { .. }999 | Drag::Image { .. }
962 | Drag::Space { .. }1000 | Drag::Space { .. }
963 )1001 )
...@@ -1038,8 +1076,14 @@ impl PageView {...@@ -1038,8 +1076,14 @@ impl PageView {
1038 let hit = self.hit_test(point);1076 let hit = self.hit_test(point);
1039 match (&self.drag, hit) {1077 match (&self.drag, hit) {
1040 (Some(Drag::Image { handle, .. }), _) => handle_cursor(*handle),1078 (Some(Drag::Image { handle, .. }), _) => handle_cursor(*handle),
1079 (None, Some(Hit::Image { id, handle: [0, 0] }))
1080 if self.modifiers.command && self.editor.picture_link(id).is_some() =>
1081 {
1082 Cursor::Pointer
1083 }
1041 (None, Some(Hit::Image { handle, .. })) => handle_cursor(handle),1084 (None, Some(Hit::Image { handle, .. })) => handle_cursor(handle),
1042 (Some(Drag::Resize { .. }), _) | (None, Some(Hit::Resize { .. })) => Cursor::EwResize,1085 (Some(Drag::Resize { .. }), _) | (None, Some(Hit::Resize { .. })) => Cursor::EwResize,
1086 (Some(Drag::Column { .. }), _) | (None, Some(Hit::Column { .. })) => Cursor::ColResize,
1043 (Some(Drag::Outline { .. }), _) | (None, Some(Hit::Handle { .. })) => Cursor::Move,1087 (Some(Drag::Outline { .. }), _) | (None, Some(Hit::Handle { .. })) => Cursor::Move,
1044 (None, Some(Hit::Date(_))) => Cursor::Pointer,1088 (None, Some(Hit::Date(_))) => Cursor::Pointer,
1045 (None, Some(Hit::Text { id, point }))1089 (None, Some(Hit::Text { id, point }))
...@@ -1047,7 +1091,10 @@ impl PageView {...@@ -1047,7 +1091,10 @@ impl PageView {
1047 {1091 {
1048 Cursor::Pointer1092 Cursor::Pointer
1049 }1093 }
1050 (None, Some(Hit::ReadOnly(_) | Hit::Check { .. } | Hit::File(_))) => Cursor::Default,1094 (
1095 None,
1096 Some(Hit::ReadOnly(_) | Hit::Check { .. } | Hit::ObjectCheck(_) | Hit::File(_)),
1097 ) => Cursor::Default,
1051 _ => Cursor::Text,1098 _ => Cursor::Text,
1052 }1099 }
1053 }1100 }
...@@ -1155,6 +1202,23 @@ impl PageView {...@@ -1155,6 +1202,23 @@ impl PageView {
1155 }1202 }
1156 Ok(Response::default())1203 Ok(Response::default())
1157 }1204 }
1205 Some(Drag::Column {
1206 table,
1207 column,
1208 width,
1209 press,
1210 preview,
1211 }) => {
1212 let width = *width + self.viewport.document_point(self.pointer)[0] - *press;
1213 if preview.as_ref().is_none_or(|(shown, _)| *shown != width) {
1214 let outline =
1215 self.editor
1216 .preview_column(&mut self.engine, *table, *column, width)?;
1217 *preview = Some((width, Box::new(outline)));
1218 return self.changed();
1219 }
1220 Ok(Response::default())
1221 }
1158 None => Ok(Response::redraw()),1222 None => Ok(Response::redraw()),
1159 }1223 }
1160 }1224 }
...@@ -1195,6 +1259,12 @@ impl PageView {...@@ -1195,6 +1259,12 @@ impl PageView {
1195 .click_check(&mut self.engine, outline, paragraph)?;1259 .click_check(&mut self.engine, outline, paragraph)?;
1196 return self.changed();1260 return self.changed();
1197 }1261 }
1262 Some(Hit::ObjectCheck(id)) => {
1263 self.set_object_focus(None);
1264 self.drag = None;
1265 self.editor.click_object_check(id)?;
1266 return self.changed();
1267 }
1198 Some(Hit::File(id)) => {1268 Some(Hit::File(id)) => {
1199 self.set_object_focus(Some(ObjectFocus::File(id)));1269 self.set_object_focus(Some(ObjectFocus::File(id)));
1200 self.drag = None;1270 self.drag = None;
...@@ -1213,6 +1283,17 @@ impl PageView {...@@ -1213,6 +1283,17 @@ impl PageView {
1213 });1283 });
1214 }1284 }
1215 }1285 }
1286 // A click selects a linked picture; Ctrl+click follows its link, as in OneNote.
1287 Some(Hit::Image { id, handle: [0, 0] })
1288 if self.modifiers.command
1289 && let Some(address) = self.editor.picture_link(id) =>
1290 {
1291 self.drag = None;
1292 return Ok(Response {
1293 request: Some(Request::OpenLink(address.to_owned())),
1294 ..Response::default()
1295 });
1296 }
1216 Some(Hit::Image { id, handle }) => {1297 Some(Hit::Image { id, handle }) => {
1217 self.set_object_focus(Some(ObjectFocus::Image(id)));1298 self.set_object_focus(Some(ObjectFocus::Image(id)));
1218 self.drag = Some(Drag::Image {1299 self.drag = Some(Drag::Image {
...@@ -1242,6 +1323,22 @@ impl PageView {...@@ -1242,6 +1323,22 @@ impl PageView {
1242 grab,1323 grab,
1243 });1324 });
1244 }1325 }
1326 Some(Hit::Column {
1327 id,
1328 table,
1329 column,
1330 width,
1331 }) => {
1332 self.set_object_focus(None);
1333 self.editor.focus_outline(id)?;
1334 self.drag = Some(Drag::Column {
1335 table,
1336 column,
1337 width,
1338 press: point[0],
1339 preview: None,
1340 });
1341 }
1245 Some(Hit::Text { id, point })1342 Some(Hit::Text { id, point })
1246 if unit == SelectionUnit::Grapheme1343 if unit == SelectionUnit::Grapheme
1247 && !self.modifiers.shift1344 && !self.modifiers.shift
...@@ -1326,13 +1423,24 @@ impl PageView {...@@ -1326,13 +1423,24 @@ impl PageView {
1326 self.editor1423 self.editor
1327 .place_image(&mut self.engine, id, origin, size)?;1424 .place_image(&mut self.engine, id, origin, size)?;
1328 }1425 }
1329 if let Some(Drag::Resize {1426 match self.drag.take() {
1330 outline: Some(outline),1427 Some(Drag::Resize {
1331 ..1428 outline: Some(outline),
1332 }) = self.drag.take()1429 ..
1333 {1430 }) => {
1334 self.editor1431 self.editor
1335 .resize(&mut self.engine, outline.bounds().width() as f32)?;1432 .resize(&mut self.engine, outline.bounds().width() as f32)?;
1433 }
1434 Some(Drag::Column {
1435 table,
1436 column,
1437 preview: Some((width, _)),
1438 ..
1439 }) => {
1440 self.editor
1441 .resize_column(&mut self.engine, table, column, width)?;
1442 }
1443 _ => {}
1336 }1444 }
1337 if let Some(id) = clicked {1445 if let Some(id) = clicked {
1338 // A click on an outline's handle selects it as OneNote does.1446 // A click on an outline's handle selects it as OneNote does.
...@@ -1421,6 +1529,11 @@ impl PageView {...@@ -1421,6 +1529,11 @@ impl PageView {
1421 /// A toolbar command, ignored like other edits while an input method composes or an object1529 /// A toolbar command, ignored like other edits while an input method composes or an object
1422 /// holds focus.1530 /// holds focus.
1423 pub fn format(&mut self, command: Formatting) -> Result<Response> {1531 pub fn format(&mut self, command: Formatting) -> Result<Response> {
1532 if let Some(ObjectFocus::Image(id) | ObjectFocus::File(id)) = self.object_focus
1533 && self.editor.format_object(&mut self.engine, id, &command)?
1534 {
1535 return self.edited();
1536 }
1424 if !self.accepts_text() || self.editor.marked_range().is_some() {1537 if !self.accepts_text() || self.editor.marked_range().is_some() {
1425 return Ok(Response::default());1538 return Ok(Response::default());
1426 }1539 }
...@@ -1692,25 +1805,44 @@ impl PageView {...@@ -1692,25 +1805,44 @@ impl PageView {
1692 if !self.accepts_text() || self.editor.marked_range().is_some() {1805 if !self.accepts_text() || self.editor.marked_range().is_some() {
1693 return Ok(Response::default());1806 return Ok(Response::default());
1694 }1807 }
1695 let image = onestore::page::Image {1808 let image = crate::editor::picture(bytes, size)?;
1696 id: onestore::page::text::new_id()?,
1697 layout: onestore::document::Layout {
1698 max_width: Some(size[0]),
1699 max_height: Some(size[1]),
1700 ..Default::default()
1701 },
1702 size: Some(size),
1703 bytes: Some(bytes.into()),
1704 display: None,
1705 alt: None,
1706 background: false,
1707 printout: None,
1708 };
1709 self.editor.insert_picture(&mut self.engine, image)?;1809 self.editor.insert_picture(&mut self.engine, image)?;
1710 self.follow_pictures()?;1810 self.follow_pictures()?;
1711 self.edited()1811 self.edited()
1712 }1812 }
17131813
1814 /// Pasted text in `language`, an LCID, and pictures, in order and as one undo step; see
1815 /// [`CanvasEditor::paste_pieces`].
1816 pub fn paste_pieces(
1817 &mut self,
1818 pieces: Vec<Piece>,
1819 language: u32,
1820 ) -> Result<(Vec<Awaited>, Response)> {
1821 if !self.accepts_text() || self.editor.marked_range().is_some() {
1822 return Ok((Vec::new(), Response::default()));
1823 }
1824 let awaited = self
1825 .editor
1826 .paste_pieces(&mut self.engine, pieces, language)?;
1827 self.follow_pictures()?;
1828 Ok((awaited, self.edited()?))
1829 }
1830
1831 /// An awaited picture arrived: see [`CanvasEditor::insert_awaited`]. The view stays.
1832 pub fn insert_awaited(
1833 &mut self,
1834 at: Awaited,
1835 bytes: Vec<u8>,
1836 size: [f32; 2],
1837 ) -> Result<Response> {
1838 let image = crate::editor::picture(bytes, size)?;
1839 if !self.editor.insert_awaited(&mut self.engine, at, image)? {
1840 return Ok(Response::default());
1841 }
1842 self.follow_pictures()?;
1843 self.changed()
1844 }
1845
1714 /// Record Audio: see [`CanvasEditor::start_recording`]; none where text cannot go.1846 /// Record Audio: see [`CanvasEditor::start_recording`]; none where text cannot go.
1715 pub fn start_recording(&mut self, label: &str) -> Result<(Option<[u8; 16]>, Response)> {1847 pub fn start_recording(&mut self, label: &str) -> Result<(Option<[u8; 16]>, Response)> {
1716 if !self.accepts_text() || self.editor.marked_range().is_some() {1848 if !self.accepts_text() || self.editor.marked_range().is_some() {
...@@ -1765,6 +1897,24 @@ impl PageView {...@@ -1765,6 +1897,24 @@ impl PageView {
1765 position: [f32; 2],1897 position: [f32; 2],
1766 file: onestore::page::Attachment,1898 file: onestore::page::Attachment,
1767 ) -> Result<Response> {1899 ) -> Result<Response> {
1900 self.drop_caret(position)?;
1901 self.insert_attachment(file)
1902 }
1903
1904 /// A picture file dropped at view point `position`, in device pixels, placed as
1905 /// [`Self::drop_attachment`] places a file and sized as [`Self::insert_picture`].
1906 pub fn drop_picture(
1907 &mut self,
1908 position: [f32; 2],
1909 bytes: Vec<u8>,
1910 size: [f32; 2],
1911 ) -> Result<Response> {
1912 self.drop_caret(position)?;
1913 self.insert_picture(bytes, size)
1914 }
1915
1916 /// Puts the caret where a drop at `position` lands, as a click there would.
1917 fn drop_caret(&mut self, position: [f32; 2]) -> Result<()> {
1768 let point = self.viewport.document_point(position);1918 let point = self.viewport.document_point(position);
1769 self.set_object_focus(None);1919 self.set_object_focus(None);
1770 match self.hit_test(point) {1920 match self.hit_test(point) {
...@@ -1779,7 +1929,7 @@ impl PageView {...@@ -1779,7 +1929,7 @@ impl PageView {
1779 None => self.place_caret(point)?,1929 None => self.place_caret(point)?,
1780 _ => {}1930 _ => {}
1781 }1931 }
1782 self.insert_attachment(file)1932 Ok(())
1783 }1933 }
17841934
1785 /// Professional, or Linear with `linear`, on the equation at the caret.1935 /// Professional, or Linear with `linear`, on the equation at the caret.
...@@ -1906,6 +2056,7 @@ impl PageView {...@@ -1906,6 +2056,7 @@ impl PageView {
1906 Some(2056 Some(
1907 Drag::Outline { .. }2057 Drag::Outline { .. }
1908 | Drag::Resize { .. }2058 | Drag::Resize { .. }
2059 | Drag::Column { .. }
1909 | Drag::Image { .. }2060 | Drag::Image { .. }
1910 | Drag::Space { .. }2061 | Drag::Space { .. }
1911 )2062 )
...@@ -2086,6 +2237,13 @@ pub enum Hit {...@@ -2086,6 +2237,13 @@ pub enum Hit {
2086 id: onestore::ExGuid,2237 id: onestore::ExGuid,
2087 grab: f32,2238 grab: f32,
2088 },2239 },
2240 /// The right border of `column` of `table`, which is `width` wide.
2241 Column {
2242 id: onestore::ExGuid,
2243 table: onestore::ExGuid,
2244 column: usize,
2245 width: f32,
2246 },
2089 Text {2247 Text {
2090 id: onestore::ExGuid,2248 id: onestore::ExGuid,
2091 point: [f32; 2],2249 point: [f32; 2],
...@@ -2107,6 +2265,8 @@ pub enum Hit {...@@ -2107,6 +2265,8 @@ pub enum Hit {
2107 },2265 },
2108 /// A file in an outline's flow or on the page, its icon or name.2266 /// A file in an outline's flow or on the page, its icon or name.
2109 File(onestore::ExGuid),2267 File(onestore::ExGuid),
2268 /// A check box tag on a picture or file on the page.
2269 ObjectCheck(onestore::ExGuid),
2110}2270}
21112271
2112/// What a document point lands on; `pixel` is document points per device pixel.2272/// What a document point lands on; `pixel` is document points per device pixel.
...@@ -2231,6 +2391,14 @@ fn page_hit(...@@ -2231,6 +2391,14 @@ fn page_hit(
2231 }2391 }
2232 }2392 }
2233 let inner = [x - outline.origin()[0], y - outline.origin()[1]];2393 let inner = [x - outline.origin()[0], y - outline.origin()[1]];
2394 if let Some((table, column, width)) = outline.column_border(inner, 3.0 * pixel) {
2395 return Some(Hit::Column {
2396 id: outline.id,
2397 table,
2398 column,
2399 width,
2400 });
2401 }
2234 if let Some(picture) = outline.shaped().objects.iter().find(|object| {2402 if let Some(picture) = outline.shaped().objects.iter().find(|object| {
2235 matches!(object.kind, crate::outline::ObjectKind::Picture)2403 matches!(object.kind, crate::outline::ObjectKind::Picture)
2236 && (object.rect[0]..=object.rect[2]).contains(&inner[0])2404 && (object.rect[0]..=object.rect[2]).contains(&inner[0])
...@@ -2285,6 +2453,7 @@ fn page_hit(...@@ -2285,6 +2453,7 @@ fn page_hit(
2285 crate::gpu::page::SceneHit::ReadOnly(index) => Some(Hit::ReadOnly(index)),2453 crate::gpu::page::SceneHit::ReadOnly(index) => Some(Hit::ReadOnly(index)),
2286 crate::gpu::page::SceneHit::Image(id) => Some(Hit::Image { id, handle: [0, 0] }),2454 crate::gpu::page::SceneHit::Image(id) => Some(Hit::Image { id, handle: [0, 0] }),
2287 crate::gpu::page::SceneHit::File(id) => Some(Hit::File(id)),2455 crate::gpu::page::SceneHit::File(id) => Some(Hit::File(id)),
2456 crate::gpu::page::SceneHit::Check(id) => Some(Hit::ObjectCheck(id)),
2288 }2457 }
2289 };2458 };
2290 [Layer::Grips, Layer::Body, Layer::Below]2459 [Layer::Grips, Layer::Body, Layer::Below]
crates/canvas/src/interaction/tests.rs+189
...@@ -708,6 +708,9 @@ fn a_picture_in_an_outline_takes_the_click_over_its_text() {...@@ -708,6 +708,9 @@ fn a_picture_in_an_outline_takes_the_click_over_its_text() {
708 alt: None,708 alt: None,
709 background: false,709 background: false,
710 printout: None,710 printout: None,
711 tags: Vec::new(),
712 link: None,
713 text: None,
711 });714 });
712 source.paragraphs.insert(1, picture);715 source.paragraphs.insert(1, picture);
713 let editor = CanvasEditor::from_page(716 let editor = CanvasEditor::from_page(
...@@ -882,6 +885,13 @@ fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() {...@@ -882,6 +885,13 @@ fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() {
882 .unwrap();885 .unwrap();
883 editor.tab(&mut engine, false).unwrap();886 editor.tab(&mut engine, false).unwrap();
884 editor.insert(&mut engine, "R").unwrap();887 editor.insert(&mut engine, "R").unwrap();
888 // Columns dragged narrower than the glyphs they hold.
889 let table = editor.active_outline().shaped().tables[0].id;
890 for column in 0..2 {
891 editor
892 .resize_column(&mut engine, table, column, 37.11)
893 .unwrap();
894 }
885 editor.tab(&mut engine, true).unwrap();895 editor.tab(&mut engine, true).unwrap();
886 let mut primitives = Vec::new();896 let mut primitives = Vec::new();
887 append_outline(897 append_outline(
...@@ -1545,6 +1555,9 @@ fn picture_view() -> (PageView, onestore::ExGuid) {...@@ -1545,6 +1555,9 @@ fn picture_view() -> (PageView, onestore::ExGuid) {
1545 alt: None,1555 alt: None,
1546 background: false,1556 background: false,
1547 printout: None,1557 printout: None,
1558 tags: Vec::new(),
1559 link: None,
1560 text: None,
1548 });1561 });
1549 source.paragraphs.insert(1, picture);1562 source.paragraphs.insert(1, picture);
1550 let editor = CanvasEditor::from_page(1563 let editor = CanvasEditor::from_page(
...@@ -2768,6 +2781,7 @@ fn an_attached_file_selects_opens_on_a_double_click_and_deletes() {...@@ -2768,6 +2781,7 @@ fn an_attached_file_selects_opens_on_a_double_click_and_deletes() {
2768 bytes: Some(Arc::from(b"notes".as_slice())),2781 bytes: Some(Arc::from(b"notes".as_slice())),
2769 preview: None,2782 preview: None,
2770 recording: None,2783 recording: None,
2784 tags: Vec::new(),
2771 };2785 };
2772 view.editor2786 view.editor
2773 .move_selection(&mut view.engine, Movement::DocumentEnd, false)2787 .move_selection(&mut view.engine, Movement::DocumentEnd, false)
...@@ -2844,6 +2858,7 @@ fn a_file_dropped_on_blank_page_lies_on_the_page_and_drags() {...@@ -2844,6 +2858,7 @@ fn a_file_dropped_on_blank_page_lies_on_the_page_and_drags() {
2844 bytes: Some(Arc::from(b"float".as_slice())),2858 bytes: Some(Arc::from(b"float".as_slice())),
2845 preview: None,2859 preview: None,
2846 recording: None,2860 recording: None,
2861 tags: Vec::new(),
2847 };2862 };
2848 let _ = view.drop_attachment([400.0, 400.0], file.clone()).unwrap();2863 let _ = view.drop_attachment([400.0, 400.0], file.clone()).unwrap();
2849 assert_eq!(view.editor.outlines().len(), 1);2864 assert_eq!(view.editor.outlines().len(), 1);
...@@ -3154,3 +3169,177 @@ fn the_spelling_pane_walks_marked_words_from_the_caret() {...@@ -3154,3 +3169,177 @@ fn the_spelling_pane_walks_marked_words_from_the_caret() {
3154 spelling.learn("qwrt");3169 spelling.learn("qwrt");
3155 assert_eq!(next(&mut view), None);3170 assert_eq!(next(&mut view), None);
3156}3171}
3172
3173/// Page `title` of `section`, with a view of it whose scene holds its pictures.
3174fn scene_view(section: &[u8], title: &str) -> PageView {
3175 let arena = onestore::Arena::default();
3176 let mut section = onestore::Section::open(&arena, section.to_vec()).unwrap();
3177 let (space, ..) = section
3178 .pages()
3179 .unwrap()
3180 .into_iter()
3181 .find(|page| page.1 == title)
3182 .unwrap();
3183 let mut engine = TextEngine::default();
3184 let (scene, editor) = PageScene::from_page(section.page(space).unwrap(), &mut engine).unwrap();
3185 PageView::new(
3186 editor,
3187 engine,
3188 Some((scene, [0.0; 2])),
3189 [800, 600],
3190 1.0,
3191 Duration::from_millis(500),
3192 )
3193}
3194
3195fn device(view: &PageView, point: [f32; 2]) -> [f32; 2] {
3196 [0, 1].map(|axis| point[axis] * view.viewport.scale + view.viewport.origin[axis])
3197}
3198
3199/// A click selects a picture with a link and Ctrl+click (Command on macOS) follows it, as in
3200/// OneNote 2010, whose tooltip says so (`corpus/picture-link`).
3201#[test]
3202fn ctrl_click_follows_a_pictures_link() {
3203 let mut view = scene_view(
3204 include_bytes!("../../../../corpus/m6/native-features-01/notebook/Features.one"),
3205 "Image png",
3206 );
3207 let (id, [x, y], [width, height]) = view
3208 .editor
3209 .objects
3210 .iter()
3211 .find_map(|object| match object {
3212 crate::editor::page::Content::Image(image) => Some(image.id),
3213 _ => None,
3214 })
3215 .and_then(|id| {
3216 let (origin, size) = view.editor.image_placement(id)?;
3217 Some((id, origin, size))
3218 })
3219 .unwrap();
3220 assert_eq!(
3221 view.editor.picture_link(id),
3222 Some("https://example.invalid/image/png")
3223 );
3224 let _ = view
3225 .pointer_moved(device(&view, [x + width / 2.0, y + height / 2.0]))
3226 .unwrap();
3227 let response = view.pointer_pressed(Instant::now()).unwrap();
3228 let _ = view.pointer_released().unwrap();
3229 assert_eq!(response.request, None);
3230 assert_eq!(view.object_focus, Some(ObjectFocus::Image(id)));
3231 let _ = view
3232 .modifiers_changed(Modifiers {
3233 control: true,
3234 command: true,
3235 ..Modifiers::default()
3236 })
3237 .unwrap();
3238 assert_eq!(view.cursor(), Cursor::Pointer);
3239 let response = view.pointer_pressed(Instant::now()).unwrap();
3240 let _ = view.pointer_released().unwrap();
3241 assert_eq!(
3242 response.request,
3243 Some(Request::OpenLink(
3244 "https://example.invalid/image/png".into()
3245 ))
3246 );
3247}
3248
3249/// A click on the check box OneNote 2010 draws beside a tagged picture on the page checks
3250/// it, and undo clears it (`corpus/object-tags`).
3251#[test]
3252fn a_check_box_beside_a_page_picture_checks_it() {
3253 let mut view = scene_view(
3254 include_bytes!("../../../../corpus/object-tags/native/notebook/files.one"),
3255 "Pictures and files",
3256 );
3257 let (id, [x, y], [_, height]) = view
3258 .editor
3259 .objects
3260 .iter()
3261 .find_map(|object| match object {
3262 crate::editor::page::Content::Image(image) if !image.tags.is_empty() => Some(image.id),
3263 _ => None,
3264 })
3265 .and_then(|id| {
3266 let (origin, size) = view.editor.image_placement(id)?;
3267 Some((id, origin, size))
3268 })
3269 .unwrap();
3270 let status = |view: &PageView| {
3271 view.editor
3272 .objects
3273 .iter()
3274 .find_map(|object| match object {
3275 crate::editor::page::Content::Image(image) if image.id == id => {
3276 Some(image.tags[0].status)
3277 }
3278 _ => None,
3279 })
3280 .unwrap()
3281 };
3282 let _ = view
3283 .pointer_moved(device(&view, [x - 18.0, y + height / 2.0]))
3284 .unwrap();
3285 assert_eq!(view.cursor(), Cursor::Default);
3286 assert!(view.pointer_pressed(Instant::now()).unwrap().changed);
3287 let _ = view.pointer_released().unwrap();
3288 assert_eq!(status(&view) & 1, 1);
3289 assert!(view.editor.undo(&mut view.engine).unwrap());
3290 assert_eq!(status(&view) & 1, 0);
3291}
3292
3293/// OneNote 2010 resizes a column from its right border: a column-resize pointer over it, the
3294/// table redrawn as it moves, and one edit on release.
3295#[test]
3296fn a_column_border_drags_with_a_resize_pointer_and_stores_on_release() {
3297 let mut engine = TextEngine::default();
3298 let mut editor = CanvasEditor::new(
3299 &mut engine,
3300 TextDocument::new(vec![Paragraph::new(String::new(), Format::default())]).unwrap(),
3301 468.0,
3302 )
3303 .unwrap();
3304 editor.insert_table(&mut engine, 1, 2).unwrap();
3305 let mut view = PageView::new(
3306 editor,
3307 engine,
3308 None,
3309 [800, 600],
3310 1.0,
3311 Duration::from_millis(500),
3312 );
3313 let _ = view.editor.take_ops().unwrap();
3314 let outline = view.editor.active_outline();
3315 let cell = outline.shaped().tables[0].cells[0].clone();
3316 let origin = outline.origin();
3317 let (scale, offset) = (view.viewport.scale, view.viewport.origin);
3318 let device = |point: [f32; 2]| [0, 1].map(|axis| point[axis] * scale + offset[axis]);
3319 let border = [
3320 origin[0] + cell.rect[2],
3321 origin[1] + (cell.rect[1] + cell.rect[3]) / 2.0,
3322 ];
3323 let _ = view.pointer_moved(device(border)).unwrap();
3324 assert_eq!(view.cursor(), Cursor::ColResize);
3325 let _ = view.pointer_pressed(Instant::now()).unwrap();
3326 let to = device([border[0] + 50.0, border[1]]);
3327 assert!(view.pointer_moved(to).unwrap().changed);
3328 assert_eq!(view.cursor(), Cursor::ColResize);
3329 view.primitives(COLORS).unwrap();
3330 let onestore::page::ParagraphContent::Table(table) =
3331 &view.editor.active_outline().document().nodes()[0].content
3332 else {
3333 panic!()
3334 };
3335 assert!(!table.columns[0].locked);
3336 let _ = view.pointer_released().unwrap();
3337 let onestore::page::ParagraphContent::Table(table) =
3338 &view.editor.active_outline().document().nodes()[0].content
3339 else {
3340 panic!()
3341 };
3342 assert!(table.columns[0].locked);
3343 assert!((table.columns[0].width - (37.11 + 50.0)).abs() < 0.01);
3344 assert_eq!(view.editor.take_ops().unwrap().len(), 1);
3345}
crates/canvas/src/layout.rs+2
...@@ -62,6 +62,7 @@ pub struct TextLayout {...@@ -62,6 +62,7 @@ pub struct TextLayout {
62 id: u64,62 id: u64,
63 pub(crate) shaped: Arc<Layout<TextBrush>>,63 pub(crate) shaped: Arc<Layout<TextBrush>>,
64 /// The paragraph text laid out, which a PDF's text layer maps glyphs back to.64 /// The paragraph text laid out, which a PDF's text layer maps glyphs back to.
65 #[cfg(feature = "gpu")]
65 pub(crate) text: Arc<str>,66 pub(crate) text: Arc<str>,
66 lines: Vec<LineBox>,67 lines: Vec<LineBox>,
67 /// Where each inline space lies: its index in the spaces laid out, its x, and its line.68 /// Where each inline space lies: its index in the spaces laid out, its x, and its line.
...@@ -520,6 +521,7 @@ impl TextEngine {...@@ -520,6 +521,7 @@ impl TextEngine {
520 Ok(TextLayout {521 Ok(TextLayout {
521 id,522 id,
522 shaped: Arc::new(shaped),523 shaped: Arc::new(shaped),
524 #[cfg(feature = "gpu")]
523 text: text.into(),525 text: text.into(),
524 lines,526 lines,
525 spaces: placed,527 spaces: placed,
crates/canvas/src/outline.rs+72-5
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1use crate::{1use crate::{
2 document::{DocumentEdit, edited_nodes, leaves},2 document::{DocumentEdit, descendants, edited_nodes, leaves},
3 layout::{InlineSpace, LayoutError, TextEngine, TextLayout},3 layout::{InlineSpace, LayoutError, TextEngine, TextLayout},
4};4};
5use onestore::page::text::{Paragraph, TextProjection};5use onestore::page::text::{Paragraph, TextProjection};
...@@ -529,6 +529,51 @@ impl ParagraphTag {...@@ -529,6 +529,51 @@ impl ParagraphTag {
529 }529 }
530}530}
531531
532/// How far a 12 pt icon starts left of a picture or file on the page, as OneNote 2010 draws
533/// one (`corpus/object-tags`).
534const PAGE_INSET: f32 = 24.75;
535
536/// The note tags of a picture or file on the page, whose bounds are `bounds`: oldest first in
537/// a column ending left of it, centred on it; a tag whose definition is missing draws nothing.
538pub(crate) fn object_tags(
539 tags: &[onestore::document::Tag],
540 definitions: &BTreeMap<ExGuid, Definition>,
541 [x, top, _, bottom]: [f32; 4],
542) -> Vec<ParagraphTag> {
543 let side = ParagraphTag::side(crate::layout::DEFAULT_FONT_SIZE);
544 let mut drawn: Vec<ParagraphTag> = tags
545 .iter()
546 .rev()
547 .filter_map(|tag| {
548 let (icon, label) = if tag.status & 4 != 0 {
549 let shape = tag.shape.unwrap_or(0);
550 (TagIcon::Task { shape }, None)
551 } else {
552 let Kind::TagDefinition { shape, label, .. } =
553 &definitions.get(tag.definition.as_ref()?)?.kind
554 else {
555 return None;
556 };
557 let checked = tag.status & 1 != 0;
558 (TagIcon::of(shape.unwrap_or(0), checked)?, label.as_deref())
559 };
560 Some(ParagraphTag {
561 icon,
562 origin: [0.0, (top + bottom - side) / 2.0],
563 size: side,
564 label: label.unwrap_or_default().to_owned(),
565 disabled: tag.status & 2 != 0,
566 })
567 })
568 .collect();
569 let right = x - PAGE_INSET + ParagraphTag::SIZE;
570 let count = drawn.len() as f32;
571 for (index, tag) in drawn.iter_mut().enumerate() {
572 tag.origin[0] = right - side * (count - index as f32);
573 }
574 drawn
575}
576
532/// How much further than its list spacing OneNote 2010 sets a marker's advance from its text.577/// How much further than its list spacing OneNote 2010 sets a marker's advance from its text.
533const MARKER_OFFSET: f32 = 3.9;578const MARKER_OFFSET: f32 = 3.9;
534/// Points an equation's linear text lays out at, holding the caret without showing.579/// Points an equation's linear text lays out at, holding the caret without showing.
...@@ -1519,7 +1564,12 @@ impl OutlineLayout {...@@ -1519,7 +1564,12 @@ impl OutlineLayout {
1519 unreachable!("text and tables are not objects")1564 unreachable!("text and tables are not objects")
1520 }1565 }
1521 };1566 };
1522 object.tags = BlockTags::new(node, &[], x, shape)?;1567 let tags = match content {
1568 ParagraphContent::Image(image) => image.tags.as_slice(),
1569 ParagraphContent::Attachment(file) => file.tags.as_slice(),
1570 _ => &[],
1571 };
1572 object.tags = BlockTags::new(node, tags, x, shape)?;
1523 let flow = object.place(y, height);1573 let flow = object.place(y, height);
1524 result.objects.push(object);1574 result.objects.push(object);
1525 (space, height, flow, extent)1575 (space, height, flow, extent)
...@@ -1580,8 +1630,9 @@ impl OutlineLayout {...@@ -1580,8 +1630,9 @@ impl OutlineLayout {
1580 }1630 }
15811631
1582 /// Lays out root nodes `range` of `nodes` once `edit` applies, the edit's own or the table1632 /// Lays out root nodes `range` of `nodes` once `edit` applies, the edit's own or the table
1583 /// holding its cell, and works out where the nodes after them move. Resizing columns or1633 /// holding its cell, and works out where the nodes after them move. Resizing columns of
1584 /// showing or hiding other nodes lays out every node; renumbering lays out the renumbered.1634 /// other nodes or showing or hiding them lays out every node; renumbering lays out the
1635 /// renumbered.
1585 #[allow(clippy::too_many_arguments)]1636 #[allow(clippy::too_many_arguments)]
1586 pub(crate) fn relayout(1637 pub(crate) fn relayout(
1587 &self,1638 &self,
...@@ -1598,7 +1649,15 @@ impl OutlineLayout {...@@ -1598,7 +1649,15 @@ impl OutlineLayout {
1598 &[f32],1649 &[f32],
1599 ) -> Result<ParagraphLayout, LayoutError>,1650 ) -> Result<ParagraphLayout, LayoutError>,
1600 ) -> Result<Relayout, LayoutError> {1651 ) -> Result<Relayout, LayoutError> {
1601 let mut range = if edit.columns.is_empty() {1652 let resized = |node: &PageParagraph| match &node.content {
1653 ParagraphContent::Table(table) => edit.columns.contains_key(&table.id),
1654 _ => false,
1655 };
1656 let mut range = if edit.columns.len()
1657 == descendants(&nodes[range.clone()], None)
1658 .filter(|(_, _, node)| resized(node))
1659 .count()
1660 {
1602 range1661 range
1603 } else {1662 } else {
1604 0..nodes.len()1663 0..nodes.len()
...@@ -1869,6 +1928,13 @@ impl OutlineLayout {...@@ -1869,6 +1928,13 @@ impl OutlineLayout {
1869 table_width(&self.tables)1928 table_width(&self.tables)
1870 }1929 }
18711930
1931 /// How far the content reaches, without the 36-point floor of `size`.
1932 pub(crate) fn content_width(&self) -> f32 {
1933 let text = self.paragraphs.iter().map(|paragraph| paragraph.size()[0]);
1934 let objects = self.objects.iter().map(|object| object.rect[2]);
1935 text.chain(objects).fold(self.table_width(), f32::max)
1936 }
1937
1872 fn table(1938 fn table(
1873 table: &Table,1939 table: &Table,
1874 depth: usize,1940 depth: usize,
...@@ -2747,6 +2813,7 @@ mod tests {...@@ -2747,6 +2813,7 @@ mod tests {
2747 bytes: None,2813 bytes: None,
2748 preview: None,2814 preview: None,
2749 recording: None,2815 recording: None,
2816 tags: Vec::new(),
2750 });2817 });
2751 let mut ink = paragraph(3, "", 1, None);2818 let mut ink = paragraph(3, "", 1, None);
2752 ink.content = ParagraphContent::Ink(Ink {2819 ink.content = ParagraphContent::Ink(Ink {
crates/canvas/src/print.rs+133-51
...@@ -5,8 +5,8 @@...@@ -5,8 +5,8 @@
5//! when its content reaches past the paper's right edge ("Scale content to paper width").5//! when its content reaches past the paper's right edge ("Scale content to paper width").
6//! A page longer than a sheet continues on the next, which starts at the first line of text6//! A page longer than a sheet continues on the next, which starts at the first line of text
7//! or picture the sheet's foot would have cut. Rule lines and template art print across the7//! or picture the sheet's foot would have cut. Rule lines and template art print across the
8//! paper's width; the page colour does not. Each sheet's footer names the section and8//! paper's width; the page colour does not. Each sheet's footer names the section, numbers
9//! numbers the sheet, counting from the first page printed, in Times New Roman.9//! the sheet counting from the first page printed, or both, in Times New Roman.
1010
11use crate::gpu::{Paper, page::PageScene};11use crate::gpu::{Paper, page::PageScene};
12use crate::layout::{LayoutError, TextEngine};12use crate::layout::{LayoutError, TextEngine};
...@@ -40,15 +40,17 @@ struct Pagination {...@@ -40,15 +40,17 @@ struct Pagination {
4040
41impl Pagination {41impl Pagination {
42 /// Lays content covering `bounds`, whose `rows` (lines of text, pictures) no sheet42 /// Lays content covering `bounds`, whose `rows` (lines of text, pictures) no sheet
43 /// should cut, on `paper` from the page's `margin_origin`.43 /// should cut, on `paper` from the page's `margin_origin`, shrunk to the paper's width
44 /// with `fit_width`.
44 fn new(45 fn new(
45 bounds: [f32; 4],46 bounds: [f32; 4],
46 rows: &[[f32; 2]],47 rows: &[[f32; 2]],
47 margin_origin: [f32; 2],48 margin_origin: [f32; 2],
48 paper: [f32; 2],49 paper: [f32; 2],
50 fit_width: bool,
49 ) -> Self {51 ) -> Self {
50 let left = margin_origin[0] - LEFT;52 let left = margin_origin[0] - LEFT;
51 let scale = if bounds[2].is_finite() {53 let scale = if fit_width && bounds[2].is_finite() {
52 (paper[0] / (bounds[2] - left + RIGHT)).min(1.0)54 (paper[0] / (bounds[2] - left + RIGHT)).min(1.0)
53 } else {55 } else {
54 1.056 1.0
...@@ -113,29 +115,54 @@ impl fmt::Display for PrintError {...@@ -113,29 +115,54 @@ impl fmt::Display for PrintError {
113115
114impl std::error::Error for PrintError {}116impl std::error::Error for PrintError {}
115117
116/// `pages` in order as a PDF on `paper` (points), each on as many sheets as it takes, the118/// What each sheet's footer says, as OneNote's Print Preview offers it.
117/// footers naming `section`.119#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
120pub enum Footer {
121 /// "Section Page 3", OneNote's default.
122 #[default]
123 SectionAndPage,
124 Page,
125 Section,
126 None,
127}
128
129/// How pages go on paper.
130#[derive(Clone, Copy, Debug, PartialEq)]
131pub struct Setup {
132 /// Width and height in points, turned as the sheet prints.
133 pub paper: [f32; 2],
134 /// Shrinks a page whose content reaches past the paper's right edge to fit it.
135 pub fit_width: bool,
136 pub footer: Footer,
137}
138
139/// `sections`' pages in order as a PDF, each on as many sheets as it takes, the footers
140/// naming the section a page is in and numbering the sheets from the first.
118pub fn pdf(141pub fn pdf(
119 pages: Vec<Page>,142 sections: Vec<(String, Vec<Page>)>,
120 engine: &mut TextEngine,143 engine: &mut TextEngine,
121 paper: [f32; 2],144 setup: &Setup,
122 section: &str,
123) -> Result<Vec<u8>, PrintError> {145) -> Result<Vec<u8>, PrintError> {
124 let title = pages146 let paper = setup.paper;
125 .first()147 let title = sections
148 .iter()
149 .flat_map(|(_, pages)| pages.first())
150 .next()
126 .map(|page| page.title.clone())151 .map(|page| page.title.clone())
127 .unwrap_or_default();152 .unwrap_or_default();
128 let mut printed = Vec::with_capacity(pages.len());153 let mut printed = Vec::new();
129 for page in pages {154 for (section, pages) in sections {
130 let (margin_origin, rules) = (page.margin_origin, page.rule_lines);155 for page in pages {
131 let mut scene = PageScene::new(page, engine).map_err(PrintError::Scene)?;156 let (margin_origin, rules) = (page.margin_origin, page.rule_lines);
132 scene.settle(None, DENSITY, Paper::WHITE);157 let mut scene = PageScene::new(page, engine).map_err(PrintError::Scene)?;
133 let (bounds, rows) = scene.printed_extent().map_err(PrintError::Scene)?;158 scene.settle(None, DENSITY, Paper::WHITE);
134 let pagination = Pagination::new(bounds, &rows, margin_origin, paper);159 let (bounds, rows) = scene.printed_extent().map_err(PrintError::Scene)?;
135 printed.push((scene, pagination, margin_origin, rules));160 let pagination = Pagination::new(bounds, &rows, margin_origin, paper, setup.fit_width);
161 printed.push((scene, pagination, margin_origin, rules, section.clone()));
162 }
136 }163 }
137 let mut contents = Vec::with_capacity(printed.len());164 let mut contents = Vec::with_capacity(printed.len());
138 for (scene, pagination, margin_origin, rules) in &printed {165 for (scene, pagination, margin_origin, rules, _) in &printed {
139 let mut primitives = Vec::new();166 let mut primitives = Vec::new();
140 scene167 scene
141 .append_primitives(&mut primitives, [0.0; 2], Paper::WHITE)168 .append_primitives(&mut primitives, [0.0; 2], Paper::WHITE)
...@@ -161,14 +188,17 @@ pub fn pdf(...@@ -161,14 +188,17 @@ pub fn pdf(
161 ..Default::default()188 ..Default::default()
162 };189 };
163 let mut footers = Vec::new();190 let mut footers = Vec::new();
164 for (sheet, _) in printed191 for (sheet, section) in printed
165 .iter()192 .iter()
166 .flat_map(|(_, pagination, ..)| pagination.sheets())193 .flat_map(|(_, pagination, .., section)| pagination.sheets().map(move |_| section))
167 .enumerate()194 .enumerate()
168 {195 {
169 let text = match section {196 let number = sheet + 1;
170 "" => format!("Page {}", sheet + 1),197 let text = match (setup.footer, section.as_str()) {
171 section => format!("{section} Page {}", sheet + 1),198 (Footer::None, _) => continue,
199 (Footer::Section, section) => section.to_owned(),
200 (Footer::Page, _) | (Footer::SectionAndPage, "") => format!("Page {number}"),
201 (Footer::SectionAndPage, section) => format!("{section} Page {number}"),
172 };202 };
173 let layout = engine203 let layout = engine
174 .layout(&Paragraph::new(text, format.clone()), f32::MAX)204 .layout(&Paragraph::new(text, format.clone()), f32::MAX)
...@@ -193,37 +223,33 @@ pub fn pdf(...@@ -193,37 +223,33 @@ pub fn pdf(
193 let scale = pagination.scale;223 let scale = pagination.scale;
194 for ([top, bottom], rules) in pagination.sheets().zip(rules) {224 for ([top, bottom], rules) in pagination.sheets().zip(rules) {
195 let origin = [-pagination.left * scale, MARGIN - top * scale];225 let origin = [-pagination.left * scale, MARGIN - top * scale];
196 // Rule lines run to the bottom margin, the page's content to where the next226 let layer = |origin, clip, primitives| Layer {
197 // sheet takes it up.
198 let page = |primitives, bottom: f32| Layer {
199 scale,227 scale,
200 origin,228 origin,
201 clip: Some([0.0, MARGIN, paper[0], MARGIN + (bottom - top) * scale]),229 clip,
202 backdrop: None,230 backdrop: None,
203 round: None,231 round: None,
204 motion: None,232 motion: None,
205 primitives,233 primitives,
206 };234 };
235 // Rule lines run to the bottom margin, the page's content to where the next
236 // sheet takes it up.
237 let page = |primitives, bottom: f32| {
238 let clip = [0.0, MARGIN, paper[0], MARGIN + (bottom - top) * scale];
239 layer(origin, Some(clip), primitives)
240 };
241 let mut layers = vec![page(rules, top + pagination.band), page(primitives, bottom)];
207 // OneNote shrinks the footer with the page, towards the paper's bottom left.242 // OneNote shrinks the footer with the page, towards the paper's bottom left.
208 let footer = Layer {243 if let Some(footer) = footer.next() {
209 scale,244 let at = [
210 origin: [
211 paper[0] / 2.0 * scale,245 paper[0] / 2.0 * scale,
212 paper[1] - 2.0 - (1.0 - scale) * 16.0,246 paper[1] - 2.0 - (1.0 - scale) * 16.0,
213 ],247 ];
214 clip: None,248 layers.push(layer(at, None, footer));
215 backdrop: None,249 }
216 round: None,
217 motion: None,
218 primitives: footer.next().expect("Each sheet has a footer"),
219 };
220 sheets.push(Sheet {250 sheets.push(Sheet {
221 size: paper,251 size: paper,
222 layers: vec![252 layers,
223 page(rules, top + pagination.band),
224 page(primitives, bottom),
225 footer,
226 ],
227 });253 });
228 }254 }
229 }255 }
...@@ -236,6 +262,13 @@ mod tests {...@@ -236,6 +262,13 @@ mod tests {
236 use onestore::{RevisionIndex, Store, document::Document};262 use onestore::{RevisionIndex, Store, document::Document};
237 use std::collections::HashMap;263 use std::collections::HashMap;
238264
265 /// OneNote 2010's defaults on Letter paper.
266 const ONENOTE: Setup = Setup {
267 paper: LETTER,
268 fit_width: true,
269 footer: Footer::SectionAndPage,
270 };
271
239 fn section(path: &str) -> Vec<Page> {272 fn section(path: &str) -> Vec<Page> {
240 let bytes = std::fs::read(format!("{}/../../{path}", env!("CARGO_MANIFEST_DIR"))).unwrap();273 let bytes = std::fs::read(format!("{}/../../{path}", env!("CARGO_MANIFEST_DIR"))).unwrap();
241 let store = Store::parse(&bytes).unwrap();274 let store = Store::parse(&bytes).unwrap();
...@@ -456,7 +489,13 @@ mod tests {...@@ -456,7 +489,13 @@ mod tests {
456 let rows: Vec<[f32; 2]> = (0..60)489 let rows: Vec<[f32; 2]> = (0..60)
457 .map(|line| [90.0 + line as f32 * 20.0, 110.0 + line as f32 * 20.0])490 .map(|line| [90.0 + line as f32 * 20.0, 110.0 + line as f32 * 20.0])
458 .collect();491 .collect();
459 let pagination = Pagination::new([36.0, 14.4, 400.0, 1290.0], &rows, [36.0, 14.4], LETTER);492 let pagination = Pagination::new(
493 [36.0, 14.4, 400.0, 1290.0],
494 &rows,
495 [36.0, 14.4],
496 LETTER,
497 true,
498 );
460 assert_eq!(pagination.scale, 1.0);499 assert_eq!(pagination.scale, 1.0);
461 // 14.4 + 720 cuts the line from 730 to 750, which starts the next sheet.500 // 14.4 + 720 cuts the line from 730 to 750, which starts the next sheet.
462 assert_eq!(pagination.tops, [14.4, 730.0]);501 assert_eq!(pagination.tops, [14.4, 730.0]);
...@@ -471,6 +510,7 @@ mod tests {...@@ -471,6 +510,7 @@ mod tests {
471 &[[20.0, 1000.0]],510 &[[20.0, 1000.0]],
472 [36.0, 14.4],511 [36.0, 14.4],
473 LETTER,512 LETTER,
513 true,
474 );514 );
475 assert_eq!(pagination.tops, [14.4, 734.4]);515 assert_eq!(pagination.tops, [14.4, 734.4]);
476 }516 }
...@@ -490,6 +530,7 @@ mod tests {...@@ -490,6 +530,7 @@ mod tests {
490 &[],530 &[],
491 [36.0, 14.4],531 [36.0, 14.4],
492 [612.36, 790.92],532 [612.36, 790.92],
533 true,
493 )534 )
494 .scale;535 .scale;
495 assert!(536 assert!(
...@@ -504,10 +545,9 @@ mod tests {...@@ -504,10 +545,9 @@ mod tests {
504 #[test]545 #[test]
505 fn a_section_prints_on_onenotes_sheets() {546 fn a_section_prints_on_onenotes_sheets() {
506 let pdf = pdf(547 let pdf = pdf(
507 section("corpus/print/native/Print.one"),548 vec![("Print".into(), section("corpus/print/native/Print.one"))],
508 &mut TextEngine::default(),549 &mut TextEngine::default(),
509 LETTER,550 &ONENOTE,
510 "Print",
511 )551 )
512 .unwrap();552 .unwrap();
513 let sheets = sheets(&pdf);553 let sheets = sheets(&pdf);
...@@ -538,10 +578,12 @@ mod tests {...@@ -538,10 +578,12 @@ mod tests {
538 #[test]578 #[test]
539 fn ruled_pages_and_art_print_on_onenotes_sheets() {579 fn ruled_pages_and_art_print_on_onenotes_sheets() {
540 let pdf = pdf(580 let pdf = pdf(
541 section("corpus/page-background/candidate/Rules.one"),581 vec![(
582 "Rules".into(),
583 section("corpus/page-background/candidate/Rules.one"),
584 )],
542 &mut TextEngine::default(),585 &mut TextEngine::default(),
543 LETTER,586 &ONENOTE,
544 "Rules",
545 )587 )
546 .unwrap();588 .unwrap();
547 let sheets = sheets(&pdf);589 let sheets = sheets(&pdf);
...@@ -550,4 +592,44 @@ mod tests {...@@ -550,4 +592,44 @@ mod tests {
550 assert!(sheets[9].1.contains("Rules Page 10"));592 assert!(sheets[9].1.contains("Rules Page 10"));
551 assert!(!sheets[9].1.contains("VeryLargeGrid"));593 assert!(!sheets[9].1.contains("VeryLargeGrid"));
552 }594 }
595
596 #[test]
597 fn unscaled_pages_keep_their_size() {
598 let pagination = Pagination::new(
599 [36.0, 14.4, 1200.0, 100.0],
600 &[],
601 [36.0, 14.4],
602 LETTER,
603 false,
604 );
605 assert_eq!(pagination.scale, 1.0);
606 }
607
608 /// Sheets number on across sections, each footer naming its page's section, or say
609 /// what the footer choice asks for.
610 #[test]
611 fn footers_say_what_was_chosen() {
612 let print = section("corpus/print/native/Print.one");
613 let rules = section("corpus/page-background/candidate/Rules.one");
614 let two = vec![
615 ("Print".into(), print[2..3].to_vec()),
616 ("Rules".into(), rules[..1].to_vec()),
617 ];
618 let text = |footer| {
619 let setup = Setup { footer, ..ONENOTE };
620 sheets(&pdf(two.clone(), &mut TextEngine::default(), &setup).unwrap())
621 .into_iter()
622 .map(|(_, text)| text)
623 .collect::<Vec<_>>()
624 };
625 let both = text(Footer::SectionAndPage);
626 assert!(both[0].contains("Print Page 1") && both[1].contains("Rules Page 2"));
627 let pages = text(Footer::Page);
628 assert!(pages[1].contains("Page 2") && !pages[1].contains("Rules"));
629 assert!(
630 text(Footer::Section)[1].contains("Rules")
631 && !text(Footer::Section)[1].contains("Page 2")
632 );
633 assert!(!text(Footer::None)[1].contains("Rules"));
634 }
553}635}
crates/canvas/src/search.rs+39-1
...@@ -203,10 +203,48 @@ fn text_paragraphs<'a>(page: &'a Page, mut visit: impl FnMut(&'a PageParagraph,...@@ -203,10 +203,48 @@ fn text_paragraphs<'a>(page: &'a Page, mut visit: impl FnMut(&'a PageParagraph,
203 }203 }
204}204}
205205
206/// A page's text outside its title, a paragraph to a line.206/// The text OneNote recognised in each of the page's pictures, or printed on a printout's
207/// pages, in page order.
208fn picture_text(page: &Page) -> Vec<&str> {
209 fn walk<'a>(paragraphs: &'a [PageParagraph], out: &mut Vec<&'a str>) {
210 for paragraph in paragraphs {
211 match &paragraph.content {
212 ParagraphContent::Image(image) => {
213 out.extend(image.text.as_ref().map(|text| text.text.as_str()))
214 }
215 ParagraphContent::Table(table) => {
216 for cell in table.rows.iter().flat_map(|row| &row.cells) {
217 walk(&cell.paragraphs, out);
218 }
219 }
220 _ => {}
221 }
222 }
223 }
224 let mut out = Vec::new();
225 for object in &page.objects {
226 match object {
227 PageObject::Outline(outline) => walk(&outline.paragraphs, &mut out),
228 PageObject::Image(image) => {
229 out.extend(image.text.as_ref().map(|text| text.text.as_str()))
230 }
231 _ => {}
232 }
233 }
234 out
235}
236
237/// A page's text outside its title, a paragraph to a line, then the text in its pictures,
238/// which OneNote searches too.
207pub fn page_text(page: &Page) -> String {239pub fn page_text(page: &Page) -> String {
208 let mut out = Vec::new();240 let mut out = Vec::new();
209 text_paragraphs(page, |_, text| out.push(shown(text)));241 text_paragraphs(page, |_, text| out.push(shown(text)));
242 out.extend(
243 picture_text(page)
244 .into_iter()
245 .flat_map(str::lines)
246 .map(str::to_owned),
247 );
210 out.retain(|line| !line.trim().is_empty());248 out.retain(|line| !line.trim().is_empty());
211 out.join("\n")249 out.join("\n")
212}250}
crates/canvas/src/template.rs+3
...@@ -47,6 +47,9 @@ impl Template {...@@ -47,6 +47,9 @@ impl Template {
47 alt: None,47 alt: None,
48 background: true,48 background: true,
49 printout: None,49 printout: None,
50 tags: Vec::new(),
51 link: None,
52 text: None,
50 })53 })
51 })54 })
52 .collect()55 .collect()
crates/canvas/tests/attachment_insert.rs+1
...@@ -58,6 +58,7 @@ fn file(name: &str, bytes: Vec<u8>, preview: Option<Arc<[u8]>>) -> Attachment {...@@ -58,6 +58,7 @@ fn file(name: &str, bytes: Vec<u8>, preview: Option<Arc<[u8]>>) -> Attachment {
58 bytes: Some(bytes.into()),58 bytes: Some(bytes.into()),
59 preview,59 preview,
60 recording: None,60 recording: None,
61 tags: Vec::new(),
61 }62 }
62}63}
6364
crates/canvas/tests/default_font.rs created+122
...@@ -0,0 +1,122 @@
1//! OneNote's Default font as the editor stores it: a new page's title in its face and colour,
2//! and new text in the page's `p` quick style in its face, size and colour. `CANVAS_DEFAULT_FONT_EXPORT`
3//! names a directory receiving the section as a notebook, for a cold reopen in OneNote 2010
4//! (`corpus/default-font`).
5
6use canvas::{
7 editor::{CanvasEditor, DefaultFont},
8 layout::TextEngine,
9};
10use onestore::{
11 Arena, PageCreation, Section, Store,
12 document::Kind,
13 op::{Edit, Op, SectionOp},
14 page::{PageObject, ParagraphContent},
15};
16
17/// Office's standard Blue, COLORREF, which OneNote 2010 stores as `DefaultFontColor`.
18const BLUE: u32 = 0xc07000;
19
20#[test]
21fn new_pages_and_text_take_the_default_font() {
22 let source = onestore::create_section("fonts.one", "Calibri page", "Author").unwrap();
23 let arena = Arena::default();
24 let mut section = Section::open(&arena, source.clone()).unwrap();
25 let creation = PageCreation::new(None, Some("Georgia title"), "Author")
26 .unwrap()
27 .titled_in("Georgia", Some(BLUE))
28 .unwrap();
29 let mut at = 133_000_000_000_000_000;
30 section
31 .apply(
32 "Author",
33 &Edit {
34 at,
35 ops: vec![Op::Section(SectionOp::Create(creation))],
36 },
37 )
38 .unwrap();
39 let (space, ..) = section.pages().unwrap()[1].clone();
40 let mut engine = TextEngine::default();
41 let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap();
42 editor.default_font = DefaultFont {
43 face: "Georgia".into(),
44 size: 14.0,
45 color: Some(BLUE),
46 };
47 for (text, y) in [("Georgia body", 120.0), ("Georgia again", 200.0)] {
48 editor.place_caret(&mut engine, [72.0, y], 240.0).unwrap();
49 editor.insert(&mut engine, text).unwrap();
50 let ops = editor.take_ops().unwrap();
51 at += 10_000_000;
52 let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect();
53 section.apply("Author", &Edit { at, ops }).unwrap();
54 }
55 let mut image = source;
56 section.seal().unwrap().unwrap().apply(&mut image).unwrap();
57
58 let arena = Arena::default();
59 let reopened = Section::open(&arena, image.clone()).unwrap();
60 let page = reopened.page(space).unwrap();
61 let style = |id| {
62 let definition = &page.definitions[&id];
63 let Kind::Style { name, .. } = &definition.kind else {
64 panic!("{definition:?}")
65 };
66 (
67 name.as_deref().unwrap_or_default().to_owned(),
68 definition.format.font.clone().unwrap_or_default(),
69 definition.format.font_size,
70 definition.format.color,
71 )
72 };
73 let title = page
74 .objects
75 .iter()
76 .find_map(|object| match object {
77 PageObject::Title(title) => Some(title),
78 _ => None,
79 })
80 .unwrap();
81 let titled = title
82 .outlines
83 .iter()
84 .flat_map(|outline| &outline.paragraphs)
85 .filter_map(|paragraph| paragraph.style)
86 .map(style)
87 .find(|(name, ..)| name == "PageTitle");
88 assert_eq!(
89 titled,
90 Some(("PageTitle".into(), "Georgia".into(), Some(17.0), Some(BLUE)))
91 );
92 let bodies: Vec<_> = page
93 .objects
94 .iter()
95 .filter_map(|object| match object {
96 PageObject::Outline(outline) if !outline.title => Some(&outline.paragraphs[0]),
97 _ => None,
98 })
99 .collect();
100 assert_eq!(bodies.len(), 2);
101 for body in &bodies {
102 assert!(matches!(body.content, ParagraphContent::Text(_)));
103 assert_eq!(
104 style(body.style.unwrap()),
105 ("p".into(), "Georgia".into(), Some(14.0), Some(BLUE))
106 );
107 }
108 assert_eq!(bodies[0].style, bodies[1].style, "one p per font");
109
110 if let Some(directory) = std::env::var_os("CANVAS_DEFAULT_FONT_EXPORT") {
111 let directory = std::path::PathBuf::from(directory);
112 std::fs::create_dir_all(&directory).unwrap();
113 std::fs::write(directory.join("fonts.one"), &image).unwrap();
114 let file_id = Store::parse(&image).unwrap().header.file_id;
115 std::fs::write(
116 directory.join("Open Notebook.onetoc2"),
117 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("fonts.one", file_id)])
118 .unwrap(),
119 )
120 .unwrap();
121 }
122}
crates/canvas/tests/links_equations.rs+13-3
...@@ -874,11 +874,18 @@ fn selections_into_equation_objects_take_them_whole() {...@@ -874,11 +874,18 @@ fn selections_into_equation_objects_take_them_whole() {
874874
875 // Enter in "Type equation here." leaves the placeholder's paragraph empty above a new one.875 // Enter in "Type equation here." leaves the placeholder's paragraph empty above a new one.
876 let placeholder = find(&editor, "equation here");876 let placeholder = find(&editor, "equation here");
877 select(&mut editor, placeholder.paragraph, placeholder.offset..placeholder.offset);877 select(
878 &mut editor,
879 placeholder.paragraph,
880 placeholder.offset..placeholder.offset,
881 );
878 editor.enter(engine, false).unwrap();882 editor.enter(engine, false).unwrap();
879 save(&mut editor);883 save(&mut editor);
880 let after = texts(&editor);884 let after = texts(&editor);
881 assert_eq!(after[placeholder.paragraph..=placeholder.paragraph + 1], ["", ""]);885 assert_eq!(
886 after[placeholder.paragraph..=placeholder.paragraph + 1],
887 ["", ""]
888 );
882 assert_eq!(caret(&editor).paragraph, placeholder.paragraph + 1);889 assert_eq!(caret(&editor).paragraph, placeholder.paragraph + 1);
883890
884 // From inside a square root into a cube root: both paragraphs go whole.891 // From inside a square root into a cube root: both paragraphs go whole.
...@@ -926,7 +933,10 @@ fn selections_into_equation_objects_take_them_whole() {...@@ -926,7 +933,10 @@ fn selections_into_equation_objects_take_them_whole() {
926 .utf16_offset(texts(&editor)[end].len())933 .utf16_offset(texts(&editor)[end].len())
927 .unwrap();934 .unwrap();
928 select(&mut editor, end, offset..offset);935 select(&mut editor, end, offset..offset);
929 for (before, linear, after) in [("One ", "a/b+c ", " two"), ("Three ", "\\sqrt x+y ", " four")] {936 for (before, linear, after) in [
937 ("One ", "a/b+c ", " two"),
938 ("Three ", "\\sqrt x+y ", " four"),
939 ] {
930 typed(&mut editor, engine, before);940 typed(&mut editor, engine, before);
931 editor.insert_equation(engine).unwrap();941 editor.insert_equation(engine).unwrap();
932 typed(&mut editor, engine, linear);942 typed(&mut editor, engine, linear);
crates/canvas/tests/ops_differential.rs+196-8
...@@ -31,7 +31,7 @@ const ART: [u8; 67] = [...@@ -31,7 +31,7 @@ const ART: [u8; 67] = [
31#[path = "../../onestore/tests/support/sweep.rs"]31#[path = "../../onestore/tests/support/sweep.rs"]
32mod sweep;32mod sweep;
3333
34const SECTIONS: [&str; 19] = [34const SECTIONS: [&str; 20] = [
35 "corpus/outline-edit/before/notebook/synthetic.one",35 "corpus/outline-edit/before/notebook/synthetic.one",
36 "corpus/paragraph-edit/before/notebook/synthetic.one",36 "corpus/paragraph-edit/before/notebook/synthetic.one",
37 "corpus/outline-edit/tree/before/notebook/synthetic.one",37 "corpus/outline-edit/tree/before/notebook/synthetic.one",
...@@ -51,6 +51,7 @@ const SECTIONS: [&str; 19] = [...@@ -51,6 +51,7 @@ const SECTIONS: [&str; 19] = [
51 "corpus/math-edit/native-enter/notebook/links.one",51 "corpus/math-edit/native-enter/notebook/links.one",
52 "corpus/paragraph-format/cold/notebook/synthetic.one",52 "corpus/paragraph-format/cold/notebook/synthetic.one",
53 "corpus/canvas/baseline-anchors.one",53 "corpus/canvas/baseline-anchors.one",
54 "corpus/styles/onenote/Styles.one",
54];55];
5556
56#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]57#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
...@@ -130,6 +131,15 @@ enum Kind {...@@ -130,6 +131,15 @@ enum Kind {
130 /// Lassoed drawings moved or deleted.131 /// Lassoed drawings moved or deleted.
131 LassoMove,132 LassoMove,
132 LassoDelete,133 LassoDelete,
134 /// A style from the Styles gallery on the caret's paragraph or a selection.
135 Style,
136 /// The stored page's styles restyled to a theme, as a theme reaches a page, and the
137 /// editor refreshed from what is stored.
138 Theme,
139 /// A two-by-two table at the caret with a line typed into its first cell, widening it.
140 Table,
141 /// The first table's first column dragged 30 points wider, locking it.
142 ColumnResize,
133 Undo,143 Undo,
134 Redo,144 Redo,
135}145}
...@@ -267,6 +277,74 @@ const INK_PLANS: [&[Kind]; 6] = [...@@ -267,6 +277,74 @@ const INK_PLANS: [&[Kind]; 6] = [
267 &[Kind::LassoMove, Kind::LassoDelete, Kind::Undo, Kind::Undo],277 &[Kind::LassoMove, Kind::LassoDelete, Kind::Undo, Kind::Undo],
268];278];
269279
280/// Gallery styles and themes, in plans of their own so the random edits' sequences stay as
281/// they were.
282/// Tables whose columns fit as they are typed in and dragged, in plans of their own so the
283/// random edits' sequences stay as they were.
284const TABLE_PLANS: [&[Kind]; 2] = [
285 &[Kind::Table, Kind::Undo, Kind::Redo, Kind::TypeOn],
286 &[
287 Kind::Table,
288 Kind::ColumnResize,
289 Kind::TypeOn,
290 Kind::Undo,
291 Kind::Undo,
292 Kind::Redo,
293 ],
294];
295
296const STYLE_PLANS: [&[Kind]; 5] = [
297 &[Kind::Style, Kind::Undo, Kind::Redo],
298 &[Kind::Style, Kind::EnterEnd, Kind::Type, Kind::Undo],
299 &[Kind::Theme, Kind::Style, Kind::TypeEnd, Kind::Undo],
300 &[Kind::Style, Kind::Theme, Kind::EnterEnd, Kind::Type],
301 &[Kind::Theme, Kind::Theme, Kind::Style, Kind::Redo],
302];
303
304/// A theme's styles for the sweep: OneNote's names in another look, headings followed by
305/// Normal; `alternate` picks one of two, so a second restyle changes the page again.
306fn theme(alternate: bool) -> BTreeMap<String, onestore::page::Definition> {
307 use onestore::document::Format;
308 [
309 "h1",
310 "h2",
311 "h3",
312 "PageTitle",
313 "cite",
314 "blockquote",
315 "code",
316 "p",
317 ]
318 .into_iter()
319 .enumerate()
320 .map(|(at, name)| {
321 let definition = onestore::page::Definition {
322 kind: Node::Style {
323 name: Some(name.into()),
324 next: name.starts_with('h').then(|| "p".into()),
325 },
326 format: Format {
327 bold: Some(name.starts_with('h')),
328 italic: Some(name == "blockquote"),
329 underline: Some(false),
330 strike: Some(false),
331 superscript: Some(false),
332 subscript: Some(false),
333 font: Some(if alternate { "Georgia" } else { "Arial" }.into()),
334 font_size: Some(10.0 + at as f32),
335 color: Some(if alternate { 0x0022_3344 } else { 0xff00_0000 }),
336 highlight: Some(0xff00_0000),
337 space_before: Some(if alternate { 6.0 } else { 0.0 }),
338 space_after: Some(0.0),
339 line_spacing: Some(0.0),
340 ..Format::default()
341 },
342 };
343 (name.to_owned(), definition)
344 })
345 .collect()
346}
347
270/// Page colour, rule lines and art undone and redone across each other.348/// Page colour, rule lines and art undone and redone across each other.
271const PAPER_PLANS: [&[Kind]; 3] = [349const PAPER_PLANS: [&[Kind]; 3] = [
272 &[Kind::Art, Kind::Art, Kind::Undo, Kind::Undo, Kind::Redo],350 &[Kind::Art, Kind::Art, Kind::Undo, Kind::Undo, Kind::Redo],
...@@ -327,6 +405,7 @@ fn perform(...@@ -327,6 +405,7 @@ fn perform(
327 Kind::Undo405 Kind::Undo
328 | Kind::Redo406 | Kind::Redo
329 | Kind::TypeOn407 | Kind::TypeOn
408 | Kind::ColumnResize
330 | Kind::PlaceImage409 | Kind::PlaceImage
331 | Kind::RemoveImage410 | Kind::RemoveImage
332 | Kind::CreateOutline411 | Kind::CreateOutline
...@@ -468,6 +547,29 @@ fn perform(...@@ -468,6 +547,29 @@ fn perform(
468 let id = outline.id;547 let id = outline.id;
469 editor.move_outline(id, [x + 36.0, y + 18.0]).is_ok()548 editor.move_outline(id, [x + 36.0, y + 18.0]).is_ok()
470 }549 }
550 Kind::Table => {
551 place(editor, middle)
552 && editor.insert_table(engine, 2, 2).is_ok()
553 && editor
554 .insert(engine, "A line long enough to widen its column")
555 .is_ok()
556 }
557 Kind::ColumnResize => {
558 let table = editor
559 .active_outline()
560 .document()
561 .nodes()
562 .iter()
563 .find_map(|node| match &node.content {
564 onestore::page::ParagraphContent::Table(table) => {
565 Some((table.id, table.columns[0].width))
566 }
567 _ => None,
568 });
569 table.is_some_and(|(id, width)| {
570 editor.resize_column(engine, id, 0, width + 30.0).is_ok()
571 })
572 }
471 Kind::ResizeOutline => {573 Kind::ResizeOutline => {
472 let width = editor.active_outline().wrap_width();574 let width = editor.active_outline().wrap_width();
473 editor.resize(engine, width + 72.0).is_ok()575 editor.resize(engine, width + 72.0).is_ok()
...@@ -602,6 +704,9 @@ fn perform(...@@ -602,6 +704,9 @@ fn perform(
602 alt: None,704 alt: None,
603 background: true,705 background: true,
604 printout: None,706 printout: None,
707 tags: Vec::new(),
708 link: None,
709 text: None,
605 };710 };
606 let shown = editor.page().unwrap().objects.iter().any(|object| {711 let shown = editor.page().unwrap().objects.iter().any(|object| {
607 matches!(object, onestore::page::PageObject::Image(image) if image.background)712 matches!(object, onestore::page::PageObject::Image(image) if image.background)
...@@ -630,6 +735,7 @@ fn perform(...@@ -630,6 +735,7 @@ fn perform(
630 kind: 1,735 kind: 1,
631 duration_ms: Some(1000),736 duration_ms: Some(1000),
632 }),737 }),
738 tags: Vec::new(),
633 };739 };
634 editor.insert(engine, "noted").is_ok()740 editor.insert(engine, "noted").is_ok()
635 && editor.finish_recording(engine, file).is_ok()741 && editor.finish_recording(engine, file).is_ok()
...@@ -645,6 +751,7 @@ fn perform(...@@ -645,6 +751,7 @@ fn perform(
645 bytes: Some(std::sync::Arc::from(b"notes".as_slice())),751 bytes: Some(std::sync::Arc::from(b"notes".as_slice())),
646 preview: Some(std::sync::Arc::from(ART.as_slice())),752 preview: Some(std::sync::Arc::from(ART.as_slice())),
647 recording: None,753 recording: None,
754 tags: Vec::new(),
648 };755 };
649 place(editor, middle) && editor.insert_attachment(engine, file).is_ok()756 place(editor, middle) && editor.insert_attachment(engine, file).is_ok()
650 }757 }
...@@ -658,6 +765,7 @@ fn perform(...@@ -658,6 +765,7 @@ fn perform(
658 bytes: Some(std::sync::Arc::from(b"float".as_slice())),765 bytes: Some(std::sync::Arc::from(b"float".as_slice())),
659 preview: Some(std::sync::Arc::from(ART.as_slice())),766 preview: Some(std::sync::Arc::from(ART.as_slice())),
660 recording: None,767 recording: None,
768 tags: Vec::new(),
661 };769 };
662 let x = 36.0 + 18.0 * random.below(20) as f32;770 let x = 36.0 + 18.0 * random.below(20) as f32;
663 editor.place_caret(engine, [x, 1440.0], 240.0).is_ok()771 editor.place_caret(engine, [x, 1440.0], 240.0).is_ok()
...@@ -778,6 +886,17 @@ fn perform(...@@ -778,6 +886,17 @@ fn perform(
778 _ => editor.delete_ink(&[ink.id]).is_ok(),886 _ => editor.delete_ink(&[ink.id]).is_ok(),
779 }887 }
780 }888 }
889 Kind::Style => {
890 let sheet = theme(random.below(2) == 0);
891 let names = ["h1", "h2", "blockquote", "code", "p"];
892 let style = sheet[names[random.below(names.len())]].clone();
893 (if random.below(2) == 0 {
894 place(editor, middle)
895 } else {
896 range(editor, random)
897 }) && editor.format(engine, Formatting::Style(style)).is_ok()
898 }
899 Kind::Theme => unreachable!("The sweep restyles what is stored"),
781 Kind::Undo => editor.undo(engine).unwrap_or(false),900 Kind::Undo => editor.undo(engine).unwrap_or(false),
782 Kind::Redo => editor.redo(engine).unwrap_or(false),901 Kind::Redo => editor.redo(engine).unwrap_or(false),
783 }902 }
...@@ -843,6 +962,66 @@ fn comparable(page: &Page) -> serde_json::Value {...@@ -843,6 +962,66 @@ fn comparable(page: &Page) -> serde_json::Value {
843}962}
844963
845impl Stored<'_> {964impl Stored<'_> {
965 /// Restyles the stored page to a theme, as opening a themed page does, checks it against
966 /// the model and the sealed image, and refreshes the editor from it, which then shows it.
967 fn theme(
968 &mut self,
969 editor: &mut CanvasEditor,
970 engine: &mut TextEngine,
971 alternate: bool,
972 ) -> Option<Outcome> {
973 self.at += 10_000_000;
974 let before = self.section.page(self.space).unwrap();
975 let sheet = theme(alternate);
976 let ops = onestore::op::restyle(&before, &sheet).unwrap();
977 if ops.is_empty() {
978 return None;
979 }
980 editor.styles = sheet;
981 let edit = Edit {
982 at: self.at,
983 ops: ops
984 .iter()
985 .map(|op| Op::Page {
986 space: self.space,
987 op: op.clone(),
988 })
989 .collect(),
990 };
991 if let Err(error) = self.section.apply("Sweep", &edit) {
992 return Some(Outcome::Differs(format!("a theme is refused: {error}")));
993 }
994 let stored = self.section.page(self.space).unwrap();
995 let mut predicted = before;
996 for op in &ops {
997 onestore::op::predict(&mut predicted, op).unwrap();
998 }
999 if comparable(&predicted) != comparable(&stored) {
1000 return Some(Outcome::Differs(format!(
1001 "the model predicts a theme otherwise: {}",
1002 difference(&stored, &predicted)
1003 )));
1004 }
1005 self.section.seal().unwrap();
1006 let arena = Arena::default();
1007 let reread = Section::open(&arena, self.section.image())
1008 .unwrap()
1009 .page(self.space)
1010 .unwrap();
1011 if reread != stored {
1012 return Some(Outcome::Differs("a theme reads back otherwise".into()));
1013 }
1014 editor.refresh(stored.clone(), engine).unwrap();
1015 Some(if reads_as(&stored, &editor.page().unwrap()) {
1016 Outcome::Same
1017 } else {
1018 Outcome::Differs(format!(
1019 "the refreshed editor shows otherwise: {}",
1020 difference(&stored, &editor.page().unwrap())
1021 ))
1022 })
1023 }
1024
846 /// Stores the editor's step as its ops, checks the stored page against what the model1025 /// Stores the editor's step as its ops, checks the stored page against what the model
847 /// oracle predicts from them and against the sealed image reread, and, unless it is the1026 /// oracle predicts from them and against the sealed image reread, and, unless it is the
848 /// editor's page, compares with that page lowered whole onto the section as it was1027 /// editor's page, compares with that page lowered whole onto the section as it was
...@@ -1071,6 +1250,8 @@ fn sweep(...@@ -1071,6 +1250,8 @@ fn sweep(
1071 .chain(RECORD_PLANS.map(<[Kind]>::to_vec))1250 .chain(RECORD_PLANS.map(<[Kind]>::to_vec))
1072 .chain(FLOATING_PLANS.map(<[Kind]>::to_vec))1251 .chain(FLOATING_PLANS.map(<[Kind]>::to_vec))
1073 .chain(TYPING_PLANS.map(<[Kind]>::to_vec))1252 .chain(TYPING_PLANS.map(<[Kind]>::to_vec))
1253 .chain(STYLE_PLANS.map(<[Kind]>::to_vec))
1254 .chain(TABLE_PLANS.map(<[Kind]>::to_vec))
1074 .collect::<Vec<Vec<Kind>>>();1255 .collect::<Vec<Vec<Kind>>>();
1075 for plan in plans {1256 for plan in plans {
1076 let arena = Arena::default();1257 let arena = Arena::default();
...@@ -1082,13 +1263,20 @@ fn sweep(...@@ -1082,13 +1263,20 @@ fn sweep(
1082 let mut editor = CanvasEditor::from_page(page.clone(), engine).unwrap();1263 let mut editor = CanvasEditor::from_page(page.clone(), engine).unwrap();
1083 for kind in &plan {1264 for kind in &plan {
1084 let model = editor.page().unwrap();1265 let model = editor.page().unwrap();
1085 if !perform(&mut editor, engine, *kind, random) {1266 let outcome = if *kind == Kind::Theme {
1086 continue;1267 match stored.theme(&mut editor, engine, random.below(2) == 0) {
1087 }1268 Some(outcome) => outcome,
1088 if std::env::var_os("OPS_SWEEP_DEBUG").is_some() {1269 None => continue,
1089 eprintln!("STEP {kind:?} of {plan:?} on {title:?}");1270 }
1090 }1271 } else {
1091 let outcome = stored.step(&mut editor, &model);1272 if !perform(&mut editor, engine, *kind, random) {
1273 continue;
1274 }
1275 if std::env::var_os("OPS_SWEEP_DEBUG").is_some() {
1276 eprintln!("STEP {kind:?} of {plan:?} on {title:?}");
1277 }
1278 stored.step(&mut editor, &model)
1279 };
1092 *tally.outcomes.entry((*kind, outcome.clone())).or_default() += 1;1280 *tally.outcomes.entry((*kind, outcome.clone())).or_default() += 1;
1093 if let Outcome::Differs(reason) = &outcome {1281 if let Outcome::Differs(reason) = &outcome {
1094 tally1282 tally
crates/canvas/tests/picture_paste.rs+142-3
...@@ -6,13 +6,13 @@...@@ -6,13 +6,13 @@
66
7use canvas::{7use canvas::{
8 document::TextPosition,8 document::TextPosition,
9 editor::{CanvasEditor, Selection},9 editor::{CanvasEditor, Piece, Selection},
10 layout::TextEngine,10 layout::TextEngine,
11};11};
12use onestore::{12use onestore::{
13 Arena, Section, Store,13 Arena, PageCreation, Section, Store,
14 document::Layout,14 document::Layout,
15 op::{Edit, Op},15 op::{Edit, Op, SectionOp},
16 page::{Image, PageObject, ParagraphContent},16 page::{Image, PageObject, ParagraphContent},
17};17};
18use parley::Affinity;18use parley::Affinity;
...@@ -35,6 +35,9 @@ fn picture(size: [f32; 2]) -> Image {...@@ -35,6 +35,9 @@ fn picture(size: [f32; 2]) -> Image {
35 alt: None,35 alt: None,
36 background: false,36 background: false,
37 printout: None,37 printout: None,
38 tags: Vec::new(),
39 link: None,
40 text: None,
38 }41 }
39}42}
4043
...@@ -123,6 +126,72 @@ fn pasted_pictures_split_the_text_or_lie_on_the_page_as_onenote_places_them() {...@@ -123,6 +126,72 @@ fn pasted_pictures_split_the_text_or_lie_on_the_page_as_onenote_places_them() {
123 store(&mut editor);126 store(&mut editor);
124 assert_eq!(body(&editor), ["Pasted pictures"]);127 assert_eq!(body(&editor), ["Pasted pictures"]);
125128
129 // A web page's text and pictures in order, undone in one step.
130 editor.select(caret(0, 7)).unwrap();
131 let png = picture([1.0; 2]).bytes.unwrap().to_vec();
132 editor
133 .paste_pieces(
134 &mut engine,
135 vec![
136 Piece::Text("web ".into()),
137 Piece::Picture(png, [150.0, 75.0]),
138 Piece::Text("page\ntext ".into()),
139 ],
140 0x0409,
141 )
142 .unwrap();
143 store(&mut editor);
144 assert_eq!(
145 body(&editor),
146 [
147 "Pasted web ",
148 "[150.0, 75.0]",
149 "",
150 "page",
151 "text ",
152 "pictures"
153 ]
154 );
155 editor.undo(&mut engine).unwrap();
156 store(&mut editor);
157 assert_eq!(body(&editor), ["Pasted pictures"]);
158
159 // A web picture still on its way ends the paragraph and goes in when it arrives, an
160 // undo step of its own, while the caret stays.
161 editor.select(caret(0, 7)).unwrap();
162 let awaited = editor
163 .paste_pieces(
164 &mut engine,
165 vec![
166 Piece::Text("web".into()),
167 Piece::Awaited,
168 Piece::Text("page ".into()),
169 ],
170 0x0409,
171 )
172 .unwrap();
173 store(&mut editor);
174 assert_eq!(body(&editor), ["Pasted web", "page pictures"]);
175 let typing = editor.selection();
176 let arrived = picture([150.0, 75.0]);
177 assert!(
178 editor
179 .insert_awaited(&mut engine, awaited[0], arrived)
180 .unwrap()
181 );
182 store(&mut editor);
183 assert_eq!(
184 body(&editor),
185 ["Pasted web", "[150.0, 75.0]", "page pictures"]
186 );
187 assert_eq!(editor.selection(), typing);
188 editor.undo(&mut engine).unwrap();
189 store(&mut editor);
190 assert_eq!(body(&editor), ["Pasted web", "page pictures"]);
191 editor.undo(&mut engine).unwrap();
192 store(&mut editor);
193 assert_eq!(body(&editor), ["Pasted pictures"]);
194
126 // At the end, an empty paragraph follows for the caret; at an empty paragraph's start195 // At the end, an empty paragraph follows for the caret; at an empty paragraph's start
127 // the picture takes its place.196 // the picture takes its place.
128 editor.select(caret(0, 15)).unwrap();197 editor.select(caret(0, 15)).unwrap();
...@@ -194,3 +263,73 @@ fn pasted_pictures_split_the_text_or_lie_on_the_page_as_onenote_places_them() {...@@ -194,3 +263,73 @@ fn pasted_pictures_split_the_text_or_lie_on_the_page_as_onenote_places_them() {
194 .unwrap();263 .unwrap();
195 }264 }
196}265}
266
267/// From the title, OneNote 2010 (lab, 2026-09-30) put a pasted picture at the end of the
268/// outline where the body starts; on a page with no body, in a new outline there; and on a
269/// page whose content lay elsewhere, on the page two grid rows below it.
270#[test]
271fn a_picture_pasted_from_the_title_goes_into_the_body() {
272 let source = onestore::create_section("pictures.one", "First page", "Author").unwrap();
273 let arena = Arena::default();
274 let mut section = Section::open(&arena, source).unwrap();
275 let creation = PageCreation::new(None, Some("Title paste"), "Author").unwrap();
276 section
277 .apply(
278 "Author",
279 &Edit {
280 at: 133_000_000_000_000_000,
281 ops: vec![Op::Section(SectionOp::Create(creation))],
282 },
283 )
284 .unwrap();
285 let (space, ..) = section.pages().unwrap()[1].clone();
286 let page = section.page(space).unwrap();
287 let mut engine = TextEngine::default();
288 let opened = |engine: &mut TextEngine| {
289 let mut editor = CanvasEditor::from_page(page.clone(), engine).unwrap();
290 let title = editor.outlines().iter().find(|o| o.title).unwrap().id;
291 editor.focus_outline(title).unwrap();
292 editor
293 };
294
295 // No body: a new outline where it starts.
296 let mut editor = opened(&mut engine);
297 let start = editor.body_start().unwrap();
298 editor
299 .insert_picture(&mut engine, picture([150.0, 75.0]))
300 .unwrap();
301 assert_eq!(body(&editor), ["[150.0, 75.0]", ""]);
302 assert_eq!(editor.active_outline().origin(), start);
303
304 // A body where it starts: at its end.
305 let mut editor = opened(&mut engine);
306 editor.leave_title(&mut engine).unwrap();
307 editor.insert(&mut engine, "body").unwrap();
308 assert_eq!(editor.active_outline().origin(), start);
309 let title = editor.outlines().iter().find(|o| o.title).unwrap().id;
310 editor.focus_outline(title).unwrap();
311 editor
312 .insert_picture(&mut engine, picture([150.0, 75.0]))
313 .unwrap();
314 assert_eq!(body(&editor), ["body", "[150.0, 75.0]", ""]);
315
316 // Content elsewhere: on the page two grid rows below it.
317 let mut editor = opened(&mut engine);
318 let margin = editor.margin_origin();
319 editor
320 .place_caret(&mut engine, [start[0] + 270.0, start[1] + 72.0], 468.0)
321 .unwrap();
322 editor.insert(&mut engine, "far").unwrap();
323 let bottom = editor.active_outline().bounds().y1 as f32;
324 let title = editor.outlines().iter().find(|o| o.title).unwrap().id;
325 editor.focus_outline(title).unwrap();
326 editor
327 .insert_picture(&mut engine, picture([150.0, 75.0]))
328 .unwrap();
329 let placed = page_pictures(&editor).pop().unwrap();
330 let row = ((bottom - margin[1]) / 18.0).floor() + 2.0;
331 assert_eq!(
332 [placed.layout.x, placed.layout.y],
333 [Some(start[0]), Some(margin[1] + row * 18.0)]
334 );
335}
crates/canvas/tests/pictures.rs created+463
...@@ -0,0 +1,463 @@
1//! Pictures as OneNote 2010 stores them in the wild.
2
3use canvas::{editor::CanvasEditor, layout::TextEngine};
4use onestore::{
5 Arena, Section,
6 op::{Edit, Op},
7 page::{Page, PageObject, ParagraphContent},
8};
9
10fn page(bytes: &[u8], title: &str) -> Page {
11 let arena = Arena::default();
12 let mut section = Section::open(&arena, bytes.to_vec()).unwrap();
13 let (space, ..) = section
14 .pages()
15 .unwrap()
16 .into_iter()
17 .find(|page| page.1 == title)
18 .unwrap();
19 section.page(space).unwrap()
20}
21
22/// A picture COM put on the page stores no layout size, only its own (PictureWidth and
23/// PictureHeight), and shows at that size (`corpus/print`).
24#[test]
25fn a_page_picture_without_a_layout_size_shows_at_its_own() {
26 let page = page(
27 include_bytes!("../../../corpus/print/native/Print.one"),
28 "Printing test",
29 );
30 let [picture] = &page
31 .objects
32 .iter()
33 .filter_map(|object| match object {
34 PageObject::Image(image) => Some(image),
35 _ => None,
36 })
37 .collect::<Vec<_>>()[..]
38 else {
39 panic!("one picture");
40 };
41 assert_eq!(picture.layout.max_width, None);
42 let mut engine = TextEngine::default();
43 let editor = CanvasEditor::from_page(page.clone(), &mut engine).unwrap();
44 let origin = [picture.layout.x.unwrap(), picture.layout.y.unwrap()];
45 assert_eq!(
46 editor.image_placement(picture.id),
47 Some((origin, [150.0, 90.0]))
48 );
49 #[cfg(feature = "gpu")]
50 {
51 let (scene, editor) = canvas::gpu::page::PageScene::from_page(page, &mut engine).unwrap();
52 assert_eq!(scene.read_only(Some(&editor)).count(), 0);
53 }
54}
55
56const OBJECTS: &[u8] = include_bytes!("../../../corpus/object-tags/native/notebook/files.one");
57
58/// The picture or file `id` holds, wherever it is on the page.
59fn tags_of(page: &Page, id: onestore::ExGuid) -> Vec<onestore::document::Tag> {
60 let contents = page.objects.iter().flat_map(|object| match object {
61 PageObject::Outline(outline) => outline
62 .paragraphs
63 .iter()
64 .map(|paragraph| match &paragraph.content {
65 ParagraphContent::Image(image) => (image.id, image.tags.clone()),
66 ParagraphContent::Attachment(file) => (file.id, file.tags.clone()),
67 _ => (paragraph.id, Vec::new()),
68 })
69 .collect(),
70 PageObject::Image(image) => vec![(image.id, image.tags.clone())],
71 PageObject::Attachment(file) => vec![(file.id, file.tags.clone())],
72 _ => Vec::new(),
73 });
74 contents
75 .filter(|(object, _)| *object == id)
76 .map(|(_, tags)| tags)
77 .next()
78 .unwrap()
79}
80
81/// OneNote 2010 stores a tag given to a picture or file on the object itself, in an
82/// outline or on the page, and draws it in a column to its left centred on it; a click on a
83/// check box checks it, one revision each, and undo clears it again
84/// (`corpus/object-tags`). `CANVAS_OBJECT_TAGS_EXPORT` names a new directory receiving the
85/// candidate for a cold reopen.
86#[test]
87fn tags_on_pictures_and_files_draw_and_check() {
88 let arena = Arena::default();
89 let mut section = Section::open(&arena, OBJECTS.to_vec()).unwrap();
90 let (space, ..) = section
91 .pages()
92 .unwrap()
93 .into_iter()
94 .find(|page| page.1 == "Pictures and files")
95 .unwrap();
96 let page = section.page(space).unwrap();
97 let outline = page
98 .objects
99 .iter()
100 .find_map(|object| match object {
101 PageObject::Outline(outline) if outline.paragraphs.len() == 4 => Some(outline),
102 _ => None,
103 })
104 .unwrap();
105 let [(picture_paragraph, picture), (_, file)] = [1, 2].map(|index| {
106 let paragraph = &outline.paragraphs[index];
107 let id = match &paragraph.content {
108 ParagraphContent::Image(image) => image.id,
109 ParagraphContent::Attachment(file) => file.id,
110 _ => panic!("a picture and a file"),
111 };
112 (paragraph.id, id)
113 });
114 let outline_id = outline.id;
115 let (page_picture, page_file) = (
116 page.objects
117 .iter()
118 .find_map(|object| match object {
119 PageObject::Image(image) if !image.tags.is_empty() => Some(image.id),
120 _ => None,
121 })
122 .unwrap(),
123 page.objects
124 .iter()
125 .find_map(|object| match object {
126 PageObject::Attachment(file) => Some(file.id),
127 _ => None,
128 })
129 .unwrap(),
130 );
131 for id in [picture, file, page_picture, page_file] {
132 let tags = tags_of(&page, id);
133 assert_eq!(tags.len(), 1);
134 assert_eq!(tags[0].status & 1, 0);
135 assert!(page.definitions.contains_key(&tags[0].definition.unwrap()));
136 }
137
138 let mut engine = TextEngine::default();
139 let mut editor = CanvasEditor::from_page(page.clone(), &mut engine).unwrap();
140 // The outline's tags stand beside the picture and the file they mark.
141 let shaped = editor
142 .outlines()
143 .iter()
144 .find(|item| item.id == outline_id)
145 .unwrap()
146 .shaped();
147 let marked: Vec<_> = shaped.tags().map(|(paragraph, ..)| paragraph).collect();
148 assert_eq!(marked, [picture_paragraph, outline.paragraphs[2].id]);
149 #[cfg(feature = "gpu")]
150 {
151 // The page picture at (400, 86), 120 by 90, has its tag 24.75 points to its left,
152 // centred on it.
153 let (scene, shown) =
154 canvas::gpu::page::PageScene::from_page(page.clone(), &mut engine).unwrap();
155 let hit = |point| scene.hit_test(point, Some(&shown), |_| None::<()>);
156 assert_eq!(
157 hit([376.0, 126.0]),
158 Some(canvas::gpu::page::SceneHit::Check(page_picture))
159 );
160 assert_eq!(
161 hit([374.0, 126.0]),
162 None,
163 "the tag's column starts 24.75 points left"
164 );
165 }
166
167 let save = |editor: &mut CanvasEditor, section: &mut Section| {
168 let ops = editor
169 .take_ops()
170 .unwrap()
171 .into_iter()
172 .map(|op| Op::Page { space, op })
173 .collect();
174 section
175 .apply(
176 "Author",
177 &Edit {
178 at: 134_000_000_000_000_000,
179 ops,
180 },
181 )
182 .unwrap();
183 section.seal().unwrap().unwrap();
184 };
185 editor.click_object_check(page_picture).unwrap();
186 save(&mut editor, &mut section);
187 editor.click_object_check(page_file).unwrap();
188 save(&mut editor, &mut section);
189 editor
190 .click_check(&mut engine, outline_id, picture_paragraph)
191 .unwrap();
192 save(&mut editor, &mut section);
193 let stored = section.page(space).unwrap();
194 for (id, checked) in [
195 (page_picture, true),
196 (page_file, true),
197 (picture, true),
198 (file, false),
199 ] {
200 assert_eq!(tags_of(&stored, id)[0].status & 1 != 0, checked, "{id:?}");
201 }
202 if let Some(directory) = std::env::var_os("CANVAS_OBJECT_TAGS_EXPORT") {
203 let directory = std::path::PathBuf::from(directory);
204 std::fs::create_dir(&directory).unwrap();
205 let written = section.image();
206 std::fs::write(directory.join("files.one"), &written).unwrap();
207 let file_id = onestore::Store::parse(&written).unwrap().header.file_id;
208 std::fs::write(
209 directory.join("Open Notebook.onetoc2"),
210 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("files.one", file_id)])
211 .unwrap(),
212 )
213 .unwrap();
214 }
215 assert!(editor.undo(&mut engine).unwrap());
216 assert!(editor.undo(&mut engine).unwrap());
217 save(&mut editor, &mut section);
218 let stored = section.page(space).unwrap();
219 assert_eq!(tags_of(&stored, page_file)[0].status & 1, 0);
220 assert_eq!(tags_of(&stored, picture)[0].status & 1, 0);
221 assert_eq!(tags_of(&stored, page_picture)[0].status & 1, 1);
222}
223
224/// Text OneNote recognised in a picture is found by search, as OneNote finds it
225/// (`corpus/object-tags`).
226#[test]
227fn search_finds_text_recognised_in_pictures() {
228 let page = page(OBJECTS, "Pictures and files");
229 let recognised: Vec<_> = page
230 .objects
231 .iter()
232 .filter_map(|object| match object {
233 PageObject::Image(image) => image.text.as_ref().map(|text| text.text.as_str()),
234 _ => None,
235 })
236 .collect();
237 assert_eq!(recognised, ["GLACIER HARBOR\nInvoice 4471"]);
238 let mut index = canvas::search::Index::default();
239 index.set(canvas::search::Entry::new(
240 "files",
241 onestore::ExGuid::default(),
242 &page,
243 0,
244 ));
245 let found = index.search(&canvas::search::Query::new("invoice 4471"), |_| true);
246 assert_eq!(found.len(), 1);
247 assert_eq!(found[0].snippet, "Invoice 4471");
248 assert!(
249 index
250 .search(&canvas::search::Query::new("harb"), |_| true)
251 .len()
252 == 1
253 );
254}
255
256/// A picture's link reads back from where OneNote stores it (WzHyperlinkUrl), and a linked
257/// picture deleted and brought back by undo keeps it (`corpus/picture-link`).
258/// `CANVAS_PICTURE_LINK_EXPORT` names a new directory receiving the candidate for a cold
259/// reopen.
260#[test]
261fn a_linked_picture_keeps_its_link_through_undo() {
262 let source = include_bytes!("../../../corpus/m6/native-features-01/notebook/Features.one");
263 let arena = Arena::default();
264 let mut section = Section::open(&arena, source.to_vec()).unwrap();
265 let (space, ..) = section
266 .pages()
267 .unwrap()
268 .into_iter()
269 .find(|page| page.1 == "Image png")
270 .unwrap();
271 let page = section.page(space).unwrap();
272 let linked = |page: &Page| -> Vec<(onestore::ExGuid, Option<String>)> {
273 page.objects
274 .iter()
275 .filter_map(|object| match object {
276 PageObject::Image(image) => Some((image.id, image.link.clone())),
277 _ => None,
278 })
279 .collect()
280 };
281 let before = linked(&page);
282 let [(id, Some(link))] = &before[..] else {
283 panic!("one linked picture");
284 };
285 assert_eq!(link, "https://example.invalid/image/png");
286 let mut engine = TextEngine::default();
287 let mut editor = CanvasEditor::from_page(page, &mut engine).unwrap();
288 editor.remove_image(&mut engine, *id).unwrap();
289 assert!(editor.undo(&mut engine).unwrap());
290 let ops = editor
291 .take_ops()
292 .unwrap()
293 .into_iter()
294 .map(|op| Op::Page { space, op })
295 .collect();
296 section
297 .apply(
298 "Author",
299 &Edit {
300 at: 134_000_000_000_000_000,
301 ops,
302 },
303 )
304 .unwrap();
305 section.seal().unwrap();
306 assert_eq!(linked(&section.page(space).unwrap()), before);
307 if let Some(directory) = std::env::var_os("CANVAS_PICTURE_LINK_EXPORT") {
308 let directory = std::path::PathBuf::from(directory);
309 std::fs::create_dir(&directory).unwrap();
310 let written = section.image();
311 std::fs::write(directory.join("Features.one"), &written).unwrap();
312 let file_id = onestore::Store::parse(&written).unwrap().header.file_id;
313 std::fs::write(
314 directory.join("Open Notebook.onetoc2"),
315 onestore::create_table_of_contents(
316 "Open Notebook.onetoc2",
317 &[("Features.one", file_id)],
318 )
319 .unwrap(),
320 )
321 .unwrap();
322 }
323}
324
325/// A tag command on a selected picture or file tags the object itself, as OneNote 2010
326/// does (`corpus/object-tags`): a tag it has comes off, one it lacks goes on unchecked.
327#[test]
328fn tag_commands_tag_a_selected_picture_or_file() {
329 use canvas::editor::{Formatting, NoteTag};
330 let arena = Arena::default();
331 let mut section = Section::open(&arena, OBJECTS.to_vec()).unwrap();
332 let (space, ..) = section
333 .pages()
334 .unwrap()
335 .into_iter()
336 .find(|page| page.1 == "Pictures and files")
337 .unwrap();
338 let page = section.page(space).unwrap();
339 let page_picture = page
340 .objects
341 .iter()
342 .find_map(|object| match object {
343 PageObject::Image(image) if !image.tags.is_empty() => Some(image.id),
344 _ => None,
345 })
346 .unwrap();
347 let file = page
348 .objects
349 .iter()
350 .find_map(|object| match object {
351 PageObject::Outline(outline) => {
352 outline.paragraphs.iter().find_map(|p| match &p.content {
353 ParagraphContent::Attachment(file) => Some(file.id),
354 _ => None,
355 })
356 }
357 _ => None,
358 })
359 .unwrap();
360 let mut engine = TextEngine::default();
361 let mut editor = CanvasEditor::from_page(page, &mut engine).unwrap();
362 let to_do = Formatting::Tag(NoteTag::defaults()[0].clone(), 0);
363 let important = Formatting::Tag(NoteTag::defaults()[1].clone(), 1);
364 assert!(
365 editor
366 .format_object(&mut engine, page_picture, &to_do)
367 .unwrap()
368 );
369 assert!(editor.format_object(&mut engine, file, &important).unwrap());
370 assert!(
371 !editor
372 .format_object(&mut engine, file, &Formatting::Bullets)
373 .unwrap()
374 );
375 let ops = editor
376 .take_ops()
377 .unwrap()
378 .into_iter()
379 .map(|op| Op::Page { space, op })
380 .collect();
381 section
382 .apply(
383 "Author",
384 &Edit {
385 at: 134_000_000_000_000_000,
386 ops,
387 },
388 )
389 .unwrap();
390 section.seal().unwrap();
391 let stored = section.page(space).unwrap();
392 assert!(tags_of(&stored, page_picture).is_empty());
393 let tags = tags_of(&stored, file);
394 assert_eq!(tags.len(), 2);
395 assert!(matches!(
396 &stored.definitions[&tags[0].definition.unwrap()].kind,
397 onestore::document::Kind::TagDefinition { label: Some(label), .. } if label == "Important"
398 ));
399}
400
401/// A picture deleted and brought back by undo keeps the text OneNote recognised in it,
402/// written back as OneNote stores it (`corpus/object-tags/restored`).
403/// `CANVAS_RECOGNIZED_EXPORT` names a new directory receiving the candidate for a cold
404/// reopen.
405#[test]
406fn a_restored_picture_keeps_its_recognised_text() {
407 let arena = Arena::default();
408 let mut section = Section::open(&arena, OBJECTS.to_vec()).unwrap();
409 let (space, ..) = section
410 .pages()
411 .unwrap()
412 .into_iter()
413 .find(|page| page.1 == "Pictures and files")
414 .unwrap();
415 let page = section.page(space).unwrap();
416 let recognized = |page: &Page| {
417 page.objects
418 .iter()
419 .find_map(|object| match object {
420 PageObject::Image(image) if image.text.is_some() => {
421 Some((image.id, image.text.clone().unwrap()))
422 }
423 _ => None,
424 })
425 .unwrap()
426 };
427 let (id, text) = recognized(&page);
428 assert_eq!(text.stored.len(), 2, "its language and word layout");
429 let mut engine = TextEngine::default();
430 let mut editor = CanvasEditor::from_page(page, &mut engine).unwrap();
431 editor.remove_image(&mut engine, id).unwrap();
432 assert!(editor.undo(&mut engine).unwrap());
433 let ops = editor
434 .take_ops()
435 .unwrap()
436 .into_iter()
437 .map(|op| Op::Page { space, op })
438 .collect();
439 section
440 .apply(
441 "Author",
442 &Edit {
443 at: 134_000_000_000_000_000,
444 ops,
445 },
446 )
447 .unwrap();
448 section.seal().unwrap();
449 assert_eq!(recognized(&section.page(space).unwrap()), (id, text));
450 if let Some(directory) = std::env::var_os("CANVAS_RECOGNIZED_EXPORT") {
451 let directory = std::path::PathBuf::from(directory);
452 std::fs::create_dir(&directory).unwrap();
453 let written = section.image();
454 std::fs::write(directory.join("files.one"), &written).unwrap();
455 let file_id = onestore::Store::parse(&written).unwrap().header.file_id;
456 std::fs::write(
457 directory.join("Open Notebook.onetoc2"),
458 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("files.one", file_id)])
459 .unwrap(),
460 )
461 .unwrap();
462 }
463}
crates/canvas/tests/printouts.rs+55-3
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1//! A file printout, whose pictures OneNote 2010 stores as pages of an XPS package with a PNG1//! A file printout, whose pictures OneNote 2010 stores as pages of an XPS package with a PNG
2//! rendering of each beside it (WebPictureContainer14), shows that rendering and edits like2//! rendering of each beside it (WebPictureContainer14), shows that rendering and edits like
3//! any picture: its pages stay printouts when moved, and a page deleted comes back on undo3//! any picture: its pages stay printouts when moved, and a page deleted comes back on undo
4//! as the picture OneNote showed of it (`corpus/printout`).4//! as the printout page it was (`corpus/printout`).
55
6use canvas::{document::TextPosition, editor::CanvasEditor, layout::TextEngine};6use canvas::{document::TextPosition, editor::CanvasEditor, layout::TextEngine};
7use onestore::{7use onestore::{
...@@ -100,9 +100,16 @@ fn printouts_show_onenotes_rendering_and_move_as_pictures() {...@@ -100,9 +100,16 @@ fn printouts_show_onenotes_rendering_and_move_as_pictures() {
100 let moved = after.iter().find(|picture| picture.id == moved).unwrap();100 let moved = after.iter().find(|picture| picture.id == moved).unwrap();
101 assert_eq!([moved.layout.x, moved.layout.y], origin.map(Some));101 assert_eq!([moved.layout.x, moved.layout.y], origin.map(Some));
102 assert!(moved.printout.is_some() && moved.display.is_some());102 assert!(moved.printout.is_some() && moved.display.is_some());
103 // The page deleted and brought back by undo is the printout page it was: its package,
104 // page number and the raster OneNote showed of it.
105 let original = printed
106 .iter()
107 .find(|picture| picture.id == restored)
108 .unwrap();
103 let restored = after.iter().find(|picture| picture.id == restored).unwrap();109 let restored = after.iter().find(|picture| picture.id == restored).unwrap();
104 assert_eq!(restored.printout, None);110 assert_eq!(restored.printout, original.printout);
105 assert_eq!(restored.bytes, printed[0].display);111 assert_eq!(restored.bytes, original.bytes);
112 assert_eq!(restored.display, original.display);
106 if let Some(directory) = std::env::var_os("SNOWBOUND_PRINTOUT_EXPORT") {113 if let Some(directory) = std::env::var_os("SNOWBOUND_PRINTOUT_EXPORT") {
107 let directory = std::path::PathBuf::from(directory);114 let directory = std::path::PathBuf::from(directory);
108 std::fs::create_dir(&directory).unwrap();115 std::fs::create_dir(&directory).unwrap();
...@@ -119,3 +126,48 @@ fn printouts_show_onenotes_rendering_and_move_as_pictures() {...@@ -119,3 +126,48 @@ fn printouts_show_onenotes_rendering_and_move_as_pictures() {
119 .unwrap();126 .unwrap();
120 }127 }
121}128}
129
130/// A copy of a printout's page keeps its pages printouts, as a page copied in OneNote does.
131#[test]
132fn a_copied_printout_page_keeps_its_printouts() {
133 let arena = Arena::default();
134 let mut section = Section::open(&arena, PRINTOUTS.to_vec()).unwrap();
135 let (space, ..) = section
136 .pages()
137 .unwrap()
138 .into_iter()
139 .find(|page| page.1 == "Printout")
140 .unwrap();
141 let page = section.page(space).unwrap();
142 let creation = onestore::PageCreation::new(None, Some("Copied"), "Author").unwrap();
143 let edit = Edit {
144 at: 134_000_000_000_000_000,
145 ops: vec![Op::Section(onestore::op::SectionOp::Import {
146 creation,
147 page: page.copy().unwrap(),
148 })],
149 };
150 section.apply("Author", &edit).unwrap();
151 section.seal().unwrap();
152 let (copied, ..) = section
153 .pages()
154 .unwrap()
155 .into_iter()
156 .find(|page| page.1 == "Copied")
157 .unwrap();
158 let copy = section.page(copied).unwrap();
159 let shown = |page: &Page| -> Vec<_> {
160 pictures(page)
161 .into_iter()
162 .map(|picture| {
163 (
164 picture.printout.clone(),
165 picture.bytes.clone(),
166 picture.display.clone(),
167 picture.text.clone(),
168 )
169 })
170 .collect()
171 };
172 assert_eq!(shown(&copy), shown(&page));
173}
crates/canvas/tests/recording.rs+2
...@@ -89,6 +89,7 @@ fn a_recording_stores_its_line_its_linked_notes_and_its_file() {...@@ -89,6 +89,7 @@ fn a_recording_stores_its_line_its_linked_notes_and_its_file() {
89 kind: 1,89 kind: 1,
90 duration_ms: Some(1000),90 duration_ms: Some(1000),
91 }),91 }),
92 tags: Vec::new(),
92 };93 };
93 editor.finish_recording(&mut engine, file).unwrap();94 editor.finish_recording(&mut engine, file).unwrap();
94 assert_eq!(editor.recording(), None);95 assert_eq!(editor.recording(), None);
...@@ -161,6 +162,7 @@ fn a_recording_from_the_title_goes_to_the_body_s_start() {...@@ -161,6 +162,7 @@ fn a_recording_from_the_title_goes_to_the_body_s_start() {
161 kind: 1,162 kind: 1,
162 duration_ms: None,163 duration_ms: None,
163 }),164 }),
165 tags: Vec::new(),
164 };166 };
165 editor.finish_recording(&mut engine, file).unwrap();167 editor.finish_recording(&mut engine, file).unwrap();
166 let ops = editor.take_ops().unwrap();168 let ops = editor.take_ops().unwrap();
crates/canvas/tests/table_widths.rs created+241
...@@ -0,0 +1,241 @@
1//! Table columns fit their content as it is typed and lock where a border is dragged, each
2//! keystroke's width stored in the revision with its text, and OneNote 2010 reads them back
3//! as it sizes them itself (`corpus/table-widths`).
4
5use canvas::{document::TextPosition, editor::CanvasEditor, layout::TextEngine};
6use onestore::{
7 Arena, ExGuid, Section, Store,
8 op::{Edit, Op},
9 page::{Page, PageObject, PageParagraph, ParagraphContent, TableColumn},
10};
11
12const TABLES: &[u8] = include_bytes!("../../../corpus/table-tags/native/notebook/Tables.one");
13
14/// Each table's first cell text and columns, in page order.
15fn tables(page: &Page) -> Vec<(String, Vec<TableColumn>)> {
16 fn walk(nodes: &[PageParagraph], out: &mut Vec<(String, Vec<TableColumn>)>) {
17 for node in nodes {
18 if let ParagraphContent::Table(table) = &node.content {
19 let first = table.rows[0].cells[0].paragraphs[0]
20 .text()
21 .map_or(String::new(), |text| text.text.text().to_owned());
22 out.push((first, table.columns.clone()));
23 for cell in table.rows.iter().flat_map(|row| &row.cells) {
24 walk(&cell.paragraphs, out);
25 }
26 }
27 }
28 }
29 let mut out = Vec::new();
30 for object in &page.objects {
31 if let PageObject::Outline(outline) = object {
32 walk(&outline.paragraphs, &mut out);
33 }
34 }
35 out
36}
37
38fn body(page: &Page) -> ExGuid {
39 page.objects
40 .iter()
41 .find_map(|object| match object {
42 PageObject::Outline(outline) if !outline.paragraphs.is_empty() => Some(outline.id),
43 _ => None,
44 })
45 .unwrap()
46}
47
48fn publish(section: &mut Section<'_>, space: ExGuid, editor: &mut CanvasEditor, at: u64) {
49 let ops = editor
50 .take_ops()
51 .unwrap()
52 .into_iter()
53 .map(|op| Op::Page { space, op })
54 .collect();
55 section.apply("Author", &Edit { at, ops }).unwrap();
56}
57
58/// Opens `space` with the caret at the end of its body's last paragraph.
59fn at_end(section: &Section<'_>, space: ExGuid, engine: &mut TextEngine) -> CanvasEditor {
60 let page = section.page(space).unwrap();
61 let mut editor = CanvasEditor::from_page(page.clone(), engine).unwrap();
62 editor.focus_outline(body(&page)).unwrap();
63 let document = editor.active_outline().document();
64 let paragraph = document.paragraphs().count() - 1;
65 let offset = document
66 .paragraphs()
67 .last()
68 .unwrap()
69 .text()
70 .encode_utf16()
71 .count() as u32;
72 editor
73 .select([TextPosition { paragraph, offset }; 2].into())
74 .unwrap();
75 editor
76}
77
78fn type_rows(editor: &mut CanvasEditor, engine: &mut TextEngine, rows: &[&[&str]]) {
79 for (index, row) in rows.iter().enumerate() {
80 if index > 0 {
81 editor.enter(engine, false).unwrap();
82 }
83 for (column, text) in row.iter().enumerate() {
84 if column > 0 {
85 editor.tab(engine, false).unwrap();
86 }
87 editor.insert(engine, text).unwrap();
88 }
89 }
90}
91
92fn close(columns: &[TableColumn], expected: &[(f32, bool)]) -> bool {
93 columns.len() == expected.len()
94 && columns
95 .iter()
96 .zip(expected)
97 .all(|(column, (width, locked))| {
98 (column.width - width).abs() < 0.01 && column.locked == *locked
99 })
100}
101
102/// `SNOWBOUND_TABLE_WIDTHS_EXPORT` names a new directory receiving the candidate for a cold
103/// reopen.
104#[test]
105fn typed_and_dragged_columns_store_onenote_s_widths() {
106 let arena = Arena::default();
107 let mut section = Section::open(&arena, TABLES.to_vec()).unwrap();
108 let pages = section.pages().unwrap();
109 let space = |title: &str| pages.iter().find(|page| page.1 == title).unwrap().0;
110 let (fruit, typed, dragged) = (
111 space("Check a table"),
112 space("Edit a tagged table"),
113 space("Delete and undo"),
114 );
115 let mut engine = TextEngine::default();
116 let mut at = 134_000_000_000_000_000;
117 let mut step = |section: &mut Section<'_>, space, editor: &mut CanvasEditor| {
118 at += 10_000_000;
119 publish(section, space, editor, at);
120 };
121
122 // A table typed after the page's text, one revision per keystroke.
123 let mut editor = at_end(&section, fruit, &mut engine);
124 editor.enter(&mut engine, false).unwrap();
125 editor.insert_table(&mut engine, 1, 1).unwrap();
126 step(&mut section, fruit, &mut editor);
127 for (index, row) in [
128 ["Fruit", "Colour", "Notes"],
129 ["Apple", "Red", "Crisp and sweet, good for pies"],
130 ["Watermelon", "Green", "Summer"],
131 ]
132 .iter()
133 .enumerate()
134 {
135 if index > 0 {
136 editor.enter(&mut engine, false).unwrap();
137 step(&mut section, fruit, &mut editor);
138 }
139 for (column, text) in row.iter().enumerate() {
140 if column > 0 {
141 editor.tab(&mut engine, false).unwrap();
142 step(&mut section, fruit, &mut editor);
143 }
144 for character in text.chars() {
145 editor.insert(&mut engine, &character.to_string()).unwrap();
146 step(&mut section, fruit, &mut editor);
147 }
148 }
149 }
150 let stored = tables(&section.page(fruit).unwrap());
151 // OneNote 2010 typing the same table (lab, 2026-09-30).
152 assert!(
153 close(
154 &stored[1].1,
155 &[(60.759, false), (37.11, false), (139.086, false)]
156 ),
157 "{stored:?}"
158 );
159
160 // Typing into a native table's cell widens its column; deleting narrows it again.
161 let mut editor = CanvasEditor::from_page(section.page(typed).unwrap(), &mut engine).unwrap();
162 editor
163 .focus_outline(body(&section.page(typed).unwrap()))
164 .unwrap();
165 editor
166 .select(
167 [TextPosition {
168 paragraph: 1,
169 offset: 10,
170 }; 2]
171 .into(),
172 )
173 .unwrap();
174 editor
175 .insert(&mut engine, " and typed on in Snowbound")
176 .unwrap();
177 step(&mut section, typed, &mut editor);
178 let widened = tables(&section.page(typed).unwrap());
179 assert!(widened[0].1[0].width > 100.0, "{widened:?}");
180
181 // A dragged column locks and keeps its width while text wraps in it, and the column
182 // after it moves; a long line in that column stops at the outline's width.
183 let mut editor = at_end(&section, dragged, &mut engine);
184 editor.enter(&mut engine, false).unwrap();
185 editor.insert_table(&mut engine, 1, 1).unwrap();
186 type_rows(&mut editor, &mut engine, &[&["Dragged", "Fitted"]]);
187 step(&mut section, dragged, &mut editor);
188 let table = editor
189 .active_outline()
190 .document()
191 .nodes()
192 .iter()
193 .find_map(|node| match &node.content {
194 ParagraphContent::Table(table)
195 if table.rows[0].cells[0].paragraphs[0]
196 .text()
197 .is_some_and(|text| text.text.text() == "Dragged") =>
198 {
199 Some(table.id)
200 }
201 _ => None,
202 })
203 .unwrap();
204 editor.resize_column(&mut engine, table, 0, 96.0).unwrap();
205 step(&mut section, dragged, &mut editor);
206 editor
207 .insert(
208 &mut engine,
209 " and then a line long enough to reach the outline's edge and wrap there",
210 )
211 .unwrap();
212 step(&mut section, dragged, &mut editor);
213 let resized = tables(&section.page(dragged).unwrap());
214 assert!(
215 resized[2].1[0].locked && resized[2].1[0].width == 96.0,
216 "{resized:?}"
217 );
218 assert!(
219 !resized[2].1[1].locked && resized[2].1[1].width > 200.0,
220 "{resized:?}"
221 );
222
223 section.seal().unwrap();
224 let written = section.image();
225 let reread = Section::open(&arena, written.clone()).unwrap();
226 assert_eq!(tables(&reread.page(fruit).unwrap()), stored);
227 assert_eq!(tables(&reread.page(typed).unwrap()), widened);
228 assert_eq!(tables(&reread.page(dragged).unwrap()), resized);
229 if let Some(directory) = std::env::var_os("SNOWBOUND_TABLE_WIDTHS_EXPORT") {
230 let directory = std::path::PathBuf::from(directory);
231 std::fs::create_dir(&directory).unwrap();
232 std::fs::write(directory.join("Tables.one"), &written).unwrap();
233 let file_id = Store::parse(&written).unwrap().header.file_id;
234 std::fs::write(
235 directory.join("Open Notebook.onetoc2"),
236 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("Tables.one", file_id)])
237 .unwrap(),
238 )
239 .unwrap();
240 }
241}
crates/draw/src/render.rs+5-2
...@@ -448,12 +448,14 @@ pub enum Primitive<'a> {...@@ -448,12 +448,14 @@ pub enum Primitive<'a> {
448 round: bool,448 round: bool,
449 color: [f32; 4],449 color: [f32; 4],
450 },450 },
451 /// A round pen's stroke between two points, `widths` units across at each, joined by the451 /// A pen's stroke between two points, `widths` units across at each, joined by the
452 /// lines touching both ends, as a pressure pen's width changes along a stroke.452 /// lines touching both ends, as a pressure pen's width changes along a stroke. A square
453 /// pen tip (not `round`) squares the ends in a PDF; the screen rounds them either way.
453 Taper {454 Taper {
454 from: [f32; 2],455 from: [f32; 2],
455 to: [f32; 2],456 to: [f32; 2],
456 widths: [f32; 2],457 widths: [f32; 2],
458 round: bool,
457 color: [f32; 4],459 color: [f32; 4],
458 },460 },
459 /// A highlighter's stroke between two points, `width` units across with square ends:461 /// A highlighter's stroke between two points, `width` units across with square ends:
...@@ -897,6 +899,7 @@ impl Renderer {...@@ -897,6 +899,7 @@ impl Renderer {
897 to,899 to,
898 widths,900 widths,
899 color,901 color,
902 ..
900 } => self.segment(space, *from, *to, *widths, true, *color)?,903 } => self.segment(space, *from, *to, *widths, true, *color)?,
901 Primitive::Highlight {904 Primitive::Highlight {
902 from,905 from,
crates/draw/src/render/pdf.rs+38
...@@ -521,7 +521,45 @@ impl Document {...@@ -521,7 +521,45 @@ impl Document {
521 from,521 from,
522 to,522 to,
523 widths,523 widths,
524 round: false,
524 color,525 color,
526 } => {
527 content.save_state();
528 self.paint(content, used, *color, false);
529 // Square ends reach half their width past each point, as a square tip does.
530 let [from_radius, to_radius] = widths.map(|width| width / 2.0);
531 let [dx, dy] = [to[0] - from[0], to[1] - from[1]];
532 let length = dx.hypot(dy);
533 let [ux, uy] = if length > 0.0 {
534 [dx / length, dy / length]
535 } else {
536 [1.0, 0.0]
537 };
538 let corner = |center: &[f32; 2], radius: f32, ahead: f32, side: f32| {
539 [
540 center[0] + radius * (ux * ahead - uy * side),
541 center[1] + radius * (uy * ahead + ux * side),
542 ]
543 };
544 let corners = [
545 corner(from, from_radius, -1.0, 1.0),
546 corner(to, to_radius, 1.0, 1.0),
547 corner(to, to_radius, 1.0, -1.0),
548 corner(from, from_radius, -1.0, -1.0),
549 ];
550 content.move_to(corners[0][0], corners[0][1]);
551 for corner in &corners[1..] {
552 content.line_to(corner[0], corner[1]);
553 }
554 content.close_path().fill_nonzero();
555 content.restore_state();
556 }
557 Primitive::Taper {
558 from,
559 to,
560 widths,
561 color,
562 ..
525 } => {563 } => {
526 content.save_state();564 content.save_state();
527 self.paint(content, used, *color, false);565 self.paint(content, used, *color, false);
crates/mobile/Cargo.toml+1-1
...@@ -9,7 +9,7 @@ publish = false...@@ -9,7 +9,7 @@ publish = false
9crate-type = ["staticlib"]9crate-type = ["staticlib"]
1010
11[dependencies]11[dependencies]
12canvas = { path = "../canvas", features = ["interaction"] }12canvas = { path = "../canvas", features = ["interaction", "pdf"] }
13draw = { path = "../draw" }13draw = { path = "../draw" }
14notebook = { path = "../notebook", features = ["smb"] }14notebook = { path = "../notebook", features = ["smb"] }
15onestore = { path = "../onestore" }15onestore = { path = "../onestore" }
crates/mobile/include/snowbound.h+8
...@@ -52,6 +52,8 @@ char *sb_section_pages(const Section *section);...@@ -52,6 +52,8 @@ char *sb_section_pages(const Section *section);
52uint32_t sb_section_poll(const Section *section, uint8_t *status);52uint32_t sb_section_poll(const Section *section, uint8_t *status);
53char *sb_section_new_page(const Section *section, const char *parent, const char *date, const char *time);53char *sb_section_new_page(const Section *section, const char *parent, const char *date, const char *time);
54bool sb_section_delete_page(const Section *section, const char *id, const char *date, const char *time);54bool sb_section_delete_page(const Section *section, const char *id, const char *date, const char *time);
55char *sb_section_themes(const Section *section, const char *id);
56bool sb_section_set_theme(const Section *section, const char *id, uint8_t scope, const char *theme);
55bool sb_section_flush(const Section *section, double seconds);57bool sb_section_flush(const Section *section, double seconds);
56void sb_section_wake(const Section *section);58void sb_section_wake(const Section *section);
57void sb_section_free(Section *section);59void sb_section_free(Section *section);
...@@ -70,6 +72,8 @@ bool sb_view_block(const View *view, float x, float y, float rect[4]);...@@ -70,6 +72,8 @@ bool sb_view_block(const View *view, float x, float y, float rect[4]);
70void sb_view_set_transform(View *view, float zoom, float x, float y);72void sb_view_set_transform(View *view, float zoom, float x, float y);
71uint8_t sb_view_target(const View *view, float x, float y);73uint8_t sb_view_target(const View *view, float x, float y);
72void sb_view_pressure(View *view, float pressure);74void sb_view_pressure(View *view, float pressure);
75void sb_set_snap_to_grid(bool on);
76void sb_set_default_font(const char *face, float size);
73bool sb_view_press(View *view, float x, float y);77bool sb_view_press(View *view, float x, float y);
74bool sb_view_drag(View *view, float x, float y);78bool sb_view_drag(View *view, float x, float y);
75bool sb_view_release(View *view);79bool sb_view_release(View *view);
...@@ -77,6 +81,8 @@ bool sb_view_undo(View *view, bool redo);...@@ -77,6 +81,8 @@ bool sb_view_undo(View *view, bool redo);
77bool sb_view_can_undo(const View *view, bool redo);81bool sb_view_can_undo(const View *view, bool redo);
78uint64_t sb_view_format(const View *view);82uint64_t sb_view_format(const View *view);
79bool sb_view_apply(View *view, uint8_t command);83bool sb_view_apply(View *view, uint8_t command);
84bool sb_view_style(View *view, uint8_t place);
85int32_t sb_view_style_place(const View *view);
80char *sb_view_correction(const View *view);86char *sb_view_correction(const View *view);
81bool sb_view_correct(View *view, uint8_t command, const char *text);87bool sb_view_correct(View *view, uint8_t command, const char *text);
82char *sb_view_title(const View *view);88char *sb_view_title(const View *view);
...@@ -92,6 +98,8 @@ char *sb_view_paper(const View *view);...@@ -92,6 +98,8 @@ char *sb_view_paper(const View *view);
92bool sb_view_set_paper(View *view, int16_t red, uint8_t green, uint8_t blue, int8_t ruled);98bool sb_view_set_paper(View *view, int16_t red, uint8_t green, uint8_t blue, int8_t ruled);
93bool sb_view_set_art(View *view, const char *name);99bool sb_view_set_art(View *view, const char *name);
94char *sb_view_page_text(const View *view);100char *sb_view_page_text(const View *view);
101uint8_t *sb_view_pdf(const View *view, float width, float height, const char *section, size_t *length);
102void sb_bytes_free(uint8_t *bytes, size_t length);
95int8_t sb_view_date_request(View *view, int64_t *seconds);103int8_t sb_view_date_request(View *view, int64_t *seconds);
96bool sb_view_change_date(View *view, int64_t seconds, const char *date, const char *time);104bool sb_view_change_date(View *view, int64_t seconds, const char *date, const char *time);
97bool sb_view_insert_picture(View *view, const uint8_t *bytes, size_t length, float width, float height);105bool sb_view_insert_picture(View *view, const uint8_t *bytes, size_t length, float width, float height);
crates/mobile/src/lib.rs+132-3
...@@ -122,6 +122,26 @@ struct Canvas {...@@ -122,6 +122,26 @@ struct Canvas {
122}122}
123123
124impl Canvas {124impl Canvas {
125 /// The page on `paper` as OneNote 2010 prints it, footers naming `section`.
126 fn pdf(&self, paper: [f32; 2], section: &str) -> Option<Vec<u8>> {
127 let page = self.page.editor.page().ok()?;
128 let mut engine = ENGINES
129 .lock()
130 .ok()
131 .and_then(|mut engines| engines.pop())
132 .unwrap_or_default();
133 let setup = canvas::print::Setup {
134 paper,
135 fit_width: true,
136 footer: canvas::print::Footer::SectionAndPage,
137 };
138 let pdf = canvas::print::pdf(vec![(section.to_owned(), vec![page])], &mut engine, &setup);
139 if let Ok(mut engines) = ENGINES.lock() {
140 engines.push(engine);
141 }
142 pdf.ok()
143 }
144
125 fn new(space: ExGuid, page: Page, pixels: [u32; 2], scale: f32) -> Result<Self> {145 fn new(space: ExGuid, page: Page, pixels: [u32; 2], scale: f32) -> Result<Self> {
126 let mut engine = ENGINES146 let mut engine = ENGINES
127 .lock()147 .lock()
...@@ -139,6 +159,7 @@ impl Canvas {...@@ -139,6 +159,7 @@ impl Canvas {
139 );159 );
140 page.touch = true;160 page.touch = true;
141 page.host_viewport = true;161 page.host_viewport = true;
162 (page.snap_to_grid, page.editor.default_font) = options();
142 Ok(Self {163 Ok(Self {
143 page,164 page,
144 space,165 space,
...@@ -235,7 +256,7 @@ impl Canvas {...@@ -235,7 +256,7 @@ impl Canvas {
235 return Target::Grip;256 return Target::Grip;
236 }257 }
237 match self.page.hit(device) {258 match self.page.hit(device) {
238 Some(Hit::Handle { .. } | Hit::Resize { .. }) => Target::Grip,259 Some(Hit::Handle { .. } | Hit::Resize { .. } | Hit::Column { .. }) => Target::Grip,
239 Some(Hit::Image { id, .. })260 Some(Hit::Image { id, .. })
240 if self.page.object_focus() == Some(ObjectFocus::Image(id)) =>261 if self.page.object_focus() == Some(ObjectFocus::Image(id)) =>
241 {262 {
...@@ -612,6 +633,40 @@ fn gpu() -> std::sync::MutexGuard<'static, Option<Gpu>> {...@@ -612,6 +633,40 @@ fn gpu() -> std::sync::MutexGuard<'static, Option<Gpu>> {
612/// rather than enumerating the system's fonts again.633/// rather than enumerating the system's fonts again.
613static ENGINES: Mutex<Vec<TextEngine>> = Mutex::new(Vec::new());634static ENGINES: Mutex<Vec<TextEngine>> = Mutex::new(Vec::new());
614635
636/// The reader's Snap To Grid and Default font, which every page opened from then on takes,
637/// and new pages' titles the font's face.
638static OPTIONS: Mutex<Option<(bool, canvas::editor::DefaultFont)>> = Mutex::new(None);
639
640fn options() -> (bool, canvas::editor::DefaultFont) {
641 OPTIONS
642 .lock()
643 .unwrap_or_else(|error| error.into_inner())
644 .clone()
645 .unwrap_or((true, canvas::editor::DefaultFont::default()))
646}
647
648/// OneNote's Snap To Grid: taps, drags and shapes land on the placement grid.
649#[unsafe(no_mangle)]
650pub extern "C" fn sb_set_snap_to_grid(on: bool) {
651 let font = options().1;
652 *OPTIONS.lock().unwrap_or_else(|error| error.into_inner()) = Some((on, font));
653}
654
655/// OneNote's Default font: new text in `face` at `size` points, new titles in `face`.
656///
657/// # Safety
658/// `face` is NUL-terminated UTF-8.
659#[unsafe(no_mangle)]
660pub unsafe extern "C" fn sb_set_default_font(face: *const c_char, size: f32) {
661 let snap = options().0;
662 let font = canvas::editor::DefaultFont {
663 face: string(face),
664 size,
665 color: None,
666 };
667 *OPTIONS.lock().unwrap_or_else(|error| error.into_inner()) = Some((snap, font));
668}
669
615pub struct View {670pub struct View {
616 /// Taken apart on drop, which returns its text engine to `ENGINES`.671 /// Taken apart on drop, which returns its text engine to `ENGINES`.
617 canvas: ManuallyDrop<Canvas>,672 canvas: ManuallyDrop<Canvas>,
...@@ -668,9 +723,24 @@ impl View {...@@ -668,9 +723,24 @@ impl View {
668 .get_default_config(&gpu.adapter, pixels[0], pixels[1])723 .get_default_config(&gpu.adapter, pixels[0], pixels[1])
669 .ok_or("No supported surface configuration")?;724 .ok_or("No supported surface configuration")?;
670 surface.configure(gpu.renderer.device(), &config);725 surface.configure(gpu.renderer.device(), &config);
671 let (page, read_only) = section.shared.page(space)?;726 let (mut page, read_only) = section.shared.page(space)?;
727 // The page wears its theme: style objects it gives otherwise are restyled first.
728 let sheet = section
729 .theme(page.identity)
730 .map(|theme| theme.sheet())
731 .unwrap_or_default();
732 let restyle = onestore::op::restyle(&page, &sheet)?;
733 if !read_only && !restyle.is_empty() {
734 section.shared.apply(Edit {
735 at: library::filetime(),
736 ops: restyle
737 .into_iter()
738 .map(|op| Op::Page { space, op })
739 .collect(),
740 })?;
741 page = section.shared.page(space)?.0;
742 }
672 let frame = Arc::<Frame>::default();743 let frame = Arc::<Frame>::default();
673 #[cfg_attr(not(target_os = "ios"), expect(unused_mut))]
674 let mut canvas = Canvas::new(space, page, pixels, scale)?;744 let mut canvas = Canvas::new(space, page, pixels, scale)?;
675 #[cfg(target_os = "ios")]745 #[cfg(target_os = "ios")]
676 {746 {
...@@ -679,6 +749,7 @@ impl View {...@@ -679,6 +749,7 @@ impl View {
679 Waker::from(frame.clone()),749 Waker::from(frame.clone()),
680 ));750 ));
681 }751 }
752 canvas.page.editor.styles = sheet;
682 Ok(Self {753 Ok(Self {
683 canvas: ManuallyDrop::new(canvas),754 canvas: ManuallyDrop::new(canvas),
684 surface,755 surface,
...@@ -967,6 +1038,37 @@ pub extern "C" fn sb_view_apply(view: &mut View, command: u8) -> bool {...@@ -967,6 +1038,37 @@ pub extern "C" fn sb_view_apply(view: &mut View, command: u8) -> bool {
967 view.stored(result)1038 view.stored(result)
968}1039}
9691040
1041/// Gives the selected paragraphs style `place` of OneNote's Styles gallery (Heading 1 to 6,
1042/// Page Title, Citation, Quote, Code, Normal) in the page's theme, or OneNote 2010's.
1043#[unsafe(no_mangle)]
1044pub extern "C" fn sb_view_style(view: &mut View, place: u8) -> bool {
1045 use notebook::sidecar::themes::{STYLES, built_in, definition};
1046 let Some((name, _)) = STYLES.get(usize::from(place)) else {
1047 return false;
1048 };
1049 let editor = &view.canvas.page.editor;
1050 let style = editor.styles.get(*name).cloned().unwrap_or_else(|| {
1051 let theme = built_in().swap_remove(0);
1052 definition(name, &theme.styles[*name])
1053 });
1054 let result = view
1055 .canvas
1056 .page
1057 .format(canvas::editor::Formatting::Style(style))
1058 .map(moved);
1059 view.stored(result)
1060}
1061
1062/// The place in the Styles gallery of the style every selected paragraph shares, or -1.
1063#[unsafe(no_mangle)]
1064pub extern "C" fn sb_view_style_place(view: &View) -> i32 {
1065 let state = view.canvas.page.editor.format_state().unwrap_or_default();
1066 notebook::sidecar::themes::STYLES
1067 .iter()
1068 .position(|(name, _)| state.style.as_deref() == Some(*name))
1069 .map_or(-1, |place| place as i32)
1070}
1071
970/// The marked word at the caret, or selected, as JSON `{"word", "suggestions",1072/// The marked word at the caret, or selected, as JSON `{"word", "suggestions",
971/// "repeated"}`; null where there is none.1073/// "repeated"}`; null where there is none.
972#[unsafe(no_mangle)]1074#[unsafe(no_mangle)]
...@@ -1241,6 +1343,33 @@ pub extern "C" fn sb_view_page_text(view: &View) -> *mut c_char {...@@ -1241,6 +1343,33 @@ pub extern "C" fn sb_view_page_text(view: &View) -> *mut c_char {
1241 owned(view.canvas.page_text())1343 owned(view.canvas.page_text())
1242}1344}
12431345
1346/// The page on paper `width` × `height` points as OneNote 2010 prints it, each sheet's
1347/// footer naming `section`: `length` bytes of PDF, freed with `sb_bytes_free`, or null.
1348///
1349/// # Safety
1350/// `section` is NUL-terminated UTF-8.
1351#[unsafe(no_mangle)]
1352pub unsafe extern "C" fn sb_view_pdf(
1353 view: &View,
1354 width: f32,
1355 height: f32,
1356 section: *const c_char,
1357 length: &mut usize,
1358) -> *mut u8 {
1359 let Some(pdf) = view.canvas.pdf([width, height], &string(section)) else {
1360 return std::ptr::null_mut();
1361 };
1362 *length = pdf.len();
1363 Box::into_raw(pdf.into_boxed_slice()).cast()
1364}
1365
1366/// # Safety
1367/// `bytes` and `length` came from this library and are not used again.
1368#[unsafe(no_mangle)]
1369pub unsafe extern "C" fn sb_bytes_free(bytes: *mut u8, length: usize) {
1370 drop(unsafe { Box::from_raw(std::ptr::slice_from_raw_parts_mut(bytes, length)) });
1371}
1372
1244/// Puts the picture file `bytes` (JPEG or PNG), `width` × `height` points, at the caret:1373/// Puts the picture file `bytes` (JPEG or PNG), `width` × `height` points, at the caret:
1245/// splitting its paragraph, or on the page at a caret on blank page.1374/// splitting its paragraph, or on the page at a caret on blank page.
1246///1375///
crates/mobile/src/library.rs+127-4
...@@ -706,12 +706,62 @@ pub(crate) fn filetime() -> u64 {...@@ -706,12 +706,62 @@ pub(crate) fn filetime() -> u64 {
706}706}
707707
708/// A page OneNote 2010 would create now, titled `date` and `time` as the host formats708/// A page OneNote 2010 would create now, titled `date` and `time` as the host formats
709/// them: the long date and the short time.709/// them: the long date and the short time; titled in the Default font's face.
710fn dated(author: &str, date: &str, time: &str) -> Result<PageCreation> {710fn dated(author: &str, date: &str, time: &str) -> Result<PageCreation> {
711 Ok(PageCreation::new(None, Some(""), author)?.dated(date, time)?)711 let font = crate::options().1;
712 Ok(PageCreation::new(None, Some(""), author)?
713 .titled_in(&font.face, font.color)?
714 .dated(date, time)?)
712}715}
713716
714impl Section {717impl Section {
718 /// The notebook's themes; none for a lone section.
719 pub(crate) fn themes(&self) -> notebook::sidecar::themes::Themes {
720 let notebook = self
721 .library
722 .notebook
723 .lock()
724 .unwrap_or_else(|error| error.into_inner());
725 notebook
726 .as_ref()
727 .and_then(|notebook| crate::report(notebook.themes().map_err(Into::into)))
728 .unwrap_or_default()
729 }
730
731 /// Merges `change` into the notebook's themes.
732 pub(crate) fn save_themes(&self, change: notebook::sidecar::themes::Themes) -> Result<()> {
733 let notebook = self
734 .library
735 .notebook
736 .lock()
737 .unwrap_or_else(|error| error.into_inner());
738 notebook
739 .as_ref()
740 .ok_or("A section on its own keeps no themes")?
741 .save_themes(change)?;
742 Ok(())
743 }
744
745 /// The theme scopes of page `id`: the page, its section and the notebook, where known.
746 pub(crate) fn scopes(&self, id: &str) -> Result<[Option<notebook::sidecar::themes::Scope>; 3]> {
747 use notebook::sidecar::themes::Scope;
748 let (page, _) = self.shared.page(id.parse()?)?;
749 Ok([
750 page.identity.map(Scope::page),
751 self.shared.section.identity().ok().map(Scope::section),
752 Some(Scope::Notebook),
753 ])
754 }
755
756 /// The theme page `identity` of this section wears, if any scope names one.
757 pub(crate) fn theme(
758 &self,
759 identity: Option<[u8; 16]>,
760 ) -> Option<notebook::sidecar::themes::Theme> {
761 let section = self.shared.section.identity().ok();
762 self.themes().effective(section, identity)
763 }
764
715 /// The section at catalog `path` of `library`, its edits naming `author`.765 /// The section at catalog `path` of `library`, its edits naming `author`.
716 pub(crate) fn open(766 pub(crate) fn open(
717 library: Arc<Library>,767 library: Arc<Library>,
...@@ -862,8 +912,7 @@ impl Section {...@@ -862,8 +912,7 @@ impl Section {
862 /// nothing waits to be published.912 /// nothing waits to be published.
863 pub(crate) fn flush(&self, limit: Duration) -> bool {913 pub(crate) fn flush(&self, limit: Duration) -> bool {
864 let section = &self.shared.section;914 let section = &self.shared.section;
865 // The section thread answers in order, so an answer means earlier edits are stored.915 if report(section.written().map_err(Into::into)).is_none() {
866 if report(section.pages().map_err(Into::into)).is_none() {
867 return false;916 return false;
868 }917 }
869 let deadline = Instant::now() + limit;918 let deadline = Instant::now() + limit;
...@@ -1598,6 +1647,80 @@ pub unsafe extern "C" fn sb_section_new_page(...@@ -1598,6 +1647,80 @@ pub unsafe extern "C" fn sb_section_new_page(
1598 report(page).map_or(std::ptr::null_mut(), |space| owned(space.to_string()))1647 report(page).map_or(std::ptr::null_mut(), |space| owned(space.to_string()))
1599}1648}
16001649
1650/// The notebook's themes for page `id` as JSON: `themes`, each with `id` and `name`, then
1651/// the id each of `page`, `section` and `notebook` names itself, or null.
1652///
1653/// # Safety
1654/// `id` is NUL-terminated UTF-8.
1655#[unsafe(no_mangle)]
1656pub unsafe extern "C" fn sb_section_themes(section: &Section, id: *const c_char) -> *mut c_char {
1657 use notebook::sidecar::themes::Scope;
1658 #[derive(serde::Serialize)]
1659 struct Listed {
1660 id: String,
1661 name: String,
1662 }
1663 #[derive(serde::Serialize)]
1664 struct Shown {
1665 themes: Vec<Listed>,
1666 page: Option<String>,
1667 section: Option<String>,
1668 notebook: Option<String>,
1669 }
1670 let shown = (|| {
1671 let scopes = section.scopes(&string(id))?;
1672 let themes = section.themes();
1673 let named = |scope: &Option<Scope>| themes.assigned(scope.as_ref()?).map(|theme| theme.id);
1674 Ok(Shown {
1675 themes: themes
1676 .all()
1677 .into_iter()
1678 .map(|theme| Listed {
1679 id: theme.id,
1680 name: theme.name,
1681 })
1682 .collect(),
1683 page: named(&scopes[0]),
1684 section: named(&scopes[1]),
1685 notebook: named(&scopes[2]),
1686 })
1687 })();
1688 json(shown)
1689}
1690
1691/// Gives theme `theme` (null for none of its own) to page `id` (`scope` 0), its section (1)
1692/// or its notebook (2); the page wears it when next opened. Writes to the notebook, so call
1693/// it off the main thread.
1694///
1695/// # Safety
1696/// Every string is NUL-terminated UTF-8; `theme` may be null.
1697#[unsafe(no_mangle)]
1698pub unsafe extern "C" fn sb_section_set_theme(
1699 section: &Section,
1700 id: *const c_char,
1701 scope: u8,
1702 theme: *const c_char,
1703) -> bool {
1704 use notebook::sidecar::themes::{Assignment, Themes};
1705 let set = (|| {
1706 let scope = section
1707 .scopes(&string(id))?
1708 .into_iter()
1709 .nth(usize::from(scope))
1710 .flatten()
1711 .ok_or("No such scope")?;
1712 section.save_themes(Themes {
1713 assignments: vec![Assignment {
1714 scope,
1715 theme: optional(theme),
1716 assigned: filetime(),
1717 }],
1718 ..Default::default()
1719 })
1720 })();
1721 report(set).is_some()
1722}
1723
1601/// Deletes page `id` to the notebook's recycle bin, or a conflict page for good; a section1724/// Deletes page `id` to the notebook's recycle bin, or a conflict page for good; a section
1602/// left without pages gets one titled `date` and `time`. Writes to the notebook, so call it1725/// left without pages gets one titled `date` and `time`. Writes to the notebook, so call it
1603/// off the main thread.1726/// off the main thread.
crates/mobile/src/tests.rs+17
...@@ -1088,3 +1088,20 @@ fn closing_refuses_while_an_edit_cannot_publish_then_deletes_nothing_pending() {...@@ -1088,3 +1088,20 @@ fn closing_refuses_while_an_edit_cannot_publish_then_deletes_nothing_pending() {
1088 std::fs::remove_dir_all(&root).unwrap();1088 std::fs::remove_dir_all(&root).unwrap();
1089 assert!(eventually(|| replicas(&directory) == 0));1089 assert!(eventually(|| replicas(&directory) == 0));
1090}1090}
1091
1092/// Print and Export as PDF make a PDF of the open page on the paper asked for.
1093#[test]
1094fn the_page_prints_as_a_pdf() {
1095 let (_directory, section) = features();
1096 let title = section.shared.section.pages().unwrap()[0].1.clone();
1097 let pdf = canvas(&section, &title)
1098 .pdf(canvas::print::A4, "Features")
1099 .unwrap();
1100 assert!(pdf.starts_with(b"%PDF"));
1101 let text = String::from_utf8_lossy(&pdf);
1102 assert!(
1103 text.contains("/MediaBox [0 0 595.276 841.89]"),
1104 "{}",
1105 &text[..400]
1106 );
1107}
crates/notebook/README.md+26-7
...@@ -56,6 +56,9 @@ the files its folders list otherwise than when last opened (`discover::Cache`, k...@@ -56,6 +56,9 @@ the files its folders list otherwise than when last opened (`discover::Cache`, k
56a replica named by the file's location and the section's document identity, so the same56a replica named by the file's location and the section's document identity, so the same
57file reopens the same queue after a relaunch and a copy of it elsewhere has its own57file reopens the same queue after a relaunch and a copy of it elsewhere has its own
58(`notebook::location`: `folder`, `local`, `smb`, and `moved` for a notebook the app moves).58(`notebook::location`: `folder`, `local`, `smb`, and `moved` for a notebook the app moves).
59`Notebook::section(path, notify)` opens a mounted notebook's section; a replica already
60named by the identity its discovery read resumes without reading the file, which the
61worker checks next, as on a share.
59A section is `Send + Sync`: `apply(author, edit)` returns at once and reports a refusal as `Event::Rejected`; `events()` drains62A section is `Send + Sync`: `apply(author, edit)` returns at once and reports a refusal as `Event::Rejected`; `events()` drains
60remote changes (`Changed(spaces)`), publication outcomes, unreachable files and63remote changes (`Changed(spaces)`), publication outcomes, unreachable files and
61failures; `notify` runs on a background thread whenever an event waits.64failures; `notify` runs on a background thread whenever an event waits.
...@@ -294,7 +297,8 @@ marked `copy`, have replicas of their own, and are never placed or listed anew b...@@ -294,7 +297,8 @@ marked `copy`, have replicas of their own, and are never placed or listed anew b
294operations, which touch only a TOC entry OneNote gave the copy's name. Of several groups297operations, which touch only a TOC entry OneNote gave the copy's name. Of several groups
295with one TOC identity, the one its parent's TOC names, else the newest, lists; the others298with one TOC identity, the one its parent's TOC names, else the newest, lists; the others
296list in `unavailable` as `Reason::Copy`. iOS's299list in `unavailable` as `Reason::Copy`. iOS's
297`.Name.one.icloud` placeholders list there under their real names as `Reason::Evicted`.300`.Name.one.icloud` placeholders, and macOS's dataless files, list there under their real names
301as `Reason::Evicted`, unless listed unchanged since discovery last read them.
298Retain the last accepted catalog if discovery fails; a connection failure does not mean302Retain the last accepted catalog if discovery fails; a connection failure does not mean
299files were deleted.303files were deleted.
300304
...@@ -307,16 +311,18 @@ directly from the core document model....@@ -307,16 +311,18 @@ directly from the core document model.
307## Notebook structure311## Notebook structure
308312
309`Notebook::create`, `create_section`, `create_group`, `rename`, `move_entry`,313`Notebook::create`, `create_section`, `create_group`, `rename`, `move_entry`,
310`set_section_color`, `reorder` and `delete` change a notebook the way OneNote does: the314`set_section_color`, `set_color`, `reorder` and `delete` change a notebook the way OneNote does: the
311table of contents (`Open Notebook.onetoc2`, created when a folder has none) gains,315table of contents (`Open Notebook.onetoc2`, created when a folder has none) gains,
312renames, reorders or loses entries; a new notebook holds "New Section 1", and a new316renames, reorders or loses entries; a new notebook holds "New Section 1", and a new
313section an empty section's file plus the page its `PageCreation` makes, in OneNote's317section an empty section's file plus the page its `PageCreation` makes, in OneNote's
314new-section colour order; a section's colour lives in its own metadata; a deleted318new-section colour order; a section's colour lives in its own metadata, the notebook's
319in its root table of contents (`corpus/section-color`); a deleted
315section moves into `OneNote_RecycleBin`, a group with its own TOC, and a deleted group's320section moves into `OneNote_RecycleBin`, a group with its own TOC, and a deleted group's
316sections move there too before its folders go. `recycle_pages` keeps copies of pages a321sections move there too before its folders go. `recycle_pages` keeps copies of pages a
317section is about to delete in `OneNote_RecycleBin/OneNote_DeletedPages.one`, with their322section is about to delete in `OneNote_RecycleBin/OneNote_DeletedPages.one`, with their
318identities, titles, dates and creation times, as OneNote does323identities, titles, dates and creation times, as OneNote does
319(`corpus/notebook-management`). A bin or deleted-pages file its TOC does not list is324(`corpus/notebook-management`); `unrecycle_pages` takes them out again by identity, as
325OneNote's Undo of a page delete does. A bin or deleted-pages file its TOC does not list is
320placed and listed again, a bin whose TOC OneNote named otherwise keeps it, and an326placed and listed again, a bin whose TOC OneNote named otherwise keeps it, and an
321unavailable bin refuses the delete (`corpus/recycle-bin-repair`). An entry a TOC still327unavailable bin refuses the delete (`corpus/recycle-bin-repair`). An entry a TOC still
322lists for a file gone from its folder gives way to the file an edit gives its name.328lists for a file gone from its folder gives way to the file an edit gives its name.
...@@ -368,14 +374,16 @@ notebook....@@ -368,14 +374,16 @@ notebook.
368`sidecar` keeps Snowbound-only data in the notebook's `.snowbound` folder, which374`sidecar` keeps Snowbound-only data in the notebook's `.snowbound` folder, which
369OneNote 2010 skips because it has the Windows hidden attribute: `Storage::hide` sets it375OneNote 2010 skips because it has the Windows hidden attribute: `Storage::hide` sets it
370through `smb::Client::hide` (SET_INFO FileBasicInformation, always, since Samba reports a376through `smb::Client::hide` (SET_INFO FileBasicInformation, always, since Samba reports a
371dot name hidden without storing it) or macOS's `UF_HIDDEN`, which an smbfs mount passes377dot name hidden without storing it), `SetFileAttributesW` on Windows, or macOS's
372to the share; Linux keeps no such attribute. Discovery skips dot names, and a notebook378`UF_HIDDEN`, which an smbfs mount passes to the share; Linux keeps no such attribute. Discovery skips dot names, and a notebook
373without the folder is whole.379without the folder is whole.
374380
375```text381```text
376.snowbound/382.snowbound/
377├ tags.json [{ name, shape, art, mapped }]: a tag's name and symbol → its art383├ tags.json [{ name, shape, art, mapped }]: a tag's name and symbol → its art
378└ tags/ <SHA-256>.png | .svg, written through a temporary name, never rewritten384├ tags/ <SHA-256>.png | .svg, written through a temporary name, never rewritten
385└ themes.json { themes: [{ id, name, styles, modified, deleted }],
386 assignments: [{ scope, theme, assigned }] }: style themes and who wears them
379```387```
380388
381`Notebook::map_tag_art(name, shape, bytes, extension)` makes the folder and hides it,389`Notebook::map_tag_art(name, shape, bytes, extension)` makes the folder and hides it,
...@@ -387,6 +395,17 @@ one side's new tag until that side maps it again. `tag_art` reads the mappings,...@@ -387,6 +395,17 @@ one side's new tag until that side maps it again. `tag_art` reads the mappings,
387over entries that name no picture, and `tag_art_file` returns a picture once its bytes395over entries that name no picture, and `tag_art_file` returns a picture once its bytes
388match its name.396match its name.
389397
398`sidecar::themes` keeps style themes: each gives OneNote 2010's eleven gallery styles
399(`STYLES`: `h1`…`h6`, `PageTitle`, `cite`, `blockquote`, `code`, `p`) a font, size,
400weight, slant, colour and spacing, and `Theme::sheet` turns them into the paragraph style
401definitions a page stores. `built_in()` lists the shipped themes, which never change once
402shipped. A scope is the notebook, a section by file identity or a page by its
403notebook-management identity; `Themes::effective(section, page)` takes the page's, else the
404section's, else the notebook's. `Notebook::themes` reads the file and `save_themes(change)`
405merges a change in as `map_tag_art` does: themes by id and assignments by scope, the later
406timestamp winning, then the greater entry. `onestore::op::restyle(page, sheet)` gives the
407`Restyle` ops that put a page in a sheet.
408
390## SMB (feature `smb`)409## SMB (feature `smb`)
391410
392`notebook::smb` provides blocking SMB access for OneNote sections and411`notebook::smb` provides blocking SMB access for OneNote sections and
crates/notebook/src/discover.rs+24-14
...@@ -93,6 +93,8 @@ pub struct Toc {...@@ -93,6 +93,8 @@ pub struct Toc {
93 pub file_id: [u8; 16],93 pub file_id: [u8; 16],
94 /// Stale or unavailable TOC references; these are not inferred active sections.94 /// Stale or unavailable TOC references; these are not inferred active sections.
95 pub unresolved: Vec<TocReference>,95 pub unresolved: Vec<TocReference>,
96 /// The notebook's colour as a COLORREF, which only a notebook's own TOC holds.
97 pub color: Option<u32>,
96}98}
9799
98#[derive(Debug, Clone, Serialize, Deserialize)]100#[derive(Debug, Clone, Serialize, Deserialize)]
...@@ -108,8 +110,8 @@ pub enum EntryKind {...@@ -108,8 +110,8 @@ pub enum EntryKind {
108 File,110 File,
109 Directory,111 Directory,
110 Other,112 Other,
111 /// A notebook file kept elsewhere and not yet on this device, as iOS lists one that113 /// A notebook file kept elsewhere and not yet on this device, as iCloud Drive's evicted
112 /// iCloud Drive evicted.114 /// files list: a `.Name.icloud` placeholder, or on macOS 14 and later a dataless file.
113 Evicted,115 Evicted,
114}116}
115117
...@@ -164,7 +166,10 @@ enum Held {...@@ -164,7 +166,10 @@ enum Held {
164 document: [u8; 16],166 document: [u8; 16],
165 },167 },
166 Locked,168 Locked,
167 Toc(Vec<TocReference>),169 Toc {
170 unresolved: Vec<TocReference>,
171 color: Option<u32>,
172 },
168}173}
169174
170impl Cache {175impl Cache {
...@@ -380,9 +385,14 @@ fn scan(...@@ -380,9 +385,14 @@ fn scan(
380 } else {385 } else {
381 continue;386 continue;
382 };387 };
383 // Without an evicted TOC the folder lists in path order; nothing writes a second one,388 let reused = cached
384 // as its placeholder blocks creating it.389 .get(&child)
385 if entry.kind == EntryKind::Evicted {390 .filter(|known| known.listed == entry.listed)
391 .and_then(|known| Some((known.file_id()?, known)));
392 // An evicted file listed as it was last read lists as then. Without an evicted TOC the
393 // folder lists in path order; nothing writes a second one, as its placeholder, or the
394 // dataless file itself, blocks creating it.
395 if entry.kind == EntryKind::Evicted && reused.is_none() {
386 if expected == FileType::Section {396 if expected == FileType::Section {
387 result.unavailable.push(Unavailable {397 result.unavailable.push(Unavailable {
388 path: child,398 path: child,
...@@ -393,15 +403,11 @@ fn scan(...@@ -393,15 +403,11 @@ fn scan(
393 }403 }
394 continue;404 continue;
395 }405 }
396 if entry.kind != EntryKind::File406 if !matches!(entry.kind, EntryKind::File | EntryKind::Evicted)
397 || (expected == FileType::TableOfContents && result.toc.is_some())407 || (expected == FileType::TableOfContents && result.toc.is_some())
398 {408 {
399 return Err(Error::Entry { path: child });409 return Err(Error::Entry { path: child });
400 }410 }
401 let reused = cached
402 .get(&child)
403 .filter(|known| known.listed == entry.listed)
404 .and_then(|known| Some((known.file_id()?, known)));
405 let (file_id, held) = match reused {411 let (file_id, held) = match reused {
406 Some((file_id, known)) => {412 Some((file_id, known)) => {
407 found.read.insert(child.clone(), known.clone());413 found.read.insert(child.clone(), known.clone());
...@@ -522,11 +528,12 @@ fn scan(...@@ -522,11 +528,12 @@ fn scan(
522 state: SectionState::Locked,528 state: SectionState::Locked,
523 copy: false,529 copy: false,
524 }),530 }),
525 Ok(Held::Toc(unresolved)) => {531 Ok(Held::Toc { unresolved, color }) => {
526 result.toc = Some(Toc {532 result.toc = Some(Toc {
527 filename: entry.name.clone(),533 filename: entry.name.clone(),
528 file_id,534 file_id,
529 unresolved,535 unresolved,
536 color,
530 })537 })
531 }538 }
532 Err(error) if expected == FileType::Section => result.sections.push(Section {539 Err(error) if expected == FileType::Section => result.sections.push(Section {
...@@ -751,7 +758,7 @@ fn held(store: &Store, expected: FileType) -> Result<Held, onestore::Error> {...@@ -751,7 +758,7 @@ fn held(store: &Store, expected: FileType) -> Result<Held, onestore::Error> {
751 });758 });
752 }759 }
753 index.validate_current()?;760 index.validate_current()?;
754 let Some(Kind::Toc { entries, .. }) = root(1).map(|node| &node.kind) else {761 let Some(Kind::Toc { entries, color, .. }) = root(1).map(|node| &node.kind) else {
755 return Err(onestore::Error {762 return Err(onestore::Error {
756 offset: 0,763 offset: 0,
757 message: "Missing notebook TOC root",764 message: "Missing notebook TOC root",
...@@ -784,7 +791,10 @@ fn held(store: &Store, expected: FileType) -> Result<Held, onestore::Error> {...@@ -784,7 +791,10 @@ fn held(store: &Store, expected: FileType) -> Result<Held, onestore::Error> {
784 order: *order,791 order: *order,
785 });792 });
786 }793 }
787 Ok(Held::Toc(unresolved))794 Ok(Held::Toc {
795 unresolved,
796 color: *color,
797 })
788}798}
789799
790/// Why a file or group that failed lists as unavailable; `None` fails the whole discovery,800/// Why a file or group that failed lists as unavailable; `None` fails the whole discovery,
crates/notebook/src/discover/source.rs+18
...@@ -12,6 +12,22 @@ fn evicted(name: &str) -> Option<&str> {...@@ -12,6 +12,22 @@ fn evicted(name: &str) -> Option<&str> {
12 (lower.ends_with(".one") || lower.ends_with(".onetoc2")).then_some(real)12 (lower.ends_with(".one") || lower.ends_with(".onetoc2")).then_some(real)
13}13}
1414
15/// Whether the system keeps the file's contents elsewhere until it is read, as macOS 14 and
16/// later list a file iCloud Drive evicted (`SF_DATALESS`).
17fn dataless(metadata: &std::fs::Metadata) -> bool {
18 const SF_DATALESS: u32 = 0x4000_0000;
19 #[cfg(target_os = "macos")]
20 let flags = std::os::macos::fs::MetadataExt::st_flags(metadata);
21 #[cfg(target_os = "ios")]
22 let flags = std::os::ios::fs::MetadataExt::st_flags(metadata);
23 #[cfg(not(any(target_os = "macos", target_os = "ios")))]
24 let flags = {
25 let _ = metadata;
26 0
27 };
28 flags & SF_DATALESS != 0
29}
30
15/// Where iOS lists the file at `path` while iCloud Drive keeps it elsewhere.31/// Where iOS lists the file at `path` while iCloud Drive keeps it elsewhere.
16pub(crate) fn placeholder(path: &Path) -> Option<PathBuf> {32pub(crate) fn placeholder(path: &Path) -> Option<PathBuf> {
17 let name = path.file_name()?.to_str()?;33 let name = path.file_name()?.to_str()?;
...@@ -62,6 +78,8 @@ impl Source for Local {...@@ -62,6 +78,8 @@ impl Source for Local {
62 name,78 name,
63 kind: if kind.is_dir() {79 kind: if kind.is_dir() {
64 EntryKind::Directory80 EntryKind::Directory
81 } else if kind.is_file() && dataless(&metadata) {
82 EntryKind::Evicted
65 } else if kind.is_file() {83 } else if kind.is_file() {
66 EntryKind::File84 EntryKind::File
67 } else {85 } else {
crates/notebook/src/lib.rs+7-1
...@@ -313,10 +313,16 @@ impl Replica {...@@ -313,10 +313,16 @@ impl Replica {
313 /// The section image the queued edits leave, the unsealed ones sealed as one more313 /// The section image the queued edits leave, the unsealed ones sealed as one more
314 /// revision whose identities differ per call: O(section).314 /// revision whose identities differ per call: O(section).
315 pub(crate) fn snapshot(&self) -> Result<Vec<u8>> {315 pub(crate) fn snapshot(&self) -> Result<Vec<u8>> {
316 self.ask(|reply| working::Request::Flush { reply })?;316 self.written()?;
317 working::image(&*self.lock()?)317 working::image(&*self.lock()?)
318 }318 }
319319
320 /// Waits until the edits applied before it are written to the queue, as reads answer
321 /// before an open burst of edits is.
322 pub fn written(&self) -> Result<()> {
323 self.ask(|reply| working::Request::Flush { reply })
324 }
325
320 /// The section file's identity, which internal links name as `section-id`.326 /// The section file's identity, which internal links name as `section-id`.
321 pub fn identity(&self) -> Result<[u8; 16]> {327 pub fn identity(&self) -> Result<[u8; 16]> {
322 let stamp = base::stamp(&*self.lock()?, base::Image::Base)?328 let stamp = base::stamp(&*self.lock()?, base::Image::Base)?
crates/notebook/src/merge.rs+23-2
...@@ -153,7 +153,7 @@ fn named(op: &PageOp) -> Vec<ExGuid> {...@@ -153,7 +153,7 @@ fn named(op: &PageOp) -> Vec<ExGuid> {
153 | PageOp::Equation { text, .. }153 | PageOp::Equation { text, .. }
154 | PageOp::Split { text, .. } => vec![*text],154 | PageOp::Split { text, .. } => vec![*text],
155 PageOp::Date { fields, .. } => fields.iter().map(|(text, _)| *text).collect(),155 PageOp::Date { fields, .. } => fields.iter().map(|(text, _)| *text).collect(),
156 PageOp::Color(_) | PageOp::RuleLines(_) => Vec::new(),156 PageOp::Color(_) | PageOp::RuleLines(_) | PageOp::Restyle { .. } => Vec::new(),
157 PageOp::Join { left, right } => vec![*left, *right],157 PageOp::Join { left, right } => vec![*left, *right],
158 PageOp::Insert { container, .. } => vec![*container],158 PageOp::Insert { container, .. } => vec![*container],
159 PageOp::Move { object, .. }159 PageOp::Move { object, .. }
...@@ -164,6 +164,7 @@ fn named(op: &PageOp) -> Vec<ExGuid> {...@@ -164,6 +164,7 @@ fn named(op: &PageOp) -> Vec<ExGuid> {
164 PageOp::Level { paragraph, .. }164 PageOp::Level { paragraph, .. }
165 | PageOp::Paragraph { paragraph, .. }165 | PageOp::Paragraph { paragraph, .. }
166 | PageOp::Style { paragraph, .. }166 | PageOp::Style { paragraph, .. }
167 | PageOp::Unstyle { paragraph }
167 | PageOp::Media { paragraph, .. }168 | PageOp::Media { paragraph, .. }
168 | PageOp::List { paragraph, .. } => vec![*paragraph],169 | PageOp::List { paragraph, .. } => vec![*paragraph],
169 PageOp::Tags { target, .. } => vec![*target],170 PageOp::Tags { target, .. } => vec![*target],
...@@ -770,11 +771,19 @@ impl Diff {...@@ -770,11 +771,19 @@ impl Diff {
770 }771 }
771 PageOp::Paragraph { paragraph, .. }772 PageOp::Paragraph { paragraph, .. }
772 | PageOp::Style { paragraph, .. }773 | PageOp::Style { paragraph, .. }
774 | PageOp::Unstyle { paragraph }
773 | PageOp::Media { paragraph, .. }775 | PageOp::Media { paragraph, .. }
774 | PageOp::List { paragraph, .. } => {776 | PageOp::List { paragraph, .. } => {
775 require(self.kept(*paragraph) && self.new_has(*paragraph))?777 require(self.kept(*paragraph) && self.new_has(*paragraph))?
776 }778 }
777 PageOp::Tags { target, .. } => require(self.same_tags(*target))?,779 PageOp::Tags { target, .. } => require(self.same_tags(*target))?,
780 // A theme reaches the style's paragraphs as the remote left them; a style the
781 // remote no longer uses is restyled when the page next opens.
782 PageOp::Restyle { style, .. } => {
783 if self.old.styles.contains(style) && !self.new.styles.contains(style) {
784 return Ok(None);
785 }
786 }
778 PageOp::Picture {787 PageOp::Picture {
779 picture: object, ..788 picture: object, ..
780 }789 }
...@@ -1079,6 +1088,8 @@ impl Node {...@@ -1079,6 +1088,8 @@ impl Node {
1079struct Index {1088struct Index {
1080 nodes: BTreeMap<ExGuid, Node>,1089 nodes: BTreeMap<ExGuid, Node>,
1081 page: Vec<ExGuid>,1090 page: Vec<ExGuid>,
1091 /// The paragraph styles the page's paragraphs use.
1092 styles: BTreeSet<ExGuid>,
1082}1093}
10831094
1084const PAGE: ExGuid = ExGuid {1095const PAGE: ExGuid = ExGuid {
...@@ -1088,7 +1099,17 @@ const PAGE: ExGuid = ExGuid {...@@ -1088,7 +1099,17 @@ const PAGE: ExGuid = ExGuid {
10881099
1089impl Index {1100impl Index {
1090 fn of(page: &Page) -> Self {1101 fn of(page: &Page) -> Self {
1091 let mut index = Self::default();1102 let mut index = Self {
1103 styles: page
1104 .definitions
1105 .iter()
1106 .filter(|(_, definition)| {
1107 matches!(definition.kind, onestore::document::Kind::Style { .. })
1108 })
1109 .map(|(id, _)| *id)
1110 .collect(),
1111 ..Self::default()
1112 };
1092 for object in &page.objects {1113 for object in &page.objects {
1093 index.page.push(object.id());1114 index.page.push(object.id());
1094 match object {1115 match object {
crates/notebook/src/queue.rs+3
...@@ -282,6 +282,9 @@ mod tests {...@@ -282,6 +282,9 @@ mod tests {
282 alt: None,282 alt: None,
283 background: false,283 background: false,
284 printout: None,284 printout: None,
285 tags: Vec::new(),
286 link: None,
287 text: None,
285 })288 })
286 };289 };
287 let space = ExGuid {290 let space = ExGuid {
crates/notebook/src/session.rs+134
...@@ -332,6 +332,8 @@ const LIMITS: discover::Limits = discover::Limits {...@@ -332,6 +332,8 @@ const LIMITS: discover::Limits = discover::Limits {
332332
333const TOC: &str = "Open Notebook.onetoc2";333const TOC: &str = "Open Notebook.onetoc2";
334const RECYCLE_BIN: &str = "OneNote_RecycleBin";334const RECYCLE_BIN: &str = "OneNote_RecycleBin";
335/// How long OneNote 2010 keeps what its recycle bin holds, its `DaysToKeepRecycledItems`.
336pub const RECYCLE_DAYS: u32 = 60;
335337
336/// A notebook's files and the cache directory holding its section replicas.338/// A notebook's files and the cache directory holding its section replicas.
337pub struct Notebook {339pub struct Notebook {
...@@ -488,6 +490,20 @@ impl Notebook {...@@ -488,6 +490,20 @@ impl Notebook {
488 crate::sidecar::map(&*self.storage, name, shape, bytes, extension)490 crate::sidecar::map(&*self.storage, name, shape, bytes, extension)
489 }491 }
490492
493 /// The notebook's style themes and which theme each scope takes (`sidecar::themes`).
494 pub fn themes(&self) -> Result<crate::sidecar::themes::Themes> {
495 crate::sidecar::themes::read(&*self.storage)
496 }
497
498 /// Merges `change` into the notebook's themes, making its hidden `.snowbound` folder
499 /// where it has none; returns the themes as they then stand.
500 pub fn save_themes(
501 &self,
502 change: crate::sidecar::themes::Themes,
503 ) -> Result<crate::sidecar::themes::Themes> {
504 crate::sidecar::themes::write(&*self.storage, change)
505 }
506
491 /// Rereads the notebook and reports what changed since the last catalog, keyed by507 /// Rereads the notebook and reports what changed since the last catalog, keyed by
492 /// file identity so a renamed or moved section stays the same section. A failed read508 /// file identity so a renamed or moved section stays the same section. A failed read
493 /// keeps the previous catalog: an unreachable notebook is not an empty one.509 /// keeps the previous catalog: an unreachable notebook is not an empty one.
...@@ -742,6 +758,13 @@ impl Notebook {...@@ -742,6 +758,13 @@ impl Notebook {
742 Ok(())758 Ok(())
743 }759 }
744760
761 /// Sets the notebook's colour (COLORREF) in its root table of contents, as OneNote 2010's
762 /// Notebook Properties does (`corpus/section-color`).
763 pub fn set_color(&mut self, color: u32) -> Result<()> {
764 self.edit_toc("", &[onestore::TocEdit::Color(color)])?;
765 self.refresh().map(drop)
766 }
767
745 /// Orders a folder's sections and groups; entries left out follow in their current order.768 /// Orders a folder's sections and groups; entries left out follow in their current order.
746 pub fn reorder(&mut self, folder: &str, paths: &[&str]) -> Result<()> {769 pub fn reorder(&mut self, folder: &str, paths: &[&str]) -> Result<()> {
747 let mut identities = Vec::new();770 let mut identities = Vec::new();
...@@ -932,6 +955,102 @@ impl Notebook {...@@ -932,6 +955,102 @@ impl Notebook {
932 self.refresh().map(drop)955 self.refresh().map(drop)
933 }956 }
934957
958 /// Takes the pages `identities` names out of the recycle bin's Deleted Pages in one
959 /// revision, as OneNote 2010 does when Undo brings deleted pages back. Pages not there
960 /// are passed over.
961 pub fn unrecycle_pages(&mut self, identities: &[[u8; 16]]) -> Result<()> {
962 let path = catalog_path(RECYCLE_BIN, "OneNote_DeletedPages.one");
963 if !self.storage.exists(&path) {
964 return Ok(());
965 }
966 let bytes = self.storage.read(&path)?;
967 let binned: Vec<ExGuid> = stored_pages(&bytes)?
968 .into_iter()
969 .filter(|stored| {
970 stored
971 .page
972 .identity
973 .is_some_and(|id| identities.contains(&id))
974 })
975 .map(|stored| stored.space)
976 .collect();
977 if binned.is_empty() {
978 return Ok(());
979 }
980 let arena = onestore::Arena::default();
981 let mut section = onestore::Section::open(&arena, bytes)?;
982 section.apply(
983 "",
984 &Edit {
985 at: crate::now(),
986 ops: vec![Op::Section(SectionOp::Delete(binned))],
987 },
988 )?;
989 if let Some(transaction) = section.seal()? {
990 self.storage.commit(&path, &transaction)?;
991 }
992 self.refresh().map(drop)
993 }
994
995 /// Deletes for good what OneNote 2010 prunes from the recycle bin at `now`, Time32 seconds
996 /// since 1980: each page of Deleted Pages last changed over `RECYCLE_DAYS` before, in one
997 /// revision, and each binned section none of whose pages changed since
998 /// (`corpus/recycle-purge`). OneNote judges by the pages' own last change, not when they
999 /// were deleted, and leaves a pruned section's entry in the bin's TOC, as this does.
1000 /// Returns how many pages and sections went.
1001 pub fn purge_recycle_bin(&mut self, now: u32) -> Result<usize> {
1002 const DELETED: &str = "OneNote_DeletedPages.one";
1003 let expired = |modified: Option<u32>| {
1004 modified.is_some_and(|modified| now.saturating_sub(modified) > RECYCLE_DAYS * 86_400)
1005 };
1006 let Ok(bin) = self.folder(RECYCLE_BIN) else {
1007 return Ok(0);
1008 };
1009 let sections: Vec<String> = bin
1010 .sections
1011 .iter()
1012 .map(|section| section.path.clone())
1013 .collect();
1014 let mut purged = 0;
1015 for path in sections {
1016 let bytes = self.storage.read(&path)?;
1017 // A protected section, or one the model cannot read, stays.
1018 let Ok(pages) = stored_pages(&bytes) else {
1019 continue;
1020 };
1021 if split(&path).1.eq_ignore_ascii_case(DELETED) {
1022 let old: Vec<ExGuid> = pages
1023 .iter()
1024 .filter(|page| expired(page.modified))
1025 .map(|page| page.space)
1026 .collect();
1027 if old.is_empty() {
1028 continue;
1029 }
1030 let arena = onestore::Arena::default();
1031 let mut section = onestore::Section::open(&arena, bytes)?;
1032 section.apply(
1033 "",
1034 &Edit {
1035 at: crate::now(),
1036 ops: vec![Op::Section(SectionOp::Delete(old.clone()))],
1037 },
1038 )?;
1039 if let Some(transaction) = section.seal()? {
1040 self.storage.commit(&path, &transaction)?;
1041 }
1042 purged += old.len();
1043 } else if !pages.is_empty() && pages.iter().all(|page| expired(page.modified)) {
1044 self.storage.delete(&path)?;
1045 purged += 1;
1046 }
1047 }
1048 if purged > 0 {
1049 self.refresh()?;
1050 }
1051 Ok(purged)
1052 }
1053
935 /// Moves a section or section group into the folder at catalog path `folder`, last, as1054 /// Moves a section or section group into the folder at catalog path `folder`, last, as
936 /// OneNote moves one dragged onto a group: the file or folder moves and each TOC's1055 /// OneNote moves one dragged onto a group: the file or folder moves and each TOC's
937 /// entry follows it. Returns its new catalog path.1056 /// entry follows it. Returns its new catalog path.
...@@ -1222,6 +1341,16 @@ impl Notebook {...@@ -1222,6 +1341,16 @@ impl Notebook {
1222 if !file.starts_with(root) {1341 if !file.starts_with(root) {
1223 return Err(io::Error::from(io::ErrorKind::PermissionDenied).into());1342 return Err(io::Error::from(io::ErrorKind::PermissionDenied).into());
1224 }1343 }
1344 // A replica the catalog's identity names resumes without reading the file, which its
1345 // worker checks next, as a share's does.
1346 if let discover::SectionState::Readable { document, .. } = &section.state {
1347 let cache = replica_file(&replicas, document);
1348 if cache.exists() {
1349 let (replica, remote, mut connect) =
1350 (Replica::open(&cache)?, file.clone(), connect);
1351 return Section::start(file, replica, move || connect(&remote), notify);
1352 }
1353 }
1225 let replica = |identity: &[u8; 16], _: &[u8]| {1354 let replica = |identity: &[u8; 16], _: &[u8]| {
1226 std::fs::create_dir_all(&replicas)?;1355 std::fs::create_dir_all(&replicas)?;
1227 Ok(replica_file(&replicas, identity))1356 Ok(replica_file(&replicas, identity))
...@@ -1722,6 +1851,11 @@ impl Section {...@@ -1722,6 +1851,11 @@ impl Section {
1722 self.replica.pending()1851 self.replica.pending()
1723 }1852 }
17241853
1854 /// See [`Replica::written`].
1855 pub fn written(&self) -> Result<()> {
1856 self.replica.written()
1857 }
1858
1725 /// The conflict pages of each page that has them, as the local edits leave them1859 /// The conflict pages of each page that has them, as the local edits leave them
1726 /// (`onestore::Section::conflicts`); `page` reads one, `delete_pages` removes it.1860 /// (`onestore::Section::conflicts`); `page` reads one, `delete_pages` removes it.
1727 pub fn conflicts(&self) -> Result<Vec<(ExGuid, Vec<onestore::ConflictPage>)>> {1861 pub fn conflicts(&self) -> Result<Vec<(ExGuid, Vec<onestore::ConflictPage>)>> {
crates/notebook/src/sidecar.rs+2
...@@ -162,5 +162,7 @@ fn temporary(path: &str) -> String {...@@ -162,5 +162,7 @@ fn temporary(path: &str) -> String {
162 format!("{path}.{unique:016x}.tmp")162 format!("{path}.{unique:016x}.tmp")
163}163}
164164
165pub mod themes;
166
165#[cfg(test)]167#[cfg(test)]
166mod tests;168mod tests;
crates/notebook/src/sidecar/tests.rs+43-2
...@@ -265,8 +265,8 @@ fn unreadable_mappings_and_pictures_are_passed_over() {...@@ -265,8 +265,8 @@ fn unreadable_mappings_and_pictures_are_passed_over() {
265 assert!(map(&folder, "Tag", 13, b"x", "gif").is_err());265 assert!(map(&folder, "Tag", 13, b"x", "gif").is_err());
266}266}
267267
268/// On this computer the folder takes the attribute macOS keeps, and the notebook's catalog268/// On this computer the folder takes the hidden attribute Windows or macOS keeps, and the
269/// never lists it.269/// notebook's catalog never lists it.
270#[test]270#[test]
271fn a_notebook_folder_keeps_the_art_hidden() {271fn a_notebook_folder_keeps_the_art_hidden() {
272 let temporary = tempfile::tempdir().unwrap();272 let temporary = tempfile::tempdir().unwrap();
...@@ -288,7 +288,48 @@ fn a_notebook_folder_keeps_the_art_hidden() {...@@ -288,7 +288,48 @@ fn a_notebook_folder_keeps_the_art_hidden() {
288 let flags = FileFlag::from_bits_retain(stat(&folder).unwrap().st_flags);288 let flags = FileFlag::from_bits_retain(stat(&folder).unwrap().st_flags);
289 assert!(flags.contains(FileFlag::UF_HIDDEN));289 assert!(flags.contains(FileFlag::UF_HIDDEN));
290 }290 }
291 #[cfg(windows)]
292 {
293 use std::os::windows::fs::MetadataExt;
294 // FILE_ATTRIBUTE_HIDDEN.
295 assert_ne!(folder.metadata().unwrap().file_attributes() & 2, 0);
296 }
291 notebook.refresh().unwrap();297 notebook.refresh().unwrap();
292 assert!(notebook.catalog().groups.is_empty());298 assert!(notebook.catalog().groups.is_empty());
293 assert_eq!(notebook.catalog().sections.len(), 1);299 assert_eq!(notebook.catalog().sections.len(), 1);
294}300}
301
302/// Themes go in the hidden folder beside the tag art, and a writer another overtook merges
303/// again until its change holds; entries it can't read are passed over, built-ins refused.
304#[test]
305fn themes_merge_into_the_hidden_folder() {
306 use themes::{Assignment, Scope, Themes};
307 let folder = Folder::default();
308 let assign = |scope, theme: &str, assigned| Themes {
309 assignments: vec![Assignment {
310 scope,
311 theme: Some(theme.into()),
312 assigned,
313 }],
314 ..Default::default()
315 };
316 let theirs = assign(Scope::Notebook, "modern", 1);
317 *folder.race.lock().unwrap() = Some(serde_json::to_vec(&theirs).unwrap());
318 let kept = themes::write(&folder, assign(Scope::section([7; 16]), "editorial", 2)).unwrap();
319 assert!(folder.hidden.lock().unwrap().contains(".snowbound"));
320 assert_eq!(kept.assignments.len(), 2);
321 assert_eq!(kept, themes::read(&folder).unwrap());
322 assert_eq!(kept.effective(Some([7; 16]), None).unwrap().id, "editorial");
323 folder.files.lock().unwrap().insert(
324 ".snowbound/themes.json".into(),
325 br#"{"themes": [{"id": 3}], "assignments": [{"scope": "notebook", "theme": "manuscript", "assigned": 4}, "junk"]}"#.to_vec(),
326 );
327 let read = themes::read(&folder).unwrap();
328 assert!(read.themes.is_empty());
329 assert_eq!(read.effective(None, None).unwrap().id, "manuscript");
330 let built_in = Themes {
331 themes: vec![themes::built_in().remove(0)],
332 ..Default::default()
333 };
334 assert!(themes::write(&folder, built_in).is_err());
335}
crates/notebook/src/sidecar/themes.rs created+560
...@@ -0,0 +1,560 @@
1//! Style themes: `themes.json` in `.snowbound` keeps a notebook's own themes and which
2//! theme its notebook, sections and pages take. A theme gives OneNote 2010's eleven gallery
3//! styles their formatting; a page wears it as its paragraph style objects, so OneNote draws
4//! the look and keeps the names (`resources/styles.md`). Writers merge as `tags.json`'s do:
5//! themes by id and assignments by scope, the later timestamp winning, then the greater
6//! entry, so writers agree whatever order they read in.
7
8use crate::{Result, session::Storage};
9use onestore::{
10 document::{Format, Kind},
11 page::Definition,
12};
13use serde::{Deserialize, Serialize};
14use std::{collections::BTreeMap, io};
15
16const FILE: &str = ".snowbound/themes.json";
17
18/// OneNote 2010's gallery, in its order: each style's stored name and the name it shows.
19pub const STYLES: [(&str, &str); 11] = [
20 ("h1", "Heading 1"),
21 ("h2", "Heading 2"),
22 ("h3", "Heading 3"),
23 ("h4", "Heading 4"),
24 ("h5", "Heading 5"),
25 ("h6", "Heading 6"),
26 ("PageTitle", "Page Title"),
27 ("cite", "Citation"),
28 ("blockquote", "Quote"),
29 ("code", "Code"),
30 ("p", "Normal"),
31];
32
33/// A style's look in a theme.
34#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
35pub struct ThemeStyle {
36 pub font: String,
37 /// Points, in half points.
38 pub size: f32,
39 pub bold: bool,
40 pub italic: bool,
41 /// `#rrggbb`, or none for automatic.
42 pub color: Option<String>,
43 /// Space above and below the paragraph, in points.
44 pub before: f32,
45 pub after: f32,
46}
47
48#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
49pub struct Theme {
50 pub id: String,
51 pub name: String,
52 /// By stored style name; a theme names all eleven.
53 pub styles: BTreeMap<String, ThemeStyle>,
54 /// When it last changed, as a FILETIME; built-in themes have none.
55 #[serde(default)]
56 pub modified: u64,
57 #[serde(default)]
58 pub deleted: bool,
59}
60
61/// What a theme is assigned to.
62#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
63#[serde(rename_all = "lowercase")]
64pub enum Scope {
65 Notebook,
66 /// A section, by its file identity, which survives renames and moves.
67 Section(String),
68 /// A page, by its notebook-management identity.
69 Page(String),
70}
71
72impl Scope {
73 pub fn section(identity: [u8; 16]) -> Self {
74 Self::Section(hex(identity))
75 }
76
77 pub fn page(identity: [u8; 16]) -> Self {
78 Self::Page(hex(identity))
79 }
80}
81
82fn hex(identity: [u8; 16]) -> String {
83 identity.iter().map(|byte| format!("{byte:02x}")).collect()
84}
85
86#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
87pub struct Assignment {
88 pub scope: Scope,
89 /// None takes the scope's override away.
90 pub theme: Option<String>,
91 pub assigned: u64,
92}
93
94/// A notebook's themes file.
95#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
96pub struct Themes {
97 #[serde(default)]
98 pub themes: Vec<Theme>,
99 #[serde(default)]
100 pub assignments: Vec<Assignment>,
101}
102
103impl Themes {
104 /// The built-in themes, then the notebook's own that aren't deleted.
105 pub fn all(&self) -> Vec<Theme> {
106 let mut all = built_in();
107 all.extend(self.themes.iter().filter(|theme| !theme.deleted).cloned());
108 all
109 }
110
111 pub fn theme(&self, id: &str) -> Option<Theme> {
112 self.all().into_iter().find(|theme| theme.id == id)
113 }
114
115 /// The theme `scope` names itself, if one it can find.
116 pub fn assigned(&self, scope: &Scope) -> Option<Theme> {
117 let assignment = self.assignments.iter().find(|kept| kept.scope == *scope)?;
118 self.theme(assignment.theme.as_deref()?)
119 }
120
121 /// The theme a page wears: its own, else its section's, else the notebook's.
122 pub fn effective(&self, section: Option<[u8; 16]>, page: Option<[u8; 16]>) -> Option<Theme> {
123 page.map(Scope::page)
124 .into_iter()
125 .chain(section.map(Scope::section))
126 .chain([Scope::Notebook])
127 .find_map(|scope| self.assigned(&scope))
128 }
129}
130
131/// `into` with `other`'s entries merged by the rule the module describes.
132pub fn merge(into: &mut Themes, other: Themes) {
133 fn later<T: Serialize>(new: &T, new_at: u64, kept: &T, kept_at: u64) -> bool {
134 let json = |value: &T| serde_json::to_string(value).unwrap_or_default();
135 (new_at, json(new)) > (kept_at, json(kept))
136 }
137 for theme in other.themes {
138 match into.themes.iter_mut().find(|kept| kept.id == theme.id) {
139 Some(kept) => {
140 if later(&theme, theme.modified, kept, kept.modified) {
141 *kept = theme;
142 }
143 }
144 None => into.themes.push(theme),
145 }
146 }
147 for assignment in other.assignments {
148 match into
149 .assignments
150 .iter_mut()
151 .find(|kept| kept.scope == assignment.scope)
152 {
153 Some(kept) => {
154 if later(&assignment, assignment.assigned, kept, kept.assigned) {
155 *kept = assignment;
156 }
157 }
158 None => into.assignments.push(assignment),
159 }
160 }
161}
162
163/// The themes file: empty without one, and without entries it holds unreadably.
164pub(crate) fn read(storage: &dyn Storage) -> Result<Themes> {
165 let bytes = match storage.read_file(FILE, super::LIMIT) {
166 Err(crate::Error::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
167 return Ok(Themes::default());
168 }
169 bytes => bytes?,
170 };
171 let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or_default();
172 let entries = |key: &str| -> Vec<serde_json::Value> {
173 value
174 .get(key)
175 .and_then(|list| list.as_array().cloned())
176 .unwrap_or_default()
177 };
178 let mut themes = Themes::default();
179 merge(
180 &mut themes,
181 Themes {
182 themes: entries("themes")
183 .into_iter()
184 .filter_map(|entry| serde_json::from_value(entry).ok())
185 .filter(|theme: &Theme| !is_built_in(&theme.id))
186 .collect(),
187 assignments: entries("assignments")
188 .into_iter()
189 .filter_map(|entry| serde_json::from_value(entry).ok())
190 .collect(),
191 },
192 );
193 Ok(themes)
194}
195
196/// Merges `change` into the notebook's themes file; returns the file as it then stands.
197pub(crate) fn write(storage: &dyn Storage, change: Themes) -> Result<Themes> {
198 if change.themes.iter().any(|theme| is_built_in(&theme.id)) {
199 return Err(io::Error::from(io::ErrorKind::InvalidInput).into());
200 }
201 match storage.create_directory(super::FOLDER) {
202 Err(crate::Error::Io(error)) if error.kind() == io::ErrorKind::AlreadyExists => {}
203 created => created?,
204 }
205 storage.hide(super::FOLDER)?;
206 for _ in 0..3 {
207 let mut merged = read(storage)?;
208 merge(&mut merged, change.clone());
209 let written = super::temporary(FILE);
210 let json = serde_json::to_vec_pretty(&merged).map_err(io::Error::from)?;
211 storage.create(&written, &json)?;
212 storage.replace(&written, FILE)?;
213 let kept = read(storage)?;
214 let mut held = kept.clone();
215 merge(&mut held, change.clone());
216 if held == kept {
217 return Ok(kept);
218 }
219 }
220 Err(io::Error::from(io::ErrorKind::ResourceBusy).into())
221}
222
223fn is_built_in(id: &str) -> bool {
224 built_in().iter().any(|theme| theme.id == id)
225}
226
227/// Stored style `name`'s paragraph style as `style` gives it: every character flag set, as
228/// OneNote 2010 writes its own, headings followed by Normal.
229pub fn definition(name: &str, style: &ThemeStyle) -> Definition {
230 let color = style
231 .color
232 .as_deref()
233 .and_then(|hex| u32::from_str_radix(hex.strip_prefix('#')?, 16).ok())
234 .map_or(0xff00_0000, |rgb| {
235 (rgb >> 16) | (rgb & 0xff00) | ((rgb & 0xff) << 16)
236 });
237 // Paragraph spacing is stored in half inches.
238 let stored = |points: f32| points / 36.0 * 36.0;
239 Definition {
240 kind: Kind::Style {
241 name: Some(name.into()),
242 next: matches!(name, "h1" | "h2" | "h3" | "h4" | "h5" | "h6").then(|| "p".into()),
243 },
244 format: Format {
245 bold: Some(style.bold),
246 italic: Some(style.italic),
247 underline: Some(false),
248 strike: Some(false),
249 superscript: Some(false),
250 subscript: Some(false),
251 font: Some(style.font.clone()),
252 font_size: Some((style.size * 2.0).round() / 2.0),
253 color: Some(color),
254 highlight: Some(0xff00_0000),
255 space_before: Some(stored(style.before)),
256 space_after: Some(stored(style.after)),
257 line_spacing: Some(0.0),
258 ..Format::default()
259 },
260 }
261}
262
263impl Theme {
264 /// The paragraph style definitions the theme gives, by stored name.
265 pub fn sheet(&self) -> BTreeMap<String, Definition> {
266 self.styles
267 .iter()
268 .map(|(name, style)| (name.clone(), definition(name, style)))
269 .collect()
270 }
271}
272
273/// A style in `font` at `size`, `bold` and `italic`, in `color`, with spacing.
274fn style(
275 font: &str,
276 size: f32,
277 [bold, italic]: [bool; 2],
278 color: Option<&str>,
279 [before, after]: [f32; 2],
280) -> ThemeStyle {
281 ThemeStyle {
282 font: font.into(),
283 size,
284 bold,
285 italic,
286 color: color.map(Into::into),
287 before,
288 after,
289 }
290}
291
292const PLAIN: [bool; 2] = [false, false];
293const BOLD: [bool; 2] = [true, false];
294const ITALIC: [bool; 2] = [false, true];
295const BOTH: [bool; 2] = [true, true];
296const NONE: [f32; 2] = [0.0; 2];
297
298/// The themes Snowbound ships. Once shipped a built-in never changes: two versions
299/// disagreeing would restyle a page back and forth as each opens it. A changed look ships
300/// under a new id.
301pub fn built_in() -> Vec<Theme> {
302 let theme = |id: &str, name: &str, styles: [ThemeStyle; 11]| Theme {
303 id: id.into(),
304 name: name.into(),
305 styles: STYLES
306 .iter()
307 .zip(styles)
308 .map(|((stored, _), style)| ((*stored).into(), style))
309 .collect(),
310 modified: 0,
311 deleted: false,
312 };
313 let blue = Some("#366092");
314 vec![
315 // OneNote 2010's own (lab, 2026-09-30).
316 theme(
317 "onenote",
318 "OneNote",
319 [
320 style("Calibri", 16.0, BOLD, Some("#17365D"), NONE),
321 style("Calibri", 13.0, BOLD, blue, NONE),
322 style("Calibri", 11.0, BOLD, blue, NONE),
323 style("Calibri", 11.0, BOTH, blue, NONE),
324 style("Calibri", 11.0, PLAIN, blue, NONE),
325 style("Calibri", 11.0, ITALIC, blue, NONE),
326 style("Calibri", 17.0, PLAIN, None, NONE),
327 style("Calibri", 9.0, PLAIN, Some("#595959"), NONE),
328 style("Calibri", 11.0, ITALIC, Some("#595959"), NONE),
329 style("Courier New", 11.0, PLAIN, None, NONE),
330 style("Calibri", 11.0, PLAIN, None, NONE),
331 ],
332 ),
333 theme(
334 "manuscript",
335 "Manuscript",
336 [
337 style("Georgia", 20.0, PLAIN, Some("#3B2F2A"), [12.0, 4.0]),
338 style("Georgia", 16.0, BOLD, Some("#3B2F2A"), [10.0, 2.0]),
339 style("Georgia", 13.0, BOLD, Some("#3B2F2A"), [8.0, 2.0]),
340 style("Georgia", 12.0, BOTH, Some("#3B2F2A"), [6.0, 0.0]),
341 style("Georgia", 12.0, PLAIN, Some("#3B2F2A"), [6.0, 0.0]),
342 style("Georgia", 12.0, ITALIC, Some("#3B2F2A"), [6.0, 0.0]),
343 style("Georgia", 26.0, PLAIN, Some("#1F1A17"), NONE),
344 style("Georgia", 9.0, PLAIN, Some("#7A6E66"), NONE),
345 style("Georgia", 12.0, ITALIC, Some("#6B5E55"), [0.0, 4.0]),
346 style("Courier New", 10.5, PLAIN, Some("#2B2B2B"), NONE),
347 style("Georgia", 12.0, PLAIN, Some("#2B2B2B"), [0.0, 4.0]),
348 ],
349 ),
350 theme(
351 "editorial",
352 "Editorial",
353 [
354 style("Georgia", 18.0, BOLD, Some("#9A3B1F"), [12.0, 2.0]),
355 style("Georgia", 14.0, BOLD, Some("#202020"), [10.0, 2.0]),
356 style("Georgia", 12.0, BOLD, Some("#202020"), [8.0, 0.0]),
357 style("Georgia", 11.0, BOTH, Some("#202020"), [6.0, 0.0]),
358 style("Georgia", 11.0, PLAIN, Some("#9A3B1F"), [6.0, 0.0]),
359 style("Georgia", 11.0, ITALIC, Some("#9A3B1F"), [6.0, 0.0]),
360 style("Georgia", 24.0, PLAIN, Some("#202020"), NONE),
361 style("Calibri", 9.0, PLAIN, Some("#707070"), NONE),
362 style("Georgia", 12.0, ITALIC, Some("#555555"), [0.0, 3.0]),
363 style("Courier New", 10.0, PLAIN, Some("#262626"), NONE),
364 style("Calibri", 11.5, PLAIN, Some("#262626"), [0.0, 3.0]),
365 ],
366 ),
367 theme(
368 "modern",
369 "Modern",
370 [
371 style("Arial", 16.0, BOLD, Some("#0E6E6E"), [10.0, 2.0]),
372 style("Arial", 13.0, BOLD, Some("#1B2631"), [8.0, 2.0]),
373 style("Arial", 11.0, BOLD, Some("#1B2631"), [6.0, 0.0]),
374 style("Arial", 10.5, BOTH, Some("#1B2631"), [6.0, 0.0]),
375 style("Arial", 10.5, BOLD, Some("#0E6E6E"), [6.0, 0.0]),
376 style("Arial", 10.5, ITALIC, Some("#0E6E6E"), [6.0, 0.0]),
377 style("Arial", 22.0, BOLD, Some("#1B2631"), NONE),
378 style("Arial", 8.0, PLAIN, Some("#7B868C"), NONE),
379 style("Arial", 10.5, ITALIC, Some("#5F6B73"), [0.0, 3.0]),
380 style("Courier New", 10.0, PLAIN, Some("#1B2631"), NONE),
381 style("Arial", 10.5, PLAIN, Some("#2E2E2E"), [0.0, 3.0]),
382 ],
383 ),
384 ]
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390
391 /// OneNote 2010's theme is what OneNote writes, as Snowbound reads it back, so a page
392 /// OneNote styled has nothing to restyle under it.
393 #[test]
394 fn the_onenote_theme_is_what_onenote_writes() {
395 let source = include_bytes!("../../../../corpus/styles/onenote/Styles.one");
396 let store = onestore::Store::parse(source).unwrap();
397 let index = onestore::RevisionIndex::parse(&store).unwrap();
398 let document = onestore::document::Document::parse(&index).unwrap();
399 let (space, _) = document.pages().unwrap()[0];
400 let page = onestore::page::Page::from_space(&document, space).unwrap();
401 let sheet = built_in()[0].sheet();
402 for (name, definition) in &sheet {
403 assert!(
404 page.definitions.values().any(|stored| stored == definition),
405 "{name}"
406 );
407 }
408 // Only the heading pasted in from a page styled through COM differs.
409 let ops = onestore::op::restyle(&page, &sheet).unwrap();
410 assert_eq!(ops.len(), 1);
411 let onestore::op::PageOp::Restyle { style, into, .. } = &ops[0] else {
412 unreachable!()
413 };
414 assert_eq!(
415 page.definitions[style].format.font.as_deref(),
416 Some("Georgia")
417 );
418 assert_eq!(page.definitions[into], sheet["h1"]);
419 }
420
421 /// OneNote 2010 on a page Snowbound dressed in Manuscript: Ctrl+Alt+2 and Enter add its
422 /// own `h2` and `p` beside the theme's (`corpus/styles/onenote-edit`). Snowbound reads
423 /// them under those names, and restyling heals both: one edit, every style the theme's.
424 #[test]
425 fn styles_onenote_adds_to_a_themed_page_are_healed() {
426 let source = include_bytes!("../../../../corpus/styles/onenote-edit/Manuscript.one");
427 let arena = onestore::Arena::default();
428 let mut section = onestore::Section::open(&arena, source.to_vec()).unwrap();
429 let (space, _, _) = section.pages().unwrap()[0];
430 let page = section.page(space).unwrap();
431 let sheet = built_in()
432 .into_iter()
433 .find(|theme| theme.id == "manuscript")
434 .unwrap()
435 .sheet();
436 let calibri: Vec<&str> = page
437 .definitions
438 .values()
439 .filter_map(|stored| match &stored.kind {
440 Kind::Style {
441 name: Some(name), ..
442 } if stored.format.font.as_deref() == Some("Calibri") => Some(name.as_str()),
443 _ => None,
444 })
445 .collect();
446 assert_eq!(calibri, ["h2", "p"]);
447 let ops = onestore::op::restyle(&page, &sheet).unwrap();
448 assert_eq!(ops.len(), 2);
449 let edit = onestore::op::Edit {
450 at: 134_000_000_000_000_000,
451 ops: ops
452 .into_iter()
453 .map(|op| onestore::op::Op::Page { space, op })
454 .collect(),
455 };
456 section.apply("Author", &edit).unwrap();
457 section.seal().unwrap();
458 let healed = onestore::Section::open(&arena, section.image())
459 .unwrap()
460 .page(space)
461 .unwrap();
462 for stored in healed.definitions.values() {
463 if let Kind::Style {
464 name: Some(name), ..
465 } = &stored.kind
466 {
467 assert_eq!(Some(stored), sheet.get(name), "{name}");
468 }
469 }
470 assert!(onestore::op::restyle(&healed, &sheet).unwrap().is_empty());
471 }
472
473 #[test]
474 fn every_built_in_theme_names_every_gallery_style() {
475 for theme in built_in() {
476 let sheet = theme.sheet();
477 for (name, _) in STYLES {
478 assert!(sheet.contains_key(name), "{} {name}", theme.id);
479 }
480 }
481 }
482
483 /// A page takes its own theme, else its section's, else the notebook's; a scope naming a
484 /// deleted theme, or none, falls through.
485 #[test]
486 fn a_page_takes_the_nearest_theme() {
487 let (section, page) = ([1; 16], [2; 16]);
488 let assign = |scope, theme: Option<&str>, assigned| Assignment {
489 scope,
490 theme: theme.map(Into::into),
491 assigned,
492 };
493 let mut custom = built_in().remove(1);
494 custom.id = "essay".into();
495 custom.deleted = true;
496 let mut themes = Themes {
497 themes: vec![custom],
498 assignments: vec![assign(Scope::Notebook, Some("modern"), 1)],
499 };
500 let effective = |themes: &Themes| themes.effective(Some(section), Some(page)).unwrap().id;
501 assert_eq!(effective(&themes), "modern");
502 merge(
503 &mut themes,
504 Themes {
505 assignments: vec![
506 assign(Scope::section(section), Some("editorial"), 2),
507 assign(Scope::page(page), Some("essay"), 2),
508 ],
509 ..Default::default()
510 },
511 );
512 assert_eq!(effective(&themes), "editorial");
513 themes.themes[0].deleted = false;
514 assert_eq!(effective(&themes), "essay");
515 merge(
516 &mut themes,
517 Themes {
518 assignments: vec![assign(Scope::page(page), None, 3)],
519 ..Default::default()
520 },
521 );
522 assert_eq!(effective(&themes), "editorial");
523 assert_eq!(themes.effective(None, None).unwrap().id, "modern");
524 }
525
526 /// Writers merging in either order agree: the later entry wins, then the greater.
527 #[test]
528 fn merges_agree_in_either_order() {
529 let theme = |name: &str, modified| {
530 let mut theme = built_in().remove(2);
531 theme.id = "mine".into();
532 theme.name = name.into();
533 theme.modified = modified;
534 theme
535 };
536 let a = Themes {
537 themes: vec![theme("A", 5)],
538 assignments: vec![Assignment {
539 scope: Scope::Notebook,
540 theme: Some("mine".into()),
541 assigned: 7,
542 }],
543 };
544 let b = Themes {
545 themes: vec![theme("B", 5)],
546 assignments: vec![Assignment {
547 scope: Scope::Notebook,
548 theme: None,
549 assigned: 9,
550 }],
551 };
552 let mut ab = a.clone();
553 merge(&mut ab, b.clone());
554 let mut ba = b;
555 merge(&mut ba, a);
556 assert_eq!(ab, ba);
557 assert_eq!(ab.themes[0].name, "B");
558 assert_eq!(ab.assignments[0].theme, None);
559 }
560}
crates/notebook/tests/discovery.rs+40
...@@ -197,6 +197,46 @@ fn evicted_files_list_under_their_names_until_downloaded() {...@@ -197,6 +197,46 @@ fn evicted_files_list_under_their_names_until_downloaded() {
197 assert!(!root.join("Open Notebook.onetoc2").exists());197 assert!(!root.join("Open Notebook.onetoc2").exists());
198}198}
199199
200/// A source whose files list as macOS lists those iCloud Drive evicted: dataless, under their
201/// names and listings, and never read.
202struct Dataless(Local);
203
204impl Source for Dataless {
205 fn entries(&mut self, path: &str, limit: usize) -> io::Result<Vec<Entry>> {
206 let mut entries = self.0.entries(path, limit)?;
207 for entry in &mut entries {
208 entry.kind = notebook::discover::EntryKind::Evicted;
209 }
210 Ok(entries)
211 }
212
213 fn read(&mut self, path: &str, _: usize) -> io::Result<Vec<u8>> {
214 panic!("read {path}, which is not on this device");
215 }
216}
217
218/// A dataless file lists as its last read found it, where it lists unchanged since, and as not
219/// downloaded otherwise.
220#[test]
221fn dataless_files_list_as_last_read_or_wait_for_their_download() {
222 let root = tempfile::tempdir().unwrap();
223 fixture(root.path());
224 let mut cache = Cache::default();
225 cache
226 .discover(&mut Local::open(root.path()).unwrap(), limits())
227 .unwrap();
228 let catalog = cache
229 .discover(&mut Dataless(Local::open(root.path()).unwrap()), limits())
230 .unwrap();
231 assert_eq!(catalog.sections[0].path, "one.one");
232 assert!(catalog.toc.is_some() && catalog.unavailable.is_empty());
233
234 let catalog = discover(&mut Dataless(Local::open(root.path()).unwrap()), limits()).unwrap();
235 assert!(catalog.sections.is_empty() && catalog.toc.is_none());
236 assert_eq!(catalog.unavailable[0].path, "one.one");
237 assert_eq!(catalog.unavailable[0].reason, Reason::Evicted);
238}
239
200#[test]240#[test]
201fn locked_and_unreadable_sections_retain_their_storage_identity() {241fn locked_and_unreadable_sections_retain_their_storage_identity() {
202 let root = tempfile::tempdir().unwrap();242 let root = tempfile::tempdir().unwrap();
crates/notebook/tests/structure.rs+52
...@@ -336,3 +336,55 @@ fn a_page_delete_lists_the_recycle_bin_it_finds_or_makes_and_leaves_an_unreadabl...@@ -336,3 +336,55 @@ fn a_page_delete_lists_the_recycle_bin_it_finds_or_makes_and_leaves_an_unreadabl
336 assert_eq!(names.len(), 2, "{names:?}");336 assert_eq!(names.len(), 2, "{names:?}");
337 }337 }
338}338}
339
340/// What the recycle bin holds goes for good once its pages last changed over 60 days
341/// before, as OneNote 2010 prunes it; a pruned section's TOC entry stays, as OneNote leaves it
342/// (`corpus/recycle-purge`). `NOTEBOOK_PURGE_EXPORT` names a new directory receiving the
343/// purged notebook for a cold reopen.
344#[test]
345fn the_recycle_bin_forgets_pages_and_sections_unchanged_for_sixty_days() {
346 let temporary = tempfile::tempdir().unwrap();
347 let root = temporary.path().join("notebook");
348 let bin = root.join("OneNote_RecycleBin");
349 let page = onestore::PageCreation::new(None, Some("Kept"), "Author").unwrap();
350 let mut notebook =
351 Notebook::create(&root, temporary.path().join("cache"), 0x00d7ff, &page).unwrap();
352 let old = onestore::PageCreation::new(None, Some("Old page"), "Author").unwrap();
353 notebook.create_section("", "Old", &old).unwrap();
354 let image = notebook.read_section("New Section 1.one").unwrap();
355 let pages = notebook::session::stored_pages(&image).unwrap();
356 notebook
357 .recycle_pages(&[pages[0].page.clone()], "Author")
358 .unwrap();
359 notebook.delete("Old.one").unwrap();
360 let toc = bin.join("Open Notebook.onetoc2");
361 let before = listed(&toc);
362 assert!(before.iter().any(|(name, _)| name == "Old.one"));
363 let deleted = bin.join("OneNote_DeletedPages.one");
364 let recycled = |notebook: &Notebook| {
365 let image = notebook
366 .read_section("OneNote_RecycleBin/OneNote_DeletedPages.one")
367 .unwrap();
368 notebook::session::stored_pages(&image).unwrap().len()
369 };
370 assert_eq!(recycled(&notebook), 1);
371 let now = std::time::SystemTime::now()
372 .duration_since(std::time::UNIX_EPOCH)
373 .unwrap()
374 .as_secs() as u32
375 - 315_532_800;
376 let day = 86_400;
377 assert_eq!(notebook.purge_recycle_bin(now + 59 * day).unwrap(), 0);
378 assert!(bin.join("Old.one").exists());
379 let length = std::fs::metadata(&deleted).unwrap().len();
380 assert_eq!(notebook.purge_recycle_bin(now + 61 * day).unwrap(), 2);
381 assert!(!bin.join("Old.one").exists());
382 assert_eq!(recycled(&notebook), 0);
383 // One appended revision; the bin's TOC untouched.
384 assert!(std::fs::metadata(&deleted).unwrap().len() > length);
385 assert_eq!(listed(&toc), before);
386 assert_eq!(notebook.purge_recycle_bin(now + 61 * day).unwrap(), 0);
387 if let Some(directory) = std::env::var_os("NOTEBOOK_PURGE_EXPORT") {
388 copy_dir(&root, std::path::Path::new(&directory));
389 }
390}
crates/notebook/tests/sync_outline.rs+1
...@@ -634,6 +634,7 @@ fn with_file(source: &[u8], space: ExGuid) -> (Vec<u8>, ExGuid) {...@@ -634,6 +634,7 @@ fn with_file(source: &[u8], space: ExGuid) -> (Vec<u8>, ExGuid) {
634 bytes: Some(std::sync::Arc::from(&b"Floating bytes"[..])),634 bytes: Some(std::sync::Arc::from(&b"Floating bytes"[..])),
635 preview: None,635 preview: None,
636 recording: None,636 recording: None,
637 tags: Vec::new(),
637 };638 };
638 let image = remote_with(source, space, |page| {639 let image = remote_with(source, space, |page| {
639 page.objects.push(PageObject::Attachment(file))640 page.objects.push(PageObject::Attachment(file))
crates/onestore/README.md+1
...@@ -56,6 +56,7 @@ harness also accepts `--client-profile release`....@@ -56,6 +56,7 @@ harness also accepts `--client-profile release`.
56| `TocEdit`, `edit_table_of_contents` | Add, rename, order and remove a table of contents' section and group entries, and colour the notebook, as one revision's `Transaction`; a section's colour is `SectionOp::Color` in its own metadata |56| `TocEdit`, `edit_table_of_contents` | Add, rename, order and remove a table of contents' section and group entries, and colour the notebook, as one revision's `Transaction`; a section's colour is `SectionOp::Color` in its own metadata |
57| `place`, `place_file` | Name a file for its notebook as OneNote does on adoption (parent TOC identity and name CRC in the header), so OneNote keeps its identity, on any `CommitIo` or under the filesystem adapter |57| `place`, `place_file` | Name a file for its notebook as OneNote does on adoption (parent TOC identity and name CRC in the header), so OneNote keeps its identity, on any `CommitIo` or under the filesystem adapter |
58| `TextAttribute`, `PageOp::Format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs |58| `TextAttribute`, `PageOp::Format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs |
59| `PageOp::Style`, `PageOp::Restyle`, `op::restyle` | Give a paragraph a paragraph style (with its NextStyle, as OneNote 2010 writes its headings), or move every paragraph of a style to a new style object of the same name; style objects are read-only, and run or paragraph values equal to what the old style gave are cleared so they follow the new one. `op::restyle(page, sheet)` brings a page's named styles to a sheet of definitions, joining styles that share a name |
59| `OutlineEdit`, `PageOp::Outline` | Change ordinary outline position/width, the position of a file or ink drawing on the page, or a paragraph's saved expansion default, preserving identities and content |60| `OutlineEdit`, `PageOp::Outline` | Change ordinary outline position/width, the position of a file or ink drawing on the page, or a paragraph's saved expansion default, preserving identities and content |
60| `Transaction`, `Stamp` | The bytes a commit writes (appended data, in-place list-tail and log patches, header) and the header and length it requires unchanged; `commit` under caller-held exclusion, `commit_file` under the conservative filesystem adapter, `apply` to the base image in memory; serializable for queues |61| `Transaction`, `Stamp` | The bytes a commit writes (appended data, in-place list-tail and log patches, header) and the header and length it requires unchanged; `commit` under caller-held exclusion, `commit_file` under the conservative filesystem adapter, `apply` to the base image in memory; serializable for queues |
61| `Arena`, `Section` | Keep a section parsed across edits: `open` validates an image once; `seal` appends one revision per changed space as a `Transaction` on `stamp`, checking only what it appends; `replay` applies a queued transaction; `image` and `page` read the result |62| `Arena`, `Section` | Keep a section parsed across edits: `open` validates an image once; `seal` appends one revision per changed space as a `Transaction` on `stamp`, checking only what it appends; `replay` applies a queued transaction; `image` and `page` read the result |
crates/onestore/examples/opts29_dump.rs deleted-29
...@@ -1,29 +0,0 @@
1use onestore::{ObjectData, PropertySets, RevisionIndex, Store, Value};
2use std::{env, fs};
3
4fn main() -> Result<(), Box<dyn std::error::Error>> {
5 for path in env::args_os().skip(1) {
6 let bytes = fs::read(&path)?;
7 let store = Store::parse(&bytes)?;
8 let index = RevisionIndex::parse(&store)?;
9 println!("== {path:?}");
10 for space in index.spaces.keys() {
11 let Ok(resolved) = index.resolve_active(*space) else { continue };
12 println!("-- space {space:?}");
13 for (id, object) in &resolved.objects {
14 let ObjectData::Properties(data) = object.data else { continue };
15 let sets = PropertySets::parse(data)?;
16 println!("{id:?} jcid={:#x}", object.jcid);
17 for p in &sets.sets[0] {
18 let v = match &p.value {
19 Value::Bytes(b) if b.len() <= 64 => format!("{:02x?}", b),
20 Value::Bytes(b) => format!("{} bytes", b.len()),
21 other => format!("{other:?}").chars().take(120).collect(),
22 };
23 println!(" {:#010x} {v}", p.id);
24 }
25 }
26 }
27 }
28 Ok(())
29}
crates/onestore/src/document.rs+4
...@@ -527,6 +527,9 @@ pub enum Kind<'a> {...@@ -527,6 +527,9 @@ pub enum Kind<'a> {
527 },527 },
528 Style {528 Style {
529 name: Option<String>,529 name: Option<String>,
530 /// NextStyle: the style Enter gives the paragraph after this one's end.
531 #[serde(default, skip_serializing_if = "Option::is_none")]
532 next: Option<String>,
530 },533 },
531 List {534 List {
532 font: Option<String>,535 font: Option<String>,
...@@ -1279,6 +1282,7 @@ impl<'a> Element<'a> {...@@ -1279,6 +1282,7 @@ impl<'a> Element<'a> {
1279 }1282 }
1280 0x12004d => Kind::Style {1283 0x12004d => Kind::Style {
1281 name: f.text(0x1c00345a)?,1284 name: f.text(0x1c00345a)?,
1285 next: f.text(0x1c00348a)?,
1282 },1286 },
1283 0x60012 => {1287 0x60012 => {
1284 let format = f1288 let format = f
crates/onestore/src/formatting.rs+25-2
...@@ -108,6 +108,28 @@ pub(crate) fn format_changes(...@@ -108,6 +108,28 @@ pub(crate) fn format_changes(
108 range: Range<u32>,108 range: Range<u32>,
109 attributes: &[TextAttribute],109 attributes: &[TextAttribute],
110 cleared: &[crate::op::TextProperty],110 cleared: &[crate::op::TextProperty],
111) -> Result<Changes, Error> {
112 formatted(active, object, range, attributes, cleared, false)
113}
114
115/// `format_changes` clearing `cleared` from whole runs, fields and equations among them,
116/// so they inherit what their paragraph style gives, as a restyle does.
117pub(crate) fn cleared_changes(
118 active: &ActivePage<'_>,
119 object: ExGuid,
120 range: Range<u32>,
121 cleared: &[crate::op::TextProperty],
122) -> Result<Changes, Error> {
123 formatted(active, object, range, &[], cleared, true)
124}
125
126fn formatted(
127 active: &ActivePage<'_>,
128 object: ExGuid,
129 range: Range<u32>,
130 attributes: &[TextAttribute],
131 cleared: &[crate::op::TextProperty],
132 fields: bool,
111) -> Result<Changes, Error> {133) -> Result<Changes, Error> {
112 let cleared: Vec<u32> = cleared.iter().map(|property| property.id()).collect();134 let cleared: Vec<u32> = cleared.iter().map(|property| property.id()).collect();
113 if (attributes.is_empty() && cleared.is_empty()) || range.start > range.end {135 if (attributes.is_empty() && cleared.is_empty()) || range.start > range.end {
...@@ -163,8 +185,9 @@ pub(crate) fn format_changes(...@@ -163,8 +185,9 @@ pub(crate) fn format_changes(
163 };185 };
164 if selected {186 if selected {
165 let format = &resolved[i].format;187 let format = &resolved[i].format;
166 if [format.math, format.embedded_object].contains(&Some(true))188 if !fields
167 || resolved[i].text.contains('\u{fffc}')189 && ([format.math, format.embedded_object].contains(&Some(true))
190 || resolved[i].text.contains('\u{fffc}'))
168 {191 {
169 return Err(invalid(192 return Err(invalid(
170 "This format range contains a field or embedded data",193 "This format range contains a field or embedded data",
crates/onestore/src/op/apply.rs+112-5
...@@ -10,8 +10,8 @@ use super::{...@@ -10,8 +10,8 @@ use super::{
10 table::{self, Structure},10 table::{self, Structure},
11};11};
12use crate::{12use crate::{
13 Error, ExGuid, Insertion, OutlineEdit, ParagraphJoin, ParagraphSplit, Section, TextAttribute,13 Error, ExGuid, Insertion, ObjectData, OutlineEdit, ParagraphJoin, ParagraphSplit, Section,
14 TreeEdit,14 TextAttribute, TreeEdit,
15 active::{ActivePage, Changes},15 active::{ActivePage, Changes},
16 document::Kind,16 document::Kind,
17 page::{17 page::{
...@@ -517,6 +517,16 @@ impl<'a> Writer<'_, 'a> {...@@ -517,6 +517,16 @@ impl<'a> Writer<'_, 'a> {
517 }517 }
518518
519 /// Requires `id` reachable on the page.519 /// Requires `id` reachable on the page.
520 /// Clears run properties over run ranges, fields and equations included.
521 fn clear_runs(&mut self, clears: &properties::Clears) -> Result<(), Failure> {
522 for (text, range, clear) in clears {
523 self.write(|page| {
524 crate::formatting::cleared_changes(page, *text, range.clone(), clear)
525 })?;
526 }
527 Ok(())
528 }
529
520 fn target(&mut self, id: ExGuid) -> Result<(), Failure> {530 fn target(&mut self, id: ExGuid) -> Result<(), Failure> {
521 let page = self.page()?;531 let page = self.page()?;
522 if page.live.is_reachable(id) && page.view.nodes.contains_key(&id) {532 if page.live.is_reachable(id) && page.view.nodes.contains_key(&id) {
...@@ -786,7 +796,89 @@ impl<'a> Writer<'_, 'a> {...@@ -786,7 +796,89 @@ impl<'a> Writer<'_, 'a> {
786 } => {796 } => {
787 self.target(*paragraph)?;797 self.target(*paragraph)?;
788 let text = self.text_of(*paragraph)?;798 let text = self.text_of(*paragraph)?;
789 self.write(|page| properties::style_changes(page, text, *style, Some(definition)))799 // What the old style gave, the new one gives instead.
800 let page = self.page()?;
801 let spacing = match page.view.nodes[&text].kind {
802 Kind::RichText {
803 paragraph_style: Some(old),
804 ..
805 } if old != *style => {
806 let (clears, spacing) = properties::restyle_plan(page, old, Some(text))?;
807 self.clear_runs(&clears)?;
808 spacing
809 }
810 _ => Vec::new(),
811 };
812 self.write(|page| {
813 let mut changes =
814 properties::style_changes(page, text, *style, Some(definition))?;
815 if let Some((_, ids)) = spacing.first() {
816 changes
817 .get_mut(&text)
818 .expect("the text is restyled")
819 .remove(ids)?;
820 }
821 Ok(changes)
822 })
823 }
824 PageOp::Unstyle { paragraph } => {
825 self.target(*paragraph)?;
826 let text = self.text_of(*paragraph)?;
827 let page = self.page()?;
828 let Kind::RichText {
829 paragraph_style: Some(old),
830 ..
831 } = page.view.nodes[&text].kind
832 else {
833 return Ok(());
834 };
835 let (clears, spacing) = properties::restyle_plan(page, old, Some(text))?;
836 self.clear_runs(&clears)?;
837 self.write(|page| {
838 let mut target = crate::write::PropertyObject::from_object(
839 &page.live.revision.objects[&text],
840 )?;
841 let mut removed = vec![0x2000342c];
842 removed.extend(spacing.into_iter().flat_map(|(_, ids)| ids));
843 target.remove(&removed)?;
844 Ok(std::collections::BTreeMap::from([(text, target)]))
845 })
846 }
847 PageOp::Restyle {
848 style,
849 into,
850 definition,
851 } => {
852 self.target(*style)?;
853 let page = self.page()?;
854 let (Kind::Style { name, .. }, Kind::Style { name: renamed, .. }) =
855 (&page.view.nodes[style].kind, &definition.kind)
856 else {
857 return Err(OpError::Unsupported("Restyle a paragraph style").into());
858 };
859 if name.is_none() || name != renamed {
860 return Err(
861 OpError::Unsupported("A restyled paragraph style keeps its name").into(),
862 );
863 }
864 if page.live.is_reachable(*into) {
865 let ObjectData::Properties(stored) = page.live.revision.objects[into].data
866 else {
867 return Err(OpError::DuplicateIdentity(*into).into());
868 };
869 let given =
870 crate::create::properties(&properties::style_object_values(definition)?)?;
871 if stored != given.as_slice() {
872 return Err(OpError::DuplicateIdentity(*into).into());
873 }
874 } else if into.guid == [0; 16] {
875 return Err(OpError::DuplicateIdentity(*into).into());
876 }
877 let (clears, spacing) = properties::restyle_plan(page, *style, None)?;
878 self.clear_runs(&clears)?;
879 self.write(|page| {
880 properties::restyle_changes(page, *style, *into, definition, &spacing)
881 })
790 }882 }
791 PageOp::Media { paragraph, media } => {883 PageOp::Media { paragraph, media } => {
792 self.target(*paragraph)?;884 self.target(*paragraph)?;
...@@ -1179,10 +1271,22 @@ impl<'a> Writer<'_, 'a> {...@@ -1179,10 +1271,22 @@ impl<'a> Writer<'_, 'a> {
1179 let Some(bytes) = &image.bytes else {1271 let Some(bytes) = &image.bytes else {
1180 return Err(OpError::Unsupported("A new picture needs its payload").into());1272 return Err(OpError::Unsupported("A new picture needs its payload").into());
1181 };1273 };
1274 if !image.tags.is_empty() {
1275 return Err(OpError::Unsupported("Set a picture's tags with their own op").into());
1276 }
1182 let (file, payload) = (fresh()?, crate::write::fresh_guid()?);1277 let (file, payload) = (fresh()?, crate::write::fresh_guid()?);
1183 let bytes: &[u8] = bytes;1278 let bytes: &[u8] = bytes;
1184 self.write_with(&[(payload, bytes)], |page| {1279 let mut payloads = vec![(payload, bytes)];
1185 content::picture_changes(page, image, image.id, file, payload, holder)1280 let shown = match &image.display {
1281 Some(raster) => {
1282 let shown = (fresh()?, crate::write::fresh_guid()?);
1283 payloads.push((shown.1, raster));
1284 Some(shown)
1285 }
1286 None => None,
1287 };
1288 self.write_with(&payloads, |page| {
1289 content::picture_changes(page, image, (image.id, file, payload), shown, holder)
1186 })1290 })
1187 }1291 }
11881292
...@@ -1194,6 +1298,9 @@ impl<'a> Writer<'_, 'a> {...@@ -1194,6 +1298,9 @@ impl<'a> Writer<'_, 'a> {
1194 let Some(bytes) = &attachment.bytes else {1298 let Some(bytes) = &attachment.bytes else {
1195 return Err(OpError::Unsupported("A new attachment needs its payload").into());1299 return Err(OpError::Unsupported("A new attachment needs its payload").into());
1196 };1300 };
1301 if !attachment.tags.is_empty() {
1302 return Err(OpError::Unsupported("Set a file's tags with their own op").into());
1303 }
1197 let ids = AttachmentIds {1304 let ids = AttachmentIds {
1198 object: attachment.id,1305 object: attachment.id,
1199 file: fresh()?,1306 file: fresh()?,
crates/onestore/src/op/content.rs+52-11
...@@ -132,9 +132,11 @@ pub(crate) fn picture_fixed_fields(stored: &Image, image: &Image) -> Result<(),...@@ -132,9 +132,11 @@ pub(crate) fn picture_fixed_fields(stored: &Image, image: &Image) -> Result<(),
132 || stored.bytes != image.bytes132 || stored.bytes != image.bytes
133 || stored.size != image.size133 || stored.size != image.size
134 || stored.background != image.background134 || stored.background != image.background
135 || stored.link != image.link
136 || stored.text != image.text
135 {137 {
136 return Err(invalid(138 return Err(invalid(
137 "A stored picture keeps its payload, intrinsic size and background state",139 "A stored picture keeps its payload, intrinsic size, background state, link and text",
138 ));140 ));
139 }141 }
140 Ok(())142 Ok(())
...@@ -201,13 +203,14 @@ fn icon_size(...@@ -201,13 +203,14 @@ fn icon_size(
201203
202/// What OneNote stores for an inserted picture: a file-data object declaring payload204/// What OneNote stores for an inserted picture: a file-data object declaring payload
203/// `payload` by identity and extension, and picture object `id` that paragraph `holder`205/// `payload` by identity and extension, and picture object `id` that paragraph `holder`
204/// holds as content or the page lists as a child. The caller embeds the payload.206/// holds as content or the page lists as a child; with `shown`, the file-data object and
207/// payload of the raster OneNote shows of it (WebPictureContainer14), as a printout page
208/// stores beside its XPS package. The caller embeds the payloads.
205pub(crate) fn picture_changes(209pub(crate) fn picture_changes(
206 active: &ActivePage<'_>,210 active: &ActivePage<'_>,
207 image: &Image,211 image: &Image,
208 id: ExGuid,212 (id, file, payload): (ExGuid, ExGuid, [u8; 16]),
209 file: ExGuid,213 shown: Option<(ExGuid, [u8; 16])>,
210 payload: [u8; 16],
211 holder: Option<ExGuid>,214 holder: Option<ExGuid>,
212) -> Result<Changes, Error> {215) -> Result<Changes, Error> {
213 let Some(bytes) = &image.bytes else {216 let Some(bytes) = &image.bytes else {
...@@ -220,8 +223,19 @@ pub(crate) fn picture_changes(...@@ -220,8 +223,19 @@ pub(crate) fn picture_changes(
220 [b'B', b'M', ..] => ".bmp",223 [b'B', b'M', ..] => ".bmp",
221 // OneNote keeps an imported TIFF as it is (`corpus/m6/native-features-01`).224 // OneNote keeps an imported TIFF as it is (`corpus/m6/native-features-01`).
222 [b'I', b'I', 0x2a, 0x00, ..] | [b'M', b'M', 0x00, 0x2a, ..] => ".tif",225 [b'I', b'I', 0x2a, 0x00, ..] | [b'M', b'M', 0x00, 0x2a, ..] => ".tif",
226 // A file printout's page: its XPS package (`corpus/printout`).
227 [b'P', b'K', 3, 4, ..] if image.printout.is_some() && shown.is_some() => ".xps",
223 _ => return Err(invalid("Choose a PNG, JPEG, GIF, BMP or TIFF picture")),228 _ => return Err(invalid("Choose a PNG, JPEG, GIF, BMP or TIFF picture")),
224 };229 };
230 if image
231 .display
232 .as_ref()
233 .is_some_and(|raster| !raster.starts_with(b"\x89PNG"))
234 {
235 return Err(invalid(
236 "A picture's shown raster is a PNG stored beside it",
237 ));
238 }
225 let modified = crate::create::current_timestamps()?.0.to_le_bytes();239 let modified = crate::create::current_timestamps()?.0.to_le_bytes();
226 let mut values: Values = vec![(0x14001d7a, modified.to_vec())];240 let mut values: Values = vec![(0x14001d7a, modified.to_vec())];
227 if let Some([width, height]) = image.size {241 if let Some([width, height]) = image.size {
...@@ -235,6 +249,18 @@ pub(crate) fn picture_changes(...@@ -235,6 +249,18 @@ pub(crate) fn picture_changes(
235 if let Some(alt) = &image.alt {249 if let Some(alt) = &image.alt {
236 values.push((0x1c001e58, crate::create::string(alt)));250 values.push((0x1c001e58, crate::create::string(alt)));
237 }251 }
252 if let Some(link) = &image.link {
253 values.push((0x1c001e20, crate::create::string(link)));
254 }
255 if let Some(recognized) = &image.text {
256 values.push((0x1c001c22, crate::create::string(&recognized.text)));
257 for (id, value) in &recognized.stored {
258 if !crate::page::RECOGNIZED_PROPERTIES.contains(id) {
259 return Err(invalid("A picture's text stores only its known properties"));
260 }
261 values.push((*id, value.clone()));
262 }
263 }
238 if image.background {264 if image.background {
239 values.push((0x08001d13 | (1 << 31), Vec::new()));265 values.push((0x08001d13 | (1 << 31), Vec::new()));
240 }266 }
...@@ -244,14 +270,21 @@ pub(crate) fn picture_changes(...@@ -244,14 +270,21 @@ pub(crate) fn picture_changes(
244 0x08001d85 | (u32::from(image.printout.is_some()) << 31),270 0x08001d85 | (u32::from(image.printout.is_some()) << 31),
245 Vec::new(),271 Vec::new(),
246 ));272 ));
247 if let Some(page) = image.printout {273 if let Some(printout) = &image.printout {
248 values.push((0x14001df9, page.to_le_bytes().to_vec()));274 values.push((0x14001df9, printout.page.to_le_bytes().to_vec()));
275 for (id, value) in &printout.stored {
276 if !crate::page::PRINTOUT_PROPERTIES.contains(id) {
277 return Err(invalid("A printout page stores only its known properties"));
278 }
279 values.push((*id, value.clone()));
280 }
249 }281 }
250 let mut changed = BTreeMap::new();282 let mut changed = BTreeMap::new();
251 changed.insert(283 let mut container = PropertyObject::file(file, &payload_reference(payload), extension)?;
252 file,284 if extension == ".xps" {
253 PropertyObject::file(file, &payload_reference(payload), extension)?,285 container.jcid = crate::write::PRINTOUT_FILE_JCID;
254 );286 }
287 changed.insert(file, container);
255 let mut picture = PropertyObject {288 let mut picture = PropertyObject {
256 jcid: 0x60011,289 jcid: 0x60011,
257 bytes: crate::create::properties(&values)?,290 bytes: crate::create::properties(&values)?,
...@@ -260,6 +293,14 @@ pub(crate) fn picture_changes(...@@ -260,6 +293,14 @@ pub(crate) fn picture_changes(
260 picture.reference(id)?;293 picture.reference(id)?;
261 let container = picture.reference(file)?;294 let container = picture.reference(file)?;
262 picture.set(&[(0x20001c3f, &container)])?;295 picture.set(&[(0x20001c3f, &container)])?;
296 if let Some((raster, payload)) = shown {
297 changed.insert(
298 raster,
299 PropertyObject::file(raster, &payload_reference(payload), ".png")?,
300 );
301 let web = picture.reference(raster)?;
302 picture.set(&[(0x200034c8, &web)])?;
303 }
263 changed.insert(id, picture);304 changed.insert(id, picture);
264 hold(active, &mut changed, id, holder, &modified)?;305 hold(active, &mut changed, id, holder, &modified)?;
265 Ok(changed)306 Ok(changed)
crates/onestore/src/op/lower.rs+71-30
...@@ -316,7 +316,9 @@ pub(crate) fn validate(...@@ -316,7 +316,9 @@ pub(crate) fn validate(
316 Some(None) => paragraph.style,316 Some(None) => paragraph.style,
317 None => previous.style,317 None => previous.style,
318 };318 };
319 if paragraph.style != style || paragraph.format != previous.format {319 // `Style` and `Unstyle` give a paragraph of text a style and take it away.
320 let restyled = paragraph.style != style && paragraph.text().is_none();
321 if restyled || paragraph.format != previous.format {
320 return Err(invalid(322 return Err(invalid(
321 "Paragraph styles and paragraph formatting cannot be edited",323 "Paragraph styles and paragraph formatting cannot be edited",
322 ));324 ));
...@@ -743,6 +745,8 @@ fn bare(paragraph: &PageParagraph) -> PageParagraph {...@@ -743,6 +745,8 @@ fn bare(paragraph: &PageParagraph) -> PageParagraph {
743 stripped.media = Default::default();745 stripped.media = Default::default();
744 match &mut stripped.content {746 match &mut stripped.content {
745 ParagraphContent::Text(text) => text.tags.clear(),747 ParagraphContent::Text(text) => text.tags.clear(),
748 ParagraphContent::Image(image) => image.tags.clear(),
749 ParagraphContent::Attachment(file) => file.tags.clear(),
746 ParagraphContent::Table(table) => {750 ParagraphContent::Table(table) => {
747 table.tags.clear();751 table.tags.clear();
748 for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) {752 for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) {
...@@ -991,16 +995,18 @@ impl Lowering {...@@ -991,16 +995,18 @@ impl Lowering {
991 };995 };
992 let stored = pictures(old.page);996 let stored = pictures(old.page);
993 for (id, image) in pictures(new.page) {997 for (id, image) in pictures(new.page) {
994 if let Some(previous) = stored.get(&id)998 let Some(previous) = stored.get(&id) else {
995 && *previous != image999 continue;
996 {1000 };
997 super::content::picture_fixed_fields(previous, &image)?;1001 super::content::picture_fixed_fields(previous, &image)?;
1002 if (&previous.layout, &previous.alt) != (&image.layout, &image.alt) {
998 self.emit(PageOp::Picture {1003 self.emit(PageOp::Picture {
999 picture: id,1004 picture: id,
1000 layout: image.layout.clone(),1005 layout: image.layout.clone(),
1001 alt: image.alt.clone(),1006 alt: image.alt.clone(),
1002 })?;1007 })?;
1003 }1008 }
1009 self.set_tags(new.page, id, &image.tags, &previous.tags)?;
1004 }1010 }
1005 let files = |page: &Page| -> BTreeMap<ExGuid, crate::page::Attachment> {1011 let files = |page: &Page| -> BTreeMap<ExGuid, crate::page::Attachment> {
1006 page.objects1012 page.objects
...@@ -1049,6 +1055,7 @@ impl Lowering {...@@ -1049,6 +1055,7 @@ impl Lowering {
1049 size: file.size,1055 size: file.size,
1050 })?;1056 })?;
1051 }1057 }
1058 self.set_tags(new.page, id, &file.tags, &previous.tags)?;
1052 }1059 }
1053 let inks = |page: &Page| -> BTreeMap<ExGuid, crate::page::Ink> {1060 let inks = |page: &Page| -> BTreeMap<ExGuid, crate::page::Ink> {
1054 page.objects1061 page.objects
...@@ -1098,7 +1105,7 @@ impl Lowering {...@@ -1098,7 +1105,7 @@ impl Lowering {
1098 if new.title_outlines.contains(&id) {1105 if new.title_outlines.contains(&id) {
1099 return Err(invalid("Title outlines cannot be added"));1106 return Err(invalid("Title outlines cannot be added"));
1100 }1107 }
1101 let object = match object {1108 let added = match object {
1102 PageObject::Outline(outline) => {1109 PageObject::Outline(outline) => {
1103 if outline1110 if outline
1104 .paragraphs1111 .paragraphs
...@@ -1115,9 +1122,17 @@ impl Lowering {...@@ -1115,9 +1122,17 @@ impl Lowering {
1115 ..(*outline).clone()1122 ..(*outline).clone()
1116 })1123 })
1117 }1124 }
1118 PageObject::Image(_) | PageObject::Attachment(_) | PageObject::Ink(_) => {1125 PageObject::Image(image) => PageObject::Image(crate::page::Image {
1119 (*object).clone()1126 tags: Vec::new(),
1127 ..image.clone()
1128 }),
1129 PageObject::Attachment(file) => {
1130 PageObject::Attachment(crate::page::Attachment {
1131 tags: Vec::new(),
1132 ..file.clone()
1133 })
1120 }1134 }
1135 PageObject::Ink(_) => (*object).clone(),
1121 PageObject::Title(_) | PageObject::Unsupported(_) => {1136 PageObject::Title(_) | PageObject::Unsupported(_) => {
1122 return Err(invalid(1137 return Err(invalid(
1123 "Titles and unsupported objects cannot be edited through the page model",1138 "Titles and unsupported objects cannot be edited through the page model",
...@@ -1125,9 +1140,15 @@ impl Lowering {...@@ -1125,9 +1140,15 @@ impl Lowering {
1125 }1140 }
1126 };1141 };
1127 self.emit(PageOp::Add {1142 self.emit(PageOp::Add {
1128 object,1143 object: added,
1129 before: next,1144 before: next,
1130 })?;1145 })?;
1146 let tags = match object {
1147 PageObject::Image(image) => image.tags.as_slice(),
1148 PageObject::Attachment(file) => file.tags.as_slice(),
1149 _ => &[],
1150 };
1151 self.set_tags(new.page, id, tags, &[])?;
1131 } else if !kept.contains(&id) {1152 } else if !kept.contains(&id) {
1132 self.emit(PageOp::Move {1153 self.emit(PageOp::Move {
1133 object: id,1154 object: id,
...@@ -1694,11 +1715,14 @@ impl Lowering {...@@ -1694,11 +1715,14 @@ impl Lowering {
16941715
1695 fn styles(&mut self, after: &Page, new: &View<'_>) -> Result<(), Error> {1716 fn styles(&mut self, after: &Page, new: &View<'_>) -> Result<(), Error> {
1696 for (id, paragraph) in &new.paragraphs {1717 for (id, paragraph) in &new.paragraphs {
1718 let current = model::paragraph(&self.current, *id)
1719 .ok_or_else(|| invalid("A paragraph is missing after text edits"))?;
1697 let Some(style) = paragraph.style else {1720 let Some(style) = paragraph.style else {
1721 if current.style.is_some() {
1722 self.emit(PageOp::Unstyle { paragraph: *id })?;
1723 }
1698 continue;1724 continue;
1699 };1725 };
1700 let current = model::paragraph(&self.current, *id)
1701 .ok_or_else(|| invalid("A paragraph is missing after text edits"))?;
1702 if current.style == Some(style) {1726 if current.style == Some(style) {
1703 continue;1727 continue;
1704 }1728 }
...@@ -1771,33 +1795,50 @@ impl Lowering {...@@ -1771,33 +1795,50 @@ impl Lowering {
1771 (ParagraphContent::Table(table), ParagraphContent::Table(stored)) => {1795 (ParagraphContent::Table(table), ParagraphContent::Table(stored)) => {
1772 targets.push((table.id, &table.tags, stored.tags.clone()))1796 targets.push((table.id, &table.tags, stored.tags.clone()))
1773 }1797 }
1798 (ParagraphContent::Image(image), ParagraphContent::Image(stored)) => {
1799 targets.push((image.id, &image.tags, stored.tags.clone()))
1800 }
1801 (ParagraphContent::Attachment(file), ParagraphContent::Attachment(stored)) => {
1802 targets.push((file.id, &file.tags, stored.tags.clone()))
1803 }
1774 _ => {}1804 _ => {}
1775 }1805 }
1776 for (target, tags, stored) in targets {1806 for (target, tags, stored) in targets {
1777 if same_tags(tags, &stored) {1807 self.set_tags(after, target, tags, &stored)?;
1778 continue;
1779 }
1780 let mut definitions = Vec::new();
1781 for tag in tags {
1782 let Some(definition) = tag.definition else {
1783 continue;
1784 };
1785 if let Some(model) = after.definitions.get(&definition)
1786 && !definitions.iter().any(|(id, _)| *id == definition)
1787 {
1788 definitions.push((definition, model.clone()));
1789 }
1790 }
1791 self.emit(PageOp::Tags {
1792 target,
1793 tags: tags.clone(),
1794 definitions,
1795 })?;
1796 }1808 }
1797 }1809 }
1798 Ok(())1810 Ok(())
1799 }1811 }
18001812
1813 /// Emits the op giving `target` `tags` where it stores others.
1814 fn set_tags(
1815 &mut self,
1816 after: &Page,
1817 target: ExGuid,
1818 tags: &[Tag],
1819 stored: &[Tag],
1820 ) -> Result<(), Error> {
1821 if same_tags(tags, stored) {
1822 return Ok(());
1823 }
1824 let mut definitions = Vec::new();
1825 for tag in tags {
1826 let Some(definition) = tag.definition else {
1827 continue;
1828 };
1829 if let Some(model) = after.definitions.get(&definition)
1830 && !definitions.iter().any(|(id, _)| *id == definition)
1831 {
1832 definitions.push((definition, model.clone()));
1833 }
1834 }
1835 self.emit(PageOp::Tags {
1836 target,
1837 tags: tags.to_vec(),
1838 definitions,
1839 })
1840 }
1841
1801 fn media(&mut self, new: &View<'_>) -> Result<(), Error> {1842 fn media(&mut self, new: &View<'_>) -> Result<(), Error> {
1802 for (id, paragraph) in &new.paragraphs {1843 for (id, paragraph) in &new.paragraphs {
1803 let current = model::paragraph(&self.current, *id)1844 let current = model::paragraph(&self.current, *id)
crates/onestore/src/op/mod.rs+52
...@@ -151,6 +151,20 @@ pub enum PageOp {...@@ -151,6 +151,20 @@ pub enum PageOp {
151 style: ExGuid,151 style: ExGuid,
152 definition: Definition,152 definition: Definition,
153 },153 },
154 /// Takes a paragraph's style away; values its style gave are cleared from its text, as
155 /// `Restyle` clears them.
156 Unstyle {
157 paragraph: ExGuid,
158 },
159 /// Moves every paragraph of style `style` to style `into`, created from `definition`
160 /// (the same name) where the page lacks it: style objects are read-only, so a restyled
161 /// style is a new one. Run and paragraph values equal to what `style` gave are cleared,
162 /// so they follow `into`; values set otherwise stay.
163 Restyle {
164 style: ExGuid,
165 into: ExGuid,
166 definition: Definition,
167 },
154 /// Links a paragraph's text or file to a moment in recordings on the page, or with168 /// Links a paragraph's text or file to a moment in recordings on the page, or with
155 /// empty `media` unlinks it.169 /// empty `media` unlinks it.
156 Media {170 Media {
...@@ -270,6 +284,44 @@ pub enum SectionOp {...@@ -270,6 +284,44 @@ pub enum SectionOp {
270 },284 },
271}285}
272286
287/// The `Restyle` ops giving the paragraphs of every style of `page` named in `sheet`
288/// that definition instead, one per style that differs; styles sharing a name become one.
289pub fn restyle(
290 page: &Page,
291 sheet: &std::collections::BTreeMap<String, Definition>,
292) -> Result<Vec<PageOp>, crate::Error> {
293 let mut into = std::collections::BTreeMap::new();
294 let mut ops = Vec::new();
295 for (id, stored) in &page.definitions {
296 let crate::document::Kind::Style {
297 name: Some(name), ..
298 } = &stored.kind
299 else {
300 continue;
301 };
302 let Some(definition) = sheet.get(name).filter(|definition| *definition != stored) else {
303 continue;
304 };
305 let target = match into.get(name) {
306 Some(target) => *target,
307 None => {
308 let target = page
309 .definitions
310 .iter()
311 .find(|(_, kept)| *kept == definition)
312 .map_or_else(crate::page::text::new_id, |(id, _)| Ok(*id))?;
313 *into.entry(name.clone()).or_insert(target)
314 }
315 };
316 ops.push(PageOp::Restyle {
317 style: *id,
318 into: target,
319 definition: definition.clone(),
320 });
321 }
322 Ok(ops)
323}
324
273impl SectionOp {325impl SectionOp {
274 /// `RestoreVersion` of `page`'s `version`, its new objects under a fresh identity.326 /// `RestoreVersion` of `page`'s `version`, its new objects under a fresh identity.
275 pub fn restore(page: ExGuid, version: ExGuid) -> Result<Self, crate::Error> {327 pub fn restore(page: ExGuid, version: ExGuid) -> Result<Self, crate::Error> {
crates/onestore/src/op/model.rs+113-48
...@@ -614,11 +614,21 @@ pub fn apply(page: &mut Page, op: &PageOp) -> Result<(), Error> {...@@ -614,11 +614,21 @@ pub fn apply(page: &mut Page, op: &PageOp) -> Result<(), Error> {
614 let tags = paragraph.tags.iter().chain(match &paragraph.content {614 let tags = paragraph.tags.iter().chain(match &paragraph.content {
615 ParagraphContent::Text(text) => text.tags.as_slice(),615 ParagraphContent::Text(text) => text.tags.as_slice(),
616 ParagraphContent::Table(table) => table.tags.as_slice(),616 ParagraphContent::Table(table) => table.tags.as_slice(),
617 ParagraphContent::Image(image) => image.tags.as_slice(),
618 ParagraphContent::Attachment(file) => file.tags.as_slice(),
617 _ => &[],619 _ => &[],
618 });620 });
619 referenced.extend(tags.filter_map(|tag| tag.definition));621 referenced.extend(tags.filter_map(|tag| tag.definition));
620 }622 }
621 }623 }
624 for object in &page.objects {
625 let tags = match object {
626 PageObject::Image(image) => image.tags.as_slice(),
627 PageObject::Attachment(file) => file.tags.as_slice(),
628 _ => &[],
629 };
630 referenced.extend(tags.iter().filter_map(|tag| tag.definition));
631 }
622 page.definitions.retain(|id, _| referenced.contains(id));632 page.definitions.retain(|id, _| referenced.contains(id));
623 Ok(())633 Ok(())
624}634}
...@@ -1000,54 +1010,59 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> {...@@ -1000,54 +1010,59 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> {
1000 let paragraph = paragraph_mut(page, *paragraph)?;1010 let paragraph = paragraph_mut(page, *paragraph)?;
1001 paragraph.style = Some(style);1011 paragraph.style = Some(style);
1002 if let Some(text) = paragraph.text_mut() {1012 if let Some(text) = paragraph.text_mut() {
1003 let mut previous = 0;1013 text.text = restyled(&text.text, &old, &new);
1004 let runs: Vec<(String, Format)> = text1014 }
1005 .text1015 }
1006 .spans()1016 PageOp::Unstyle { paragraph: id } => {
1007 .iter()1017 let Some(style) = paragraph_mut(page, *id)?.style.take() else {
1008 .map(|span| {1018 return Ok(());
1009 // What the old style gave, the new one gives instead.1019 };
1010 let mut format = span.format.clone();1020 let old = page
1011 macro_rules! restyle {1021 .definitions
1012 ($($field:ident),*) => {$(1022 .get(&style)
1013 if old.$field.is_some() && format.$field == old.$field {1023 .ok_or_else(|| invalid("The page has no such paragraph style"))?
1014 format.$field = new.$field.clone();1024 .format
1015 } else if format.$field.is_none() {1025 .clone();
1016 format.$field = new.$field.clone();1026 if let Some(text) = paragraph_mut(page, *id)?.text_mut() {
1017 }1027 text.text = restyled(&text.text, &old, &Format::default());
1018 )*};1028 }
1029 }
1030 PageOp::Restyle {
1031 style,
1032 into,
1033 definition,
1034 } => {
1035 let old = page
1036 .definitions
1037 .get(style)
1038 .ok_or_else(|| invalid("The page has no such paragraph style"))?
1039 .format
1040 .clone();
1041 let mut definition = definition.clone();
1042 let format = &mut definition.format;
1043 for points in [
1044 &mut format.space_before,
1045 &mut format.space_after,
1046 &mut format.line_spacing,
1047 ]
1048 .into_iter()
1049 .flatten()
1050 {
1051 *points = stored(*points);
1052 }
1053 page.definitions
1054 .entry(*into)
1055 .or_insert_with(|| definition.clone());
1056 let paths: Vec<Path> = lists(page).into_iter().map(|(path, ..)| path).collect();
1057 for path in paths {
1058 for paragraph in list_at(page, &path) {
1059 if paragraph.style == Some(*style) {
1060 paragraph.style = Some(*into);
1061 if let Some(text) = paragraph.text_mut() {
1062 text.text = restyled(&text.text, &old, &definition.format);
1019 }1063 }
1020 restyle!(1064 }
1021 bold,1065 }
1022 italic,
1023 underline,
1024 strike,
1025 superscript,
1026 subscript,
1027 hidden,
1028 hyperlink,
1029 hyperlink_label,
1030 math,
1031 embedded_object,
1032 font,
1033 font_size,
1034 color,
1035 highlight,
1036 language,
1037 alignment,
1038 rtl,
1039 space_before,
1040 space_after,
1041 line_spacing,
1042 list_spacing,
1043 math_object
1044 );
1045 let run = (text.text.text()[previous..span.end].to_owned(), format);
1046 previous = span.end;
1047 run
1048 })
1049 .collect();
1050 text.text = Paragraph::from_runs(runs);
1051 }1066 }
1052 }1067 }
1053 PageOp::Media { paragraph, media } => {1068 PageOp::Media { paragraph, media } => {
...@@ -1088,8 +1103,12 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> {...@@ -1088,8 +1103,12 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> {
1088 paragraph.tags = tags;1103 paragraph.tags = tags;
1089 } else if let Ok(text) = text_mut(page, *target) {1104 } else if let Ok(text) = text_mut(page, *target) {
1090 text.tags = tags;1105 text.tags = tags;
1106 } else if let Ok(table) = table_mut(page, *target) {
1107 table.tags = tags;
1108 } else if let Ok(image) = image_mut(page, *target) {
1109 image.tags = tags;
1091 } else {1110 } else {
1092 table_mut(page, *target)?.tags = tags;1111 attachment_mut(page, *target)?.tags = tags;
1093 }1112 }
1094 }1113 }
1095 PageOp::Add { object, before } => {1114 PageOp::Add { object, before } => {
...@@ -1271,6 +1290,52 @@ fn drop_emptied(page: &mut Page) {...@@ -1271,6 +1290,52 @@ fn drop_emptied(page: &mut Page) {
12711290
1272/// The identity a read-only definition is stored under: an equal one the page holds, as1291/// The identity a read-only definition is stored under: an equal one the page holds, as
1273/// the writers share identical read-only objects, else `id`.1292/// the writers share identical read-only objects, else `id`.
1293/// `text` moved from paragraph style `old` to `new`: what the old style gave, the new one
1294/// gives instead.
1295fn restyled(text: &Paragraph, old: &Format, new: &Format) -> Paragraph {
1296 let mut previous = 0;
1297 Paragraph::from_runs(text.spans().iter().map(|span| {
1298 let mut format = span.format.clone();
1299 macro_rules! restyle {
1300 ($($field:ident),*) => {$(
1301 if old.$field.is_some() && format.$field == old.$field {
1302 format.$field = new.$field.clone();
1303 } else if format.$field.is_none() {
1304 format.$field = new.$field.clone();
1305 }
1306 )*};
1307 }
1308 restyle!(
1309 bold,
1310 italic,
1311 underline,
1312 strike,
1313 superscript,
1314 subscript,
1315 hidden,
1316 hyperlink,
1317 hyperlink_label,
1318 math,
1319 embedded_object,
1320 font,
1321 font_size,
1322 color,
1323 highlight,
1324 language,
1325 alignment,
1326 rtl,
1327 space_before,
1328 space_after,
1329 line_spacing,
1330 list_spacing,
1331 math_object
1332 );
1333 let run = (text.text()[previous..span.end].to_owned(), format);
1334 previous = span.end;
1335 run
1336 }))
1337}
1338
1274fn define(page: &mut Page, id: ExGuid, definition: &crate::page::Definition) -> ExGuid {1339fn define(page: &mut Page, id: ExGuid, definition: &crate::page::Definition) -> ExGuid {
1275 if page.definitions.contains_key(&id) {1340 if page.definitions.contains_key(&id) {
1276 return id;1341 return id;
crates/onestore/src/op/properties.rs+166-21
...@@ -2,7 +2,7 @@...@@ -2,7 +2,7 @@
22
3use super::Values;3use super::Values;
4use crate::{4use crate::{
5 Error, ExGuid, PropertySets, Value,5 Error, ExGuid, ObjectData, PropertySets, Value,
6 active::{ActivePage, Changes},6 active::{ActivePage, Changes},
7 document::{Kind, Tag},7 document::{Kind, Tag},
8 page::{Definition, MediaIndex},8 page::{Definition, MediaIndex},
...@@ -293,26 +293,7 @@ pub(crate) fn style_changes(...@@ -293,26 +293,7 @@ pub(crate) fn style_changes(
293 if !raw.objects.contains_key(&style) {293 if !raw.objects.contains_key(&style) {
294 let definition = definition294 let definition = definition
295 .ok_or_else(|| invalid("A paragraph references a missing style definition"))?;295 .ok_or_else(|| invalid("A paragraph references a missing style definition"))?;
296 let Kind::Style { name } = &definition.kind else {296 let values = style_object_values(definition)?;
297 return Err(invalid("A paragraph style must be a style definition"));
298 };
299 let format = &definition.format;
300 let mut values = super::content::style_values(format);
301 if let Some(name) = name {
302 values.push((0x1c00345a, crate::create::string(name)));
303 }
304 if let Some(alignment) = format.alignment {
305 values.push((0x0c003477, vec![alignment]));
306 }
307 for (property, value) in [
308 (0x1400342e, format.space_before),
309 (0x1400342f, format.space_after),
310 (0x14003430, format.line_spacing),
311 ] {
312 if let Some(points) = value {
313 values.push((property, (points / 36.0).to_le_bytes().to_vec()));
314 }
315 }
316 let mut node = PropertyObject {297 let mut node = PropertyObject {
317 jcid: 0x12004d,298 jcid: 0x12004d,
318 bytes: crate::create::properties(&values)?,299 bytes: crate::create::properties(&values)?,
...@@ -328,6 +309,170 @@ pub(crate) fn style_changes(...@@ -328,6 +309,170 @@ pub(crate) fn style_changes(
328 Ok(changed)309 Ok(changed)
329}310}
330311
312/// Run properties to clear, by text and run range.
313pub(crate) type Clears = Vec<(ExGuid, std::ops::Range<u32>, Vec<super::TextProperty>)>;
314/// Paragraph properties to remove, by text.
315pub(crate) type Spacing = Vec<(ExGuid, Vec<u32>)>;
316
317/// Style-level values the texts of paragraph style `style` (or only text `only`) repeat:
318/// run properties equal to the style's, by run range, and each text's paragraph properties
319/// equal to the style's. Texts whose runs carry objects of their own stay as they are.
320pub(crate) fn restyle_plan(
321 active: &ActivePage<'_>,
322 style: ExGuid,
323 only: Option<ExGuid>,
324) -> Result<(Clears, Spacing), Error> {
325 use super::TextProperty::*;
326 const CHARACTER: [super::TextProperty; 10] = [
327 Bold,
328 Italic,
329 Underline,
330 Strike,
331 Superscript,
332 Subscript,
333 Font,
334 FontSize,
335 Color,
336 Highlight,
337 ];
338 const PARAGRAPH: [u32; 4] = [0x0c003477, 0x1400342e, 0x1400342f, 0x14003430];
339 let raw = &active.live.revision;
340 let values = |id: ExGuid| -> Result<BTreeMap<u32, (u32, Vec<u8>)>, Error> {
341 let ObjectData::Properties(bytes) = raw.objects[&id].data else {
342 return Err(invalid("A style holds no properties"));
343 };
344 Ok(PropertySets::parse(bytes)?.sets[0]
345 .iter()
346 .map(|property| {
347 let value = match property.value {
348 Value::Bytes(bytes) => bytes.to_vec(),
349 _ => Vec::new(),
350 };
351 (property.id & 0x7fffffff, (property.id, value))
352 })
353 .collect())
354 };
355 let stored = values(style)?;
356 let repeats = |own: &BTreeMap<u32, (u32, Vec<u8>)>, key: u32| {
357 own.get(&key)
358 .is_some_and(|value| stored.get(&key) == Some(value))
359 };
360 let mut clears = Vec::new();
361 let mut spacing = Vec::new();
362 for (&text, node) in &active.view.nodes {
363 let Kind::RichText {
364 text: characters,
365 runs,
366 paragraph_style: Some(paragraph_style),
367 boilerplate: false,
368 } = &node.kind
369 else {
370 continue;
371 };
372 if *paragraph_style != style || only.is_some_and(|only| only != text) {
373 continue;
374 }
375 let own = values(text)?;
376 if own.contains_key(&0x24003458) {
377 continue;
378 }
379 let repeated: Vec<u32> = PARAGRAPH
380 .into_iter()
381 .filter(|key| repeats(&own, *key))
382 .collect();
383 if !repeated.is_empty() {
384 spacing.push((text, repeated));
385 }
386 let mut pending: Option<(std::ops::Range<u32>, Vec<super::TextProperty>)> = None;
387 for run in runs {
388 let cleared: Vec<super::TextProperty> = match run.format {
389 Some(id) => {
390 let own = values(id)?;
391 CHARACTER
392 .into_iter()
393 .filter(|property| repeats(&own, property.id()))
394 .collect()
395 }
396 None => Vec::new(),
397 };
398 let range = if characters.is_empty() {
399 0..0
400 } else {
401 run.start..run.end
402 };
403 match &mut pending {
404 Some((open, kept)) if *kept == cleared && open.end == range.start => {
405 open.end = range.end;
406 }
407 _ => {
408 if let Some((range, clear)) = pending.take() {
409 clears.push((text, range, clear));
410 }
411 pending = Some((range, cleared));
412 }
413 }
414 }
415 clears.extend(pending.map(|(range, clear)| (text, range, clear)));
416 }
417 clears.retain(|(_, _, clear)| !clear.is_empty());
418 Ok((clears, spacing))
419}
420
421/// The texts of paragraph style `style` moved to style `into`, created from `definition`
422/// where the page lacks it, each without its `spacing` properties.
423pub(crate) fn restyle_changes(
424 active: &ActivePage<'_>,
425 style: ExGuid,
426 into: ExGuid,
427 definition: &Definition,
428 spacing: &[(ExGuid, Vec<u32>)],
429) -> Result<Changes, Error> {
430 let mut changed = BTreeMap::new();
431 for (&text, node) in &active.view.nodes {
432 if !matches!(node.kind, Kind::RichText { paragraph_style: Some(id), .. } if id == style) {
433 continue;
434 }
435 let mut moved = style_changes(active, text, into, Some(definition))?;
436 let mut target = moved.remove(&text).expect("the text is restyled");
437 if let Some((_, ids)) = spacing.iter().find(|(id, _)| *id == text) {
438 target.remove(ids)?;
439 }
440 changed.insert(text, target);
441 changed.extend(moved);
442 }
443 Ok(changed)
444}
445
446/// The properties of a paragraph style object holding `definition`: NextStyle first, as
447/// OneNote 2010 writes it, then the character formatting, the name and paragraph spacing.
448pub(crate) fn style_object_values(definition: &Definition) -> Result<Values, Error> {
449 let Kind::Style { name, next } = &definition.kind else {
450 return Err(invalid("A paragraph style must be a style definition"));
451 };
452 let format = &definition.format;
453 let mut values = Values::new();
454 if let Some(next) = next {
455 values.push((0x1c00348a, crate::create::string(next)));
456 }
457 values.extend(super::content::style_values(format));
458 if let Some(name) = name {
459 values.push((0x1c00345a, crate::create::string(name)));
460 }
461 if let Some(alignment) = format.alignment {
462 values.push((0x0c003477, vec![alignment]));
463 }
464 for (property, value) in [
465 (0x1400342e, format.space_before),
466 (0x1400342f, format.space_after),
467 (0x14003430, format.line_spacing),
468 ] {
469 if let Some(points) = value {
470 values.push((property, (points / 36.0).to_le_bytes().to_vec()));
471 }
472 }
473 Ok(values)
474}
475
331/// The text-object properties of paragraph formatting, in the order they are written.476/// The text-object properties of paragraph formatting, in the order they are written.
332pub(crate) fn paragraph_values(477pub(crate) fn paragraph_values(
333 alignment: Option<u8>,478 alignment: Option<u8>,
crates/onestore/src/op/tests.rs+199-1
...@@ -631,7 +631,7 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {...@@ -631,7 +631,7 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {
631 };631 };
632 let style = (family == Family::Style).then(|| {632 let style = (family == Family::Style).then(|| {
633 let existing = page.definitions.iter().find_map(|(id, d)| {633 let existing = page.definitions.iter().find_map(|(id, d)| {
634 matches!(&d.kind, Kind::Style { name: Some(name) } if name == "Heading 2")634 matches!(&d.kind, Kind::Style { name: Some(name), .. } if name == "Heading 2")
635 .then_some(*id)635 .then_some(*id)
636 });636 });
637 existing.unwrap_or_else(|| {637 existing.unwrap_or_else(|| {
...@@ -641,6 +641,7 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {...@@ -641,6 +641,7 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {
641 Definition {641 Definition {
642 kind: Kind::Style {642 kind: Kind::Style {
643 name: Some("Heading 2".into()),643 name: Some("Heading 2".into()),
644 next: None,
644 },645 },
645 format: Format {646 format: Format {
646 font: Some("Calibri".into()),647 font: Some("Calibri".into()),
...@@ -1103,6 +1104,9 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {...@@ -1103,6 +1104,9 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {
1103 alt: Some("dot".into()),1104 alt: Some("dot".into()),
1104 background: false,1105 background: false,
1105 printout: None,1106 printout: None,
1107 tags: Vec::new(),
1108 link: None,
1109 text: None,
1106 }),1110 }),
1107 Family::Attachment => ParagraphContent::Attachment(Attachment {1111 Family::Attachment => ParagraphContent::Attachment(Attachment {
1108 id: id(),1112 id: id(),
...@@ -1113,6 +1117,7 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {...@@ -1113,6 +1117,7 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {
1113 bytes: Some(b"attached bytes".as_slice().into()),1117 bytes: Some(b"attached bytes".as_slice().into()),
1114 preview: None,1118 preview: None,
1115 recording: None,1119 recording: None,
1120 tags: Vec::new(),
1116 }),1121 }),
1117 _ => {1122 _ => {
1118 let Some(equation) = equation else {1123 let Some(equation) = equation else {
...@@ -1158,6 +1163,9 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {...@@ -1158,6 +1163,9 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {
1158 alt: None,1163 alt: None,
1159 background: false,1164 background: false,
1160 printout: None,1165 printout: None,
1166 tags: Vec::new(),
1167 link: None,
1168 text: None,
1161 })1169 })
1162 } else {1170 } else {
1163 PageObject::Ink(Ink {1171 PageObject::Ink(Ink {
...@@ -1816,6 +1824,7 @@ fn random_object_op(page: &Page, rng: &mut Rng) -> Option<PageOp> {...@@ -1816,6 +1824,7 @@ fn random_object_op(page: &Page, rng: &mut Rng) -> Option<PageOp> {
1816 definition: Definition {1824 definition: Definition {
1817 kind: Kind::Style {1825 kind: Kind::Style {
1818 name: Some("Heading 3".into()),1826 name: Some("Heading 3".into()),
1827 next: None,
1819 },1828 },
1820 format: Format {1829 format: Format {
1821 bold: Some(true),1830 bold: Some(true),
...@@ -3122,3 +3131,192 @@ fn deleted_objects_added_back_store_as_they_were() {...@@ -3122,3 +3131,192 @@ fn deleted_objects_added_back_store_as_they_were() {
3122 assert_eq!(shape(&mut section, object), stored, "{title}");3131 assert_eq!(shape(&mut section, object), stored, "{title}");
3123 }3132 }
3124}3133}
3134
3135/// A style definition named `name` in random formatting, as a theme gives one.
3136fn random_style(name: &str, rng: &mut Rng) -> Definition {
3137 let fonts = ["Georgia", "Arial", "Calibri", "Courier New"];
3138 Definition {
3139 kind: Kind::Style {
3140 name: Some(name.into()),
3141 next: rng.coin().then(|| "p".into()),
3142 },
3143 format: Format {
3144 bold: Some(rng.coin()),
3145 italic: Some(rng.coin()),
3146 underline: Some(false),
3147 strike: Some(false),
3148 superscript: Some(false),
3149 subscript: Some(false),
3150 font: Some(fonts[rng.next() as usize % fonts.len()].into()),
3151 font_size: Some(8.0 + (rng.next() % 30) as f32 / 2.0),
3152 color: Some(if rng.coin() {
3153 0xff00_0000
3154 } else {
3155 rng.next() as u32 & 0xff_ffff
3156 }),
3157 highlight: Some(0xff00_0000),
3158 space_before: Some((rng.next() % 4) as f32 * 3.0),
3159 space_after: Some((rng.next() % 3) as f32 * 2.0),
3160 line_spacing: Some(0.0),
3161 ..Default::default()
3162 },
3163 }
3164}
3165
3166/// Restyling a page's paragraph styles, after runs were given values their style already
3167/// gives, reads back as the model predicts; reopened, the section holds the same pages, and
3168/// a second restyle to the same sheet has nothing left to do.
3169#[test]
3170fn restyles_read_back_as_the_model_predicts() {
3171 let sources = SOURCES.iter().copied().chain([(
3172 "styles",
3173 &include_bytes!("../../../../corpus/styles/onenote/Styles.one")[..],
3174 )]);
3175 let mut restyled = 0;
3176 for (name, source) in sources {
3177 for space in pages(source) {
3178 let arena = Arena::default();
3179 let mut section = Section::open(&arena, source.to_vec()).unwrap();
3180 let mut rng = Rng(space.n as u64 * 7919 + name.len() as u64);
3181 let before = section.page(space).unwrap();
3182 // Runs stating their style's font and size, which the restyle clears.
3183 let mut ops = Vec::new();
3184 for (_, _, list) in model::lists(&before) {
3185 for paragraph in list {
3186 let (Some(style), Some(text)) = (paragraph.style, paragraph.text()) else {
3187 continue;
3188 };
3189 let format = &before.definitions[&style].format;
3190 let length = text.text.utf16_offset(text.text.text().len()).unwrap();
3191 if length == 0 || text.date_field.is_some() || !rng.coin() {
3192 continue;
3193 }
3194 let set: Vec<TextAttribute> = [
3195 format.font.clone().map(TextAttribute::Font),
3196 format.font_size.map(TextAttribute::FontSize),
3197 ]
3198 .into_iter()
3199 .flatten()
3200 .collect();
3201 if !set.is_empty() && !crate::page::Math::is_equation(&text.text) {
3202 ops.push(PageOp::Format {
3203 text: text.id,
3204 range: 0..length,
3205 set,
3206 clear: Vec::new(),
3207 });
3208 }
3209 }
3210 }
3211 let mut stated = before.clone();
3212 for op in ops.clone() {
3213 if section
3214 .apply(
3215 "Author",
3216 &Edit {
3217 at: AT,
3218 ops: vec![Op::Page {
3219 space,
3220 op: op.clone(),
3221 }],
3222 },
3223 )
3224 .is_ok()
3225 {
3226 model::apply(&mut stated, &op).unwrap();
3227 }
3228 }
3229 let names: BTreeSet<String> = stated
3230 .definitions
3231 .values()
3232 .filter_map(|definition| match &definition.kind {
3233 Kind::Style { name, .. } => name.clone(),
3234 _ => None,
3235 })
3236 .collect();
3237 let sheet: BTreeMap<String, Definition> = names
3238 .iter()
3239 .map(|style| (style.clone(), random_style(style, &mut rng)))
3240 .collect();
3241 let ops = restyle(&stated, &sheet).unwrap();
3242 if ops.is_empty() {
3243 continue;
3244 }
3245 restyled += ops.len();
3246 let edit = Edit {
3247 at: AT + 1,
3248 ops: ops
3249 .iter()
3250 .map(|op| Op::Page {
3251 space,
3252 op: op.clone(),
3253 })
3254 .collect(),
3255 };
3256 section
3257 .apply("Author", &edit)
3258 .unwrap_or_else(|error| panic!("{name} {space:?}: {error:?}"));
3259 let mut predicted = section.page(space).unwrap();
3260 let stored = predicted.clone();
3261 predicted.clone_from(&stated);
3262 for op in &ops {
3263 model::apply(&mut predicted, op).unwrap();
3264 }
3265 assert!(
3266 normalize(&predicted) == normalize(&stored),
3267 "{name} {space:?}: {}",
3268 first_difference(&normalize(&stored), &normalize(&predicted))
3269 );
3270 assert!(
3271 restyle(&stored, &sheet).unwrap().is_empty(),
3272 "{name}: restyled twice"
3273 );
3274 section.seal().unwrap();
3275 let reopened = Section::open(&arena, section.image()).unwrap();
3276 assert!(reopened.page(space).unwrap() == stored, "{name}: reopened");
3277 }
3278 }
3279 assert!(restyled > 20, "{restyled} styles restyled");
3280}
3281
3282/// A restyle keeps the style's name and needs the page to hold the style.
3283#[test]
3284fn restyle_refuses_renames_and_missing_styles() {
3285 let source = include_bytes!("../../../../corpus/styles/onenote/Styles.one");
3286 let arena = Arena::default();
3287 let mut section = Section::open(&arena, source.to_vec()).unwrap();
3288 let space = pages(source)[0];
3289 let page = section.page(space).unwrap();
3290 let (&h1, stored) = page
3291 .definitions
3292 .iter()
3293 .find(|(_, d)| matches!(&d.kind, Kind::Style { name: Some(name), .. } if name == "h1"))
3294 .unwrap();
3295 assert_eq!(
3296 stored.kind,
3297 Kind::Style {
3298 name: Some("h1".into()),
3299 next: Some("p".into()),
3300 }
3301 );
3302 let mut renamed = stored.clone();
3303 renamed.kind = Kind::Style {
3304 name: Some("h2".into()),
3305 next: None,
3306 };
3307 for (style, definition) in [(h1, renamed), (id(), stored.clone())] {
3308 let edit = Edit {
3309 at: AT,
3310 ops: vec![Op::Page {
3311 space,
3312 op: PageOp::Restyle {
3313 style,
3314 into: id(),
3315 definition,
3316 },
3317 }],
3318 };
3319 assert!(section.apply("Author", &edit).is_err());
3320 assert!(section.page(space).unwrap() == page);
3321 }
3322}
crates/onestore/src/page/mod.rs+167-63
...@@ -285,6 +285,10 @@ pub struct Attachment {...@@ -285,6 +285,10 @@ pub struct Attachment {
285 /// Set when the file is audio or video OneNote plays; notes on the page refer to it by285 /// Set when the file is audio or video OneNote plays; notes on the page refer to it by
286 /// identity (`PageParagraph::media`).286 /// identity (`PageParagraph::media`).
287 pub recording: Option<Recording>,287 pub recording: Option<Recording>,
288 /// Note tags on the file itself, where OneNote stores a tagged file's, in a paragraph or
289 /// on the page.
290 #[serde(default)]
291 pub tags: Vec<Tag>,
288}292}
289293
290/// A recording's identity, whether it is audio (1) or video (2), and its length.294/// A recording's identity, whether it is audio (1) or video (2), and its length.
...@@ -324,6 +328,7 @@ impl PartialEq for Attachment {...@@ -324,6 +328,7 @@ impl PartialEq for Attachment {
324 && self.size == other.size328 && self.size == other.size
325 && self.layout == other.layout329 && self.layout == other.layout
326 && self.recording == other.recording330 && self.recording == other.recording
331 && self.tags == other.tags
327 }332 }
328}333}
329334
...@@ -376,6 +381,7 @@ impl Attachment {...@@ -376,6 +381,7 @@ impl Attachment {
376 .unwrap_or(1),381 .unwrap_or(1),
377 duration_ms: *recording_duration,382 duration_ms: *recording_duration,
378 }),383 }),
384 tags: node.tags.clone(),
379 })385 })
380 }386 }
381}387}
...@@ -435,12 +441,52 @@ pub struct Image {...@@ -435,12 +441,52 @@ pub struct Image {
435 pub display: Option<Arc<[u8]>>,441 pub display: Option<Arc<[u8]>>,
436 pub alt: Option<String>,442 pub alt: Option<String>,
437 pub background: bool,443 pub background: bool,
438 /// The page of a file printout the picture shows, zero-based (DisplayedPageNumber);444 /// Set when the picture is a page of a file printout, which OneNote frames.
439 /// OneNote frames printout pages.445 #[serde(default)]
446 pub printout: Option<Printout>,
447 /// Note tags on the picture itself, where OneNote stores a tagged picture's, in a
448 /// paragraph or on the page.
449 #[serde(default)]
450 pub tags: Vec<Tag>,
451 /// The address the picture links to (WzHyperlinkUrl).
452 #[serde(default)]
453 pub link: Option<String>,
454 /// Text OneNote recognised in the picture, or a printout page's text; OneNote searches
455 /// it.
440 #[serde(default)]456 #[serde(default)]
441 pub printout: Option<u32>,457 pub text: Option<Recognized>,
442}458}
443459
460/// Text a picture holds (RichEditTextUnicode).
461#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
462pub struct Recognized {
463 pub text: String,
464 /// Its language (RichEditTextLangID) and the undocumented layout of its words,
465 /// `RECOGNIZED_PROPERTIES`, written back as read.
466 pub stored: Vec<(u32, Vec<u8>)>,
467}
468
469/// RichEditTextLangID, and where OneNote 2010 found each word of a picture's text
470/// (`corpus/object-tags`), not in MS-ONE.
471pub(crate) const RECOGNIZED_PROPERTIES: [u32; 2] = [0x10001cfe, 0x1c001d61];
472
473/// A page of a file printout.
474#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
475pub struct Printout {
476 /// The page shown, zero-based (DisplayedPageNumber).
477 pub page: u32,
478 /// The undocumented properties tying the page to its XPS package, `PRINTOUT_PROPERTIES`,
479 /// written back as read; without them OneNote does not draw a page it reads back.
480 pub stored: Vec<(u32, Vec<u8>)>,
481}
482
483/// The printout's identity, its file's index and the page's place in it, as OneNote 2010
484/// stores them on a printout page (`corpus/printout`), not in MS-ONE; with the picture's
485/// file name (ImageFilename) and the package's description and hash, also undocumented.
486pub(crate) const PRINTOUT_PROPERTIES: [u32; 6] = [
487 0x1c001d84, 0x14003480, 0x14003481, 0x1c001dd7, 0x1c001dfb, 0x1c001dfc,
488];
489
444impl PartialEq for Image {490impl PartialEq for Image {
445 fn eq(&self, other: &Self) -> bool {491 fn eq(&self, other: &Self) -> bool {
446 self.id == other.id492 self.id == other.id
...@@ -449,6 +495,9 @@ impl PartialEq for Image {...@@ -449,6 +495,9 @@ impl PartialEq for Image {
449 && self.alt == other.alt495 && self.alt == other.alt
450 && self.background == other.background496 && self.background == other.background
451 && self.printout == other.printout497 && self.printout == other.printout
498 && self.tags == other.tags
499 && self.link == other.link
500 && self.text == other.text
452 }501 }
453}502}
454503
...@@ -466,6 +515,8 @@ impl Image {...@@ -466,6 +515,8 @@ impl Image {
466 printout,515 printout,
467 picture_width,516 picture_width,
468 picture_height,517 picture_height,
518 link,
519 filename,
469 ..520 ..
470 } = &node.kind521 } = &node.kind
471 else {522 else {
...@@ -498,8 +549,8 @@ impl Image {...@@ -498,8 +549,8 @@ impl Image {
498 display: payload(web)?,549 display: payload(web)?,
499 alt: alt.clone(),550 alt: alt.clone(),
500 background: background.unwrap_or(false),551 background: background.unwrap_or(false),
501 printout: printout.unwrap_or(false).then(|| {552 printout: printout.unwrap_or(false).then(|| Printout {
502 fields553 page: fields
503 .iter()554 .iter()
504 .find_map(|field| match field.value {555 .find_map(|field| match field.value {
505 FieldValue::Bytes(&[a, b, c, d]) if field.id == 0x14001df9 => {556 FieldValue::Bytes(&[a, b, c, d]) if field.id == 0x14001df9 => {
...@@ -507,8 +558,50 @@ impl Image {...@@ -507,8 +558,50 @@ impl Image {
507 }558 }
508 _ => None,559 _ => None,
509 })560 })
510 .unwrap_or(0)561 .unwrap_or(0),
562 stored: filename
563 .iter()
564 .map(|name| (0x1c001dd7, crate::create::string(name)))
565 .chain(fields.iter().filter_map(|field| match field.value {
566 FieldValue::Bytes(bytes) if PRINTOUT_PROPERTIES.contains(&field.id) => {
567 Some((field.id, bytes.to_vec()))
568 }
569 _ => None,
570 }))
571 .collect(),
511 }),572 }),
573 tags: node.tags.clone(),
574 link: link.clone(),
575 text: fields
576 .iter()
577 .find_map(|field| match field.value {
578 FieldValue::Bytes(bytes) if field.id == 0x1c001c22 => Some(bytes),
579 _ => None,
580 })
581 .map(|bytes| {
582 let units: Vec<u16> = bytes
583 .chunks_exact(2)
584 .map(|pair| u16::from_le_bytes([pair[0], pair[1]]))
585 .collect();
586 String::from_utf16_lossy(&units)
587 .trim_end_matches('\0')
588 .to_owned()
589 })
590 .filter(|text| !text.trim().is_empty())
591 .map(|text| Recognized {
592 text,
593 stored: fields
594 .iter()
595 .filter_map(|field| match field.value {
596 FieldValue::Bytes(bytes)
597 if RECOGNIZED_PROPERTIES.contains(&field.id) =>
598 {
599 Some((field.id, bytes.to_vec()))
600 }
601 _ => None,
602 })
603 .collect(),
604 }),
512 })605 })
513 }606 }
514}607}
...@@ -870,14 +963,14 @@ impl Page {...@@ -870,14 +963,14 @@ impl Page {
870 page.objects.push(PageObject::Outline(outline));963 page.objects.push(PageObject::Outline(outline));
871 }964 }
872 }965 }
873 Kind::Image { .. } => {966 Kind::Image { .. } | Kind::Attachment { .. } => {
874 page.objects967 for tag in node.tags.iter().filter_map(|tag| tag.definition) {
875 .push(PageObject::Image(Image::read(revision, id, node)?));968 define(revision, tag, false, &mut page.definitions)?;
876 }969 }
877 Kind::Attachment { .. } => {970 page.objects.push(match node.kind {
878 page.objects.push(PageObject::Attachment(Attachment::read(971 Kind::Image { .. } => PageObject::Image(Image::read(revision, id, node)?),
879 revision, id, node,972 _ => PageObject::Attachment(Attachment::read(revision, id, node)?),
880 )?));973 });
881 }974 }
882 Kind::Ink { .. } => {975 Kind::Ink { .. } => {
883 page.objects976 page.objects
...@@ -990,6 +1083,55 @@ pub(crate) fn text_of(...@@ -990,6 +1083,55 @@ pub(crate) fn text_of(
990 })1083 })
991}1084}
9921085
1086/// Adds list or tag definition `id` to `definitions`.
1087fn define(
1088 revision: &Revision<'_>,
1089 id: ExGuid,
1090 is_list: bool,
1091 definitions: &mut BTreeMap<ExGuid, Definition>,
1092) -> Result<(), Error> {
1093 let invalid = |message| Error { offset: 0, message };
1094 let definition = revision
1095 .nodes
1096 .get(&id)
1097 .ok_or_else(|| invalid("Missing canvas list or tag definition"))?;
1098 let kind = match &definition.kind {
1099 Kind::List {
1100 font,
1101 format,
1102 restart,
1103 bullet,
1104 } if is_list => Kind::List {
1105 font: font.clone(),
1106 format: format.clone(),
1107 restart: *restart,
1108 bullet: *bullet,
1109 },
1110 Kind::TagDefinition {
1111 label,
1112 action_type,
1113 shape,
1114 color,
1115 highlight,
1116 } if !is_list => Kind::TagDefinition {
1117 label: label.clone(),
1118 action_type: *action_type,
1119 shape: *shape,
1120 color: *color,
1121 highlight: *highlight,
1122 },
1123 _ => return Err(invalid("Canvas list or tag definition has the wrong type")),
1124 };
1125 definitions.insert(
1126 id,
1127 Definition {
1128 kind,
1129 format: definition.format.clone(),
1130 },
1131 );
1132 Ok(())
1133}
1134
993fn read_paragraphs(1135fn read_paragraphs(
994 revision: &Revision<'_>,1136 revision: &Revision<'_>,
995 container: &Element<'_>,1137 container: &Element<'_>,
...@@ -1137,67 +1279,28 @@ fn read_paragraphs(...@@ -1137,67 +1279,28 @@ fn read_paragraphs(
1137 .chain(match &content {1279 .chain(match &content {
1138 ParagraphContent::Text(text) => text.tags.as_slice(),1280 ParagraphContent::Text(text) => text.tags.as_slice(),
1139 ParagraphContent::Table(table) => table.tags.as_slice(),1281 ParagraphContent::Table(table) => table.tags.as_slice(),
1140 ParagraphContent::Image(_)1282 ParagraphContent::Image(image) => image.tags.as_slice(),
1141 | ParagraphContent::Attachment(_)1283 ParagraphContent::Attachment(file) => file.tags.as_slice(),
1142 | ParagraphContent::Ink(_)1284 ParagraphContent::Ink(_) | ParagraphContent::Unsupported(_) => &[],
1143 | ParagraphContent::Unsupported(_) => &[],
1144 })1285 })
1145 .filter_map(|tag| tag.definition.as_ref())1286 .filter_map(|tag| tag.definition.as_ref())
1146 .map(|id| (id, false)),1287 .map(|id| (id, false)),
1147 ) {1288 ) {
1148 let definition = revision1289 define(revision, *id, is_list, definitions)?;
1149 .nodes
1150 .get(id)
1151 .ok_or_else(|| invalid("Missing canvas list or tag definition"))?;
1152 let kind = match &definition.kind {
1153 Kind::List {
1154 font,
1155 format,
1156 restart,
1157 bullet,
1158 } if is_list => Kind::List {
1159 font: font.clone(),
1160 format: format.clone(),
1161 restart: *restart,
1162 bullet: *bullet,
1163 },
1164 Kind::TagDefinition {
1165 label,
1166 action_type,
1167 shape,
1168 color,
1169 highlight,
1170 } if !is_list => Kind::TagDefinition {
1171 label: label.clone(),
1172 action_type: *action_type,
1173 shape: *shape,
1174 color: *color,
1175 highlight: *highlight,
1176 },
1177 _ => {
1178 return Err(invalid(
1179 "Canvas list or tag definition has the wrong type",
1180 ));
1181 }
1182 };
1183 definitions.insert(
1184 *id,
1185 Definition {
1186 kind,
1187 format: definition.format.clone(),
1188 },
1189 );
1190 }1290 }
1191 if let Some(id) = base_style {1291 if let Some(id) = base_style {
1192 let definition = revision1292 let definition = revision
1193 .nodes1293 .nodes
1194 .get(&id)1294 .get(&id)
1195 .ok_or_else(|| invalid("Missing canvas paragraph style"))?;1295 .ok_or_else(|| invalid("Missing canvas paragraph style"))?;
1196 let Kind::Style { name } = &definition.kind else {1296 let Kind::Style { name, next } = &definition.kind else {
1197 return Err(invalid("Canvas paragraph style has the wrong type"));1297 return Err(invalid("Canvas paragraph style has the wrong type"));
1198 };1298 };
1199 definitions.entry(id).or_insert_with(|| Definition {1299 definitions.entry(id).or_insert_with(|| Definition {
1200 kind: Kind::Style { name: name.clone() },1300 kind: Kind::Style {
1301 name: name.clone(),
1302 next: next.clone(),
1303 },
1201 format: definition.format.clone(),1304 format: definition.format.clone(),
1202 });1305 });
1203 }1306 }
...@@ -1445,6 +1548,7 @@ mod tests {...@@ -1445,6 +1548,7 @@ mod tests {
1445 });1548 });
1446 let mut style = element(Kind::Style {1549 let mut style = element(Kind::Style {
1447 name: Some("Body".into()),1550 name: Some("Body".into()),
1551 next: None,
1448 });1552 });
1449 style.format.font_size = Some(12.0);1553 style.format.font_size = Some(12.0);
1450 let mut metadata = element(Kind::Metadata {1554 let mut metadata = element(Kind::Metadata {
...@@ -1705,7 +1809,7 @@ mod tests {...@@ -1705,7 +1809,7 @@ mod tests {
1705 assert_eq!(paragraph.parent, None);1809 assert_eq!(paragraph.parent, None);
1706 assert_eq!(paragraph.style, Some(id(5)));1810 assert_eq!(paragraph.style, Some(id(5)));
1707 assert!(matches!(&page.definitions[&id(5)].kind,1811 assert!(matches!(&page.definitions[&id(5)].kind,
1708 Kind::Style { name: Some(name) } if name == "Body"));1812 Kind::Style { name: Some(name), .. } if name == "Body"));
1709 assert_eq!(page.definitions[&id(5)].format.font_size, Some(12.0));1813 assert_eq!(page.definitions[&id(5)].format.font_size, Some(12.0));
1710 let text = &paragraph.text().unwrap().text;1814 let text = &paragraph.text().unwrap().text;
1711 assert_eq!(text.text(), "ab");1815 assert_eq!(text.text(), "ab");
crates/onestore/src/pages.rs+34-3
...@@ -506,6 +506,12 @@ pub struct PageCreation {...@@ -506,6 +506,12 @@ pub struct PageCreation {
506 /// The page identity and creation time (FILETIME) a page keeps from elsewhere.506 /// The page identity and creation time (FILETIME) a page keeps from elsewhere.
507 #[serde(default, skip_serializing_if = "Option::is_none")]507 #[serde(default, skip_serializing_if = "Option::is_none")]
508 kept: Option<([u8; 16], u64)>,508 kept: Option<([u8; 16], u64)>,
509 /// The title's font, OneNote's Default font face; none is Calibri.
510 #[serde(default, skip_serializing_if = "Option::is_none")]
511 title_font: Option<String>,
512 /// The title's colour, OneNote's Default font colour as COLORREF; none is automatic.
513 #[serde(default, skip_serializing_if = "Option::is_none")]
514 title_color: Option<u32>,
509}515}
510516
511impl PageCreation {517impl PageCreation {
...@@ -522,6 +528,8 @@ impl PageCreation {...@@ -522,6 +528,8 @@ impl PageCreation {
522 created: current_timestamps()?.0,528 created: current_timestamps()?.0,
523 date: None,529 date: None,
524 kept: None,530 kept: None,
531 title_font: None,
532 title_color: None,
525 };533 };
526 page.validate()?;534 page.validate()?;
527 Ok(page)535 Ok(page)
...@@ -538,6 +546,16 @@ impl PageCreation {...@@ -538,6 +546,16 @@ impl PageCreation {
538 Ok(self)546 Ok(self)
539 }547 }
540548
549 /// Sets the title in `font` and `color` (COLORREF; none is automatic), as OneNote 2010
550 /// sets a new page's `PageTitle` quick style in its Default font's face and colour,
551 /// keeping the size (`corpus/default-font`).
552 pub fn titled_in(mut self, font: &str, color: Option<u32>) -> Result<Self, Error> {
553 self.title_font = Some(font.to_owned());
554 self.title_color = color;
555 self.validate()?;
556 Ok(self)
557 }
558
541 /// Keeps `identity`, the page identity internal links name, and `created`, as OneNote559 /// Keeps `identity`, the page identity internal links name, and `created`, as OneNote
542 /// 2010 keeps both for a page it moves to the notebook's recycle bin.560 /// 2010 keeps both for a page it moves to the notebook's recycle bin.
543 pub fn keeping(mut self, identity: [u8; 16], created: u64) -> Result<Self, Error> {561 pub fn keeping(mut self, identity: [u8; 16], created: u64) -> Result<Self, Error> {
...@@ -626,6 +644,10 @@ impl PageCreation {...@@ -626,6 +644,10 @@ impl PageCreation {
626 .iter()644 .iter()
627 .chain(self.date.iter().flatten())645 .chain(self.date.iter().flatten())
628 .any(|text| text.contains(['\0', '\r', '\n', '\u{fffc}', '\u{fddf}']))646 .any(|text| text.contains(['\0', '\r', '\n', '\u{fffc}', '\u{fddf}']))
647 || self
648 .title_font
649 .as_ref()
650 .is_some_and(|font| font.is_empty() || font.contains('\0'))
629 {651 {
630 return Err(invalid(652 return Err(invalid(
631 "Use a new page identity, an existing page anchor, and ordinary single-line title text",653 "Use a new page identity, an existing page anchor, and ordinary single-line title text",
...@@ -924,11 +946,20 @@ impl PageCreation {...@@ -924,11 +946,20 @@ impl PageCreation {
924 (946 (
925 18,947 18,
926 0x12004d,948 0x12004d,
927 vec![949 [
928 (0x1c00345a, string("PageTitle")),950 (0x1c00345a, string("PageTitle")),
929 (0x1c001c0a, string("Calibri")),951 (
952 0x1c001c0a,
953 string(self.title_font.as_deref().unwrap_or("Calibri")),
954 ),
930 (0x10001c0b, 34_u16.to_le_bytes().to_vec()),955 (0x10001c0b, 34_u16.to_le_bytes().to_vec()),
931 ],956 ]
957 .into_iter()
958 .chain(
959 self.title_color
960 .map(|color| (0x14001c0c, color.to_le_bytes().to_vec())),
961 )
962 .collect(),
932 ),963 ),
933 (19, 0x12004d, vec![(0x14001c3b, reference(0x409))]),964 (19, 0x12004d, vec![(0x14001c3b, reference(0x409))]),
934 ] {965 ] {
crates/onestore/src/write.rs+7-2
...@@ -419,9 +419,11 @@ pub(crate) fn replacement(id: ExGuid, object: &crate::Object<'_>) -> Result<Prop...@@ -419,9 +419,11 @@ pub(crate) fn replacement(id: ExGuid, object: &crate::Object<'_>) -> Result<Prop
419}419}
420420
421/// The object type OneNote gives embedded picture payload declarations; embedded files421/// The object type OneNote gives embedded picture payload declarations; embedded files
422/// use `EMBEDDED_FILE_JCID` with the same declaration shape.422/// use `EMBEDDED_FILE_JCID` with the same declaration shape, and a printout's XPS package
423/// `PRINTOUT_FILE_JCID` (`corpus/printout`).
423pub(crate) const FILE_DATA_JCID: u32 = 0x80039;424pub(crate) const FILE_DATA_JCID: u32 = 0x80039;
424pub(crate) const EMBEDDED_FILE_JCID: u32 = 0x80036;425pub(crate) const EMBEDDED_FILE_JCID: u32 = 0x80036;
426pub(crate) const PRINTOUT_FILE_JCID: u32 = 0x8003a;
425427
426/// The reference and extension of a file-data declaration built by `PropertyObject::file`.428/// The reference and extension of a file-data declaration built by `PropertyObject::file`.
427fn file_declaration(bytes: &[u8]) -> Result<(&[u8], &[u8])> {429fn file_declaration(bytes: &[u8]) -> Result<(&[u8], &[u8])> {
...@@ -470,7 +472,10 @@ pub(crate) fn declared(...@@ -470,7 +472,10 @@ pub(crate) fn declared(
470}472}
471473
472fn is_file_declaration(jcid: u32) -> bool {474fn is_file_declaration(jcid: u32) -> bool {
473 jcid == FILE_DATA_JCID || jcid == EMBEDDED_FILE_JCID475 matches!(
476 jcid,
477 FILE_DATA_JCID | EMBEDDED_FILE_JCID | PRINTOUT_FILE_JCID
478 )
474}479}
475480
476#[derive(Clone)]481#[derive(Clone)]
crates/onestore/tests/page_attachments.rs+3
...@@ -116,6 +116,7 @@ fn insert_attachment(section: &str, preview: Option<Arc<[u8]>>) -> Vec<u8> {...@@ -116,6 +116,7 @@ fn insert_attachment(section: &str, preview: Option<Arc<[u8]>>) -> Vec<u8> {
116 bytes: Some(Arc::from(PAYLOAD)),116 bytes: Some(Arc::from(PAYLOAD)),
117 preview: preview.clone(),117 preview: preview.clone(),
118 recording: None,118 recording: None,
119 tags: Vec::new(),
119 });120 });
120 body_paragraphs(&mut after).push(holder.clone());121 body_paragraphs(&mut after).push(holder.clone());
121 body_paragraphs(&mut after).push(plain_paragraph(&template, "After the file"));122 body_paragraphs(&mut after).push(plain_paragraph(&template, "After the file"));
...@@ -187,6 +188,7 @@ fn attachments_need_a_file_name_and_stored_ones_are_renamed_in_place() {...@@ -187,6 +188,7 @@ fn attachments_need_a_file_name_and_stored_ones_are_renamed_in_place() {
187 bytes: Some(Arc::from(PAYLOAD)),188 bytes: Some(Arc::from(PAYLOAD)),
188 preview: None,189 preview: None,
189 recording: None,190 recording: None,
191 tags: Vec::new(),
190 });192 });
191 body_paragraphs(&mut after).push(holder);193 body_paragraphs(&mut after).push(holder);
192 assert!(ops::saved(&source, space, &after).is_err());194 assert!(ops::saved(&source, space, &after).is_err());
...@@ -302,6 +304,7 @@ fn a_file_on_the_page_is_attached_moved_renamed_and_removed_as_ops() {...@@ -302,6 +304,7 @@ fn a_file_on_the_page_is_attached_moved_renamed_and_removed_as_ops() {
302 bytes: Some(Arc::from(PAYLOAD)),304 bytes: Some(Arc::from(PAYLOAD)),
303 preview: attachments(&native)[0].preview.clone(),305 preview: attachments(&native)[0].preview.clone(),
304 recording: None,306 recording: None,
307 tags: Vec::new(),
305 };308 };
306 let steps: [&dyn Fn(&mut Page); 4] = [309 let steps: [&dyn Fn(&mut Page); 4] = [
307 &|page| page.objects.push(PageObject::Attachment(file.clone())),310 &|page| page.objects.push(PageObject::Attachment(file.clone())),
crates/onestore/tests/page_images.rs+9
...@@ -119,6 +119,9 @@ fn a_picture_inserted_on_a_fresh_page_reads_back_and_can_be_removed() {...@@ -119,6 +119,9 @@ fn a_picture_inserted_on_a_fresh_page_reads_back_and_can_be_removed() {
119 alt: None,119 alt: None,
120 background: false,120 background: false,
121 printout: None,121 printout: None,
122 tags: Vec::new(),
123 link: None,
124 text: None,
122 };125 };
123 holder.content = ParagraphContent::Image(image.clone());126 holder.content = ParagraphContent::Image(image.clone());
124 body_paragraphs(&mut after).push(holder.clone());127 body_paragraphs(&mut after).push(holder.clone());
...@@ -299,6 +302,9 @@ fn a_page_level_picture_is_inserted_moved_and_removed() {...@@ -299,6 +302,9 @@ fn a_page_level_picture_is_inserted_moved_and_removed() {
299 alt: Some("Placed in Rust".into()),302 alt: Some("Placed in Rust".into()),
300 background: false,303 background: false,
301 printout: None,304 printout: None,
305 tags: Vec::new(),
306 link: None,
307 text: None,
302 };308 };
303 after.objects.push(PageObject::Image(image.clone()));309 after.objects.push(PageObject::Image(image.clone()));
304 let written = ops::saved(&source, space, &after).unwrap();310 let written = ops::saved(&source, space, &after).unwrap();
...@@ -366,6 +372,9 @@ fn pictures_need_a_recognised_payload_and_stored_ones_stay_fixed() {...@@ -366,6 +372,9 @@ fn pictures_need_a_recognised_payload_and_stored_ones_stay_fixed() {
366 alt: None,372 alt: None,
367 background: false,373 background: false,
368 printout: None,374 printout: None,
375 tags: Vec::new(),
376 link: None,
377 text: None,
369 });378 });
370 body_paragraphs(&mut after).push(holder);379 body_paragraphs(&mut after).push(holder);
371 assert!(ops::saved(&source, space, &after).is_err());380 assert!(ops::saved(&source, space, &after).is_err());
crates/onestore/tests/protected.rs+1
...@@ -179,6 +179,7 @@ fn a_protected_page_edit_is_stored_under_the_section_key() {...@@ -179,6 +179,7 @@ fn a_protected_page_edit_is_stored_under_the_section_key() {
179 bytes: Some(std::sync::Arc::from(&b"A payload that stays sealed"[..])),179 bytes: Some(std::sync::Arc::from(&b"A payload that stays sealed"[..])),
180 preview: None,180 preview: None,
181 recording: None,181 recording: None,
182 tags: Vec::new(),
182 });183 });
183 paragraphs.insert(at + 1, file);184 paragraphs.insert(at + 1, file);
184 let text = paragraphs[at].text_mut().unwrap();185 let text = paragraphs[at].text_mut().unwrap();
crates/snowbound/Cargo.toml+4
...@@ -54,8 +54,12 @@ windows-sys = { version = "0.61", features = [...@@ -54,8 +54,12 @@ windows-sys = { version = "0.61", features = [
54 "Win32_Security_Credentials",54 "Win32_Security_Credentials",
55 "Win32_Storage_FileSystem",55 "Win32_Storage_FileSystem",
56 "Win32_System_Com",56 "Win32_System_Com",
57 "Win32_System_Console",
58 "Win32_System_Diagnostics_Debug",
57 "Win32_System_IO",59 "Win32_System_IO",
60 "Win32_System_Kernel",
58 "Win32_System_LibraryLoader",61 "Win32_System_LibraryLoader",
62 "Win32_System_Pipes",
59 "Win32_System_Registry",63 "Win32_System_Registry",
60 "Win32_System_Threading",64 "Win32_System_Threading",
61 "Win32_System_Time",65 "Win32_System_Time",
crates/snowbound/assets/icons/styles.svg created+8
...@@ -0,0 +1,8 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#79c2ff"/><stop offset="1" stop-color="#1f7cf0"/></linearGradient>
4 </defs>
5 <path d="M2.8 2.35H13.2A0.8 0.8 0 0 1 14 3.15V5.15A0.8 0.8 0 0 1 13.2 5.95H2.8A0.8 0.8 0 0 1 2 5.15V3.15A0.8 0.8 0 0 1 2.8 2.35Z" fill="#000000" fill-opacity="0.16"/>
6 <path class="accent" d="M2.8 1.6H13.2A0.8 0.8 0 0 1 14 2.4V4.4A0.8 0.8 0 0 1 13.2 5.2H2.8A0.8 0.8 0 0 1 2 4.4V2.4A0.8 0.8 0 0 1 2.8 1.6Z" fill="url(#g0)" stroke="#1560c4" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
7 <path d="M2 7.5H11V9H2ZM2 10.5H14V11.5H2ZM2 13H12V14H2Z" fill="currentColor"/>
8</svg>
crates/snowbound/linux/README.md+3-2
...@@ -21,8 +21,9 @@ Edits save to the notebook folder as you type. Settings live in...@@ -21,8 +21,9 @@ Edits save to the notebook folder as you type. Settings live in
21puts the latter elsewhere.21puts the latter elsewhere.
2222
23**Install Snowbound**, shown while no notebook is open, adds Snowbound to your app menu and23**Install Snowbound**, shown while no notebook is open, adds Snowbound to your app menu and
24copies it to `~/.local/bin/snowbound`. Options, next to the update settings,24to the apps that open `.one` and `.onetoc2` files, and copies it to
25uninstalls it again; your notebooks stay where they are.25`~/.local/bin/snowbound`. Options, next to the update settings, uninstalls it
26again; your notebooks stay where they are.
2627
27The window's title bar is your desktop's own: KDE's on KDE, and on GNOME and28The window's title bar is your desktop's own: KDE's on KDE, and on GNOME and
28other desktops without server-side decorations an Adwaita-style one drawn by29other desktops without server-side decorations an Adwaita-style one drawn by
crates/snowbound/linux/onenote.xml created+9
...@@ -0,0 +1,9 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">
3 <mime-type type="application/onenote">
4 <comment>OneNote notebook file</comment>
5 <alias type="application/msonenote"/>
6 <glob pattern="*.one"/>
7 <glob pattern="*.onetoc2"/>
8 </mime-type>
9</mime-info>
crates/snowbound/linux/snowbound.desktop+2-1
...@@ -3,8 +3,9 @@ Type=Application...@@ -3,8 +3,9 @@ Type=Application
3Name=Snowbound3Name=Snowbound
4GenericName=Note Taking4GenericName=Note Taking
5Comment=Type and draw notes in OneNote notebooks5Comment=Type and draw notes in OneNote notebooks
6Exec=snowbound6Exec=snowbound %F
7Icon=net.paperclover.snowbound7Icon=net.paperclover.snowbound
8Terminal=false8Terminal=false
9Categories=Office;9Categories=Office;
10MimeType=application/onenote;
10StartupWMClass=net.paperclover.snowbound11StartupWMClass=net.paperclover.snowbound
crates/snowbound/src/art.rs+1
...@@ -41,6 +41,7 @@ pub const SERVER: &[&str] = art!("icons/server");...@@ -41,6 +41,7 @@ pub const SERVER: &[&str] = art!("icons/server");
41pub const SIDEBAR_COLLAPSE: &[&str] = art!("icons/sidebar-collapse");41pub const SIDEBAR_COLLAPSE: &[&str] = art!("icons/sidebar-collapse");
42pub const SIDEBAR_EXPAND: &[&str] = art!("icons/sidebar-expand");42pub const SIDEBAR_EXPAND: &[&str] = art!("icons/sidebar-expand");
43pub const STRIKETHROUGH: &[&str] = art!("icons/strikethrough");43pub const STRIKETHROUGH: &[&str] = art!("icons/strikethrough");
44pub const STYLES: &[&str] = art!("icons/styles");
44pub const SUBSCRIPT: &[&str] = art!("icons/subscript");45pub const SUBSCRIPT: &[&str] = art!("icons/subscript");
45pub const SUPERSCRIPT: &[&str] = art!("icons/superscript");46pub const SUPERSCRIPT: &[&str] = art!("icons/superscript");
46pub const TABLE: &[&str] = art!("icons/table");47pub const TABLE: &[&str] = art!("icons/table");
crates/snowbound/src/attachment.rs+2-11
...@@ -35,19 +35,10 @@ impl State {...@@ -35,19 +35,10 @@ impl State {
35 preview: platform::file_icon(path).map(Into::into),35 preview: platform::file_icon(path).map(Into::into),
36 recording: crate::recording::attached(name, &bytes),36 recording: crate::recording::attached(name, &bytes),
37 bytes: Some(bytes.into()),37 bytes: Some(bytes.into()),
38 tags: Vec::new(),
38 };39 };
39 let response = match at {40 let response = match at {
40 Some(point) => {41 Some(point) => self.view.drop_attachment(self.page_point(point), file)?,
41 let scale = self.ui.scale();
42 let corner = self.ui.rect(crate::page()).unwrap_or_default();
43 self.view.drop_attachment(
44 [
45 (point[0] - corner[0]) * scale,
46 (point[1] - corner[1]) * scale,
47 ],
48 file,
49 )?
50 }
51 None => self.view.insert_attachment(file)?,42 None => self.view.insert_attachment(file)?,
52 };43 };
53 self.respond(response);44 self.respond(response);
crates/snowbound/src/commands.rs+448-64
...@@ -7,7 +7,11 @@ use canvas::editor::{Alignment, FormatState, Formatting, ListStyle, Pen, Toggle}...@@ -7,7 +7,11 @@ use canvas::editor::{Alignment, FormatState, Formatting, ListStyle, Pen, Toggle}
7use canvas::interaction::{Request, ink::Tool};7use canvas::interaction::{Request, ink::Tool};
8use draw::edit::{Key, Modifiers, NamedKey, Platform};8use draw::edit::{Key, Modifiers, NamedKey, Platform};
9use onestore::page::ink::ShapeKind;9use onestore::page::ink::ShapeKind;
10use std::{error::Error, sync::Arc};10use std::{
11 collections::BTreeMap,
12 error::Error,
13 sync::{Arc, PoisonError, RwLock, RwLockReadGuard},
14};
1115
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub enum Id {17pub enum Id {
...@@ -24,7 +28,6 @@ pub enum Id {...@@ -24,7 +28,6 @@ pub enum Id {
24 CopyPageLink,28 CopyPageLink,
25 ShowNotebook,29 ShowNotebook,
26 ExportPdf,30 ExportPdf,
27 ExportSectionPdf,
28 Print,31 Print,
29 Undo,32 Undo,
30 Redo,33 Redo,
...@@ -84,6 +87,12 @@ pub enum Id {...@@ -84,6 +87,12 @@ pub enum Id {
84 Indent,87 Indent,
85 Outdent,88 Outdent,
86 ClearFormatting,89 ClearFormatting,
90 /// The style at this place in OneNote's Styles gallery (`notebook::sidecar::themes::STYLES`).
91 Style(usize),
92 /// The Styles gallery.
93 Styles,
94 /// The Themes dialog, assigning a theme to the page, its section or its notebook.
95 Theme(crate::themes::Scope),
87 /// The tag at this place in the user's tag list.96 /// The tag at this place in the user's tag list.
88 Tag(usize),97 Tag(usize),
89 CustomizeTags,98 CustomizeTags,
...@@ -240,7 +249,6 @@ pub const COMMANDS: &[Command] = &[...@@ -240,7 +249,6 @@ pub const COMMANDS: &[Command] = &[
240 NONE,249 NONE,
241 ),250 ),
242 row!(Id::ExportPdf, "Export as PDF…", NONE, NONE),251 row!(Id::ExportPdf, "Export as PDF…", NONE, NONE),
243 row!(Id::ExportSectionPdf, "Export Section as PDF…", NONE, NONE),
244 // Go to takes OneNote's Ctrl+P, and Pause its Ctrl+Alt+P.252 // Go to takes OneNote's Ctrl+P, and Pause its Ctrl+Alt+P.
245 row!(253 row!(
246 Id::Print,254 Id::Print,
...@@ -307,7 +315,8 @@ pub const COMMANDS: &[Command] = &[...@@ -307,7 +315,8 @@ pub const COMMANDS: &[Command] = &[
307 &[cmd('0')],315 &[cmd('0')],
308 &[cmd('0').option()]316 &[cmd('0').option()]
309 ),317 ),
310 row!(Id::Sidebar, "Notebook List", &[cmd('s').control()], NONE),318 // Sidebars toggle on Command-Backslash, as in Notion; OneNote 2010 leaves Ctrl+\ free.
319 row!(Id::Sidebar, "Notebook List", &[cmd('\\')], &[cmd('\\')]),
311 row!(Id::PageList, "Page List", NONE, NONE),320 row!(Id::PageList, "Page List", NONE, NONE),
312 row!(Id::PagesMatchTheme, "Pages Match UI Theme", NONE, NONE),321 row!(Id::PagesMatchTheme, "Pages Match UI Theme", NONE, NONE),
313 row!(Id::HideSpelling, "Hide Spelling Errors", NONE, NONE),322 row!(Id::HideSpelling, "Hide Spelling Errors", NONE, NONE),
...@@ -473,6 +482,67 @@ pub const COMMANDS: &[Command] = &[...@@ -473,6 +482,67 @@ pub const COMMANDS: &[Command] = &[
473 &[cmd('n').shift()],482 &[cmd('n').shift()],
474 &[cmd('n').shift()]483 &[cmd('n').shift()]
475 ),484 ),
485 row!(Id::Styles, "Styles", NONE, NONE),
486 // OneNote 2010's Ctrl+Alt+1 to 6; OneNote for Mac's Option-Command ones.
487 row!(
488 Id::Style(0),
489 "Heading 1",
490 &[cmd('1').option()],
491 &[cmd('1').option()]
492 ),
493 row!(
494 Id::Style(1),
495 "Heading 2",
496 &[cmd('2').option()],
497 &[cmd('2').option()]
498 ),
499 row!(
500 Id::Style(2),
501 "Heading 3",
502 &[cmd('3').option()],
503 &[cmd('3').option()]
504 ),
505 row!(
506 Id::Style(3),
507 "Heading 4",
508 &[cmd('4').option()],
509 &[cmd('4').option()]
510 ),
511 row!(
512 Id::Style(4),
513 "Heading 5",
514 &[cmd('5').option()],
515 &[cmd('5').option()]
516 ),
517 row!(
518 Id::Style(5),
519 "Heading 6",
520 &[cmd('6').option()],
521 &[cmd('6').option()]
522 ),
523 row!(Id::Style(6), "Page Title", NONE, NONE),
524 row!(Id::Style(7), "Citation", NONE, NONE),
525 row!(Id::Style(8), "Quote", NONE, NONE),
526 row!(Id::Style(9), "Code", NONE, NONE),
527 row!(Id::Style(10), "Normal", NONE, NONE),
528 row!(
529 Id::Theme(crate::themes::Scope::Page),
530 "Page Theme…",
531 NONE,
532 NONE
533 ),
534 row!(
535 Id::Theme(crate::themes::Scope::Section),
536 "Section Theme…",
537 NONE,
538 NONE
539 ),
540 row!(
541 Id::Theme(crate::themes::Scope::Notebook),
542 "Notebook Theme…",
543 NONE,
544 NONE
545 ),
476 row!(Id::CustomizeTags, "Customize Tags…", NONE, NONE),546 row!(Id::CustomizeTags, "Customize Tags…", NONE, NONE),
477 row!(547 row!(
478 Id::RemoveTags,548 Id::RemoveTags,
...@@ -499,6 +569,7 @@ pub fn command(id: Id) -> &'static Command {...@@ -499,6 +569,7 @@ pub fn command(id: Id) -> &'static Command {
499}569}
500570
501impl Command {571impl Command {
572 /// The table's chords on `platform`, which the user's may replace.
502 pub fn chords(&self, platform: Platform) -> &'static [Chord] {573 pub fn chords(&self, platform: Platform) -> &'static [Chord] {
503 match platform {574 match platform {
504 Platform::MacOs => self.mac,575 Platform::MacOs => self.mac,
...@@ -555,35 +626,118 @@ pub fn index(choice: &Choice) -> usize {...@@ -555,35 +626,118 @@ pub fn index(choice: &Choice) -> usize {
555 .expect("Menus offer listed choices")626 .expect("Menus offer listed choices")
556}627}
557628
629/// The chords set in Options' Keyboard here, each list in place of its command's defaults.
630#[derive(Clone, Debug, Default, PartialEq)]
631pub struct Keymap(Vec<(Id, Vec<Chord>)>);
632
633/// The keymap the keyboard, menus, palette and tooltips follow.
634static KEYMAP: RwLock<Keymap> = RwLock::new(Keymap(Vec::new()));
635
636fn keymap() -> RwLockReadGuard<'static, Keymap> {
637 KEYMAP.read().unwrap_or_else(PoisonError::into_inner)
638}
639
640impl Keymap {
641 /// The keymap in use.
642 pub fn current() -> Self {
643 keymap().clone()
644 }
645
646 /// Puts `self` in use, reporting whether it differs from the one it replaces.
647 pub fn install(self) -> bool {
648 let mut installed = KEYMAP.write().unwrap_or_else(PoisonError::into_inner);
649 let changed = *installed != self;
650 *installed = self;
651 changed
652 }
653
654 /// Command `id`'s chords on `platform`: the user's, or the table's.
655 pub fn chords(&self, id: Id, platform: Platform) -> &[Chord] {
656 self.0
657 .iter()
658 .find(|(set, _)| *set == id)
659 .map_or_else(|| command(id).chords(platform), |(_, chords)| chords)
660 }
661
662 /// Gives command `id` `chords` here, forgetting them where they are its defaults.
663 pub fn set(&mut self, id: Id, chords: Vec<Chord>) {
664 self.0.retain(|(set, _)| *set != id);
665 if chords != command(id).chords(Platform::CURRENT) {
666 self.0.push((id, chords));
667 }
668 }
669
670 pub fn customized(&self, id: Id) -> bool {
671 self.0.iter().any(|(set, _)| *set == id)
672 }
673
674 /// The command `chord` runs on `platform`.
675 pub fn ran(&self, chord: Chord, platform: Platform) -> Option<Id> {
676 COMMANDS
677 .iter()
678 .find(|command| self.chords(command.id, platform).contains(&chord))
679 .map(|command| command.id)
680 .or_else(|| {
681 (0..9)
682 .find(|&place| self.tag_chord(place, platform) == Some(chord))
683 .map(Id::Tag)
684 })
685 }
686
687 /// The chord applying the tag at `place` in the list: the first nine take Ctrl+1 to
688 /// Ctrl+9, as OneNote's do, but for one a command was given.
689 fn tag_chord(&self, place: usize, platform: Platform) -> Option<Chord> {
690 let digit = u8::try_from(place).ok().filter(|place| *place < 9)?;
691 let chord = cmd(char::from(b'1' + digit));
692 let taken = COMMANDS
693 .iter()
694 .any(|command| self.chords(command.id, platform).contains(&chord));
695 (!taken).then_some(chord)
696 }
697
698 /// As settings keep it: each set command's chords by its name in the table.
699 pub fn saved(&self) -> BTreeMap<String, Vec<String>> {
700 (self.0.iter())
701 .map(|(id, chords)| (format!("{id:?}"), chords.iter().map(Chord::saved).collect()))
702 .collect()
703 }
704
705 /// The keymap settings kept, without commands or chords this build no longer has.
706 pub fn from_saved(saved: &BTreeMap<String, Vec<String>>) -> Self {
707 Self(
708 COMMANDS
709 .iter()
710 .filter_map(|command| {
711 let chords = saved.get(&format!("{:?}", command.id))?;
712 let chords = chords.iter().filter_map(|chord| Chord::read(chord));
713 Some((command.id, chords.collect()))
714 })
715 .collect(),
716 )
717 }
718}
719
558/// The command `key` with `modifiers` runs here.720/// The command `key` with `modifiers` runs here.
559pub fn find(key: &Key, modifiers: Modifiers) -> Option<Id> {721pub fn find(key: &Key, modifiers: Modifiers) -> Option<Id> {
560 ran(722 keymap().ran(pressed(key, modifiers)?, Platform::CURRENT)
561 pressed(key, modifiers, Platform::CURRENT)?,
562 Platform::CURRENT,
563 )
564}723}
565724
566/// The command `chord` runs on `platform`.725/// Command `id`'s chords here.
567fn ran(chord: Chord, platform: Platform) -> Option<Id> {726pub fn chords(id: Id) -> Vec<Chord> {
568 COMMANDS727 keymap().chords(id, Platform::CURRENT).to_vec()
569 .iter()
570 .find(|command| command.chords(platform).contains(&chord))
571 .map(|command| command.id)
572 .or_else(|| {
573 (0..9)
574 .find(|&place| tag_chord(place) == Some(chord))
575 .map(Id::Tag)
576 })
577}728}
578729
579/// The chord applying the tag at `place` in the list: the first nine take Ctrl+1 to Ctrl+9,730/// The chord applying the tag at `place` in the list here.
580/// as OneNote's do.
581pub fn tag_chord(place: usize) -> Option<Chord> {731pub fn tag_chord(place: usize) -> Option<Chord> {
582 let digit = u8::try_from(place).ok().filter(|place| *place < 9)?;732 keymap().tag_chord(place, Platform::CURRENT)
583 Some(cmd(char::from(b'1' + digit)))733}
734
735/// The chord `key` with `modifiers` makes here.
736pub fn pressed(key: &Key, modifiers: Modifiers) -> Option<Chord> {
737 chord_on(key, modifiers, Platform::CURRENT)
584}738}
585739
586fn pressed(key: &Key, modifiers: Modifiers, platform: Platform) -> Option<Chord> {740fn chord_on(key: &Key, modifiers: Modifiers, platform: Platform) -> Option<Chord> {
587 let (key, shifted) = match key {741 let (key, shifted) = match key {
588 Key::Named(named) => (Press::Named(*named), false),742 Key::Named(named) => (Press::Named(*named), false),
589 Key::Character(text) => {743 Key::Character(text) => {
...@@ -630,6 +784,49 @@ const SHIFTED: [(char, char); 21] = [...@@ -630,6 +784,49 @@ const SHIFTED: [(char, char); 21] = [
630 ('(', '9'),784 ('(', '9'),
631];785];
632786
787/// The named keys a chord may take: AppKit's key equivalent for each, and how macOS's and
788/// other desktops' menus show it.
789const NAMED: [(NamedKey, char, &str, &str); 15] = [
790 (NamedKey::Space, ' ', "Space", "Space"),
791 (NamedKey::Tab, '\t', "⇥", "Tab"),
792 (NamedKey::Enter, '\r', "↩", "Enter"),
793 (NamedKey::Backspace, '\u{8}', "⌫", "Backspace"),
794 (NamedKey::Delete, '\u{f728}', "⌦", "Delete"),
795 (NamedKey::ArrowLeft, '\u{f702}', "←", "Left"),
796 (NamedKey::ArrowRight, '\u{f703}', "→", "Right"),
797 (NamedKey::ArrowUp, '\u{f700}', "↑", "Up"),
798 (NamedKey::ArrowDown, '\u{f701}', "↓", "Down"),
799 (NamedKey::Home, '\u{f729}', "↖", "Home"),
800 (NamedKey::End, '\u{f72b}', "↘", "End"),
801 (NamedKey::PageUp, '\u{f72c}', "⇞", "Page Up"),
802 (NamedKey::PageDown, '\u{f72d}', "⇟", "Page Down"),
803 (NamedKey::F7, '\u{f70a}', "F7", "F7"),
804 (NamedKey::F11, '\u{f70e}', "F11", "F11"),
805];
806
807/// The chords AppKit's own menu items take on macOS, which no command may.
808const APPKIT: [(Chord, &str); 5] = [
809 (cmd('q'), "Quit Snowbound"),
810 (cmd('h'), "Hide Snowbound"),
811 (cmd('h').option(), "Hide Others"),
812 (cmd('m'), "Minimize"),
813 (
814 named(NamedKey::Space).command().control(),
815 "Emoji & Symbols",
816 ),
817];
818
819/// Why a chord can't run a command.
820#[derive(Clone, Copy, Debug, PartialEq, Eq)]
821pub enum Unusable {
822 /// A key no chord takes, or a character typed without a shortcut modifier.
823 Typing,
824 /// Moves or deletes text, as `draw::edit` reads it, and no command takes it by default.
825 Editing,
826 /// The system's: AppKit's item of this title.
827 System(&'static str),
828}
829
633impl Chord {830impl Chord {
634 /// The key AppKit matches and shows: a shifted symbol stands for Shift with its key.831 /// The key AppKit matches and shows: a shifted symbol stands for Shift with its key.
635 pub fn equivalent(self) -> (String, bool) {832 pub fn equivalent(self) -> (String, bool) {
...@@ -641,26 +838,27 @@ impl Chord {...@@ -641,26 +838,27 @@ impl Chord {
641 })838 })
642 }839 }
643 Press::Char(base) => (base.to_string(), self.shift),840 Press::Char(base) => (base.to_string(), self.shift),
644 Press::Named(NamedKey::Space) => (" ".to_owned(), self.shift),841 Press::Named(named) => (
645 Press::Named(NamedKey::ArrowLeft) => ('\u{f702}'.to_string(), self.shift),842 NAMED
646 Press::Named(NamedKey::ArrowRight) => ('\u{f703}'.to_string(), self.shift),843 .iter()
647 // AppKit's NSF7FunctionKey.844 .find(|(listed, ..)| *listed == named)
648 Press::Named(NamedKey::F7) => ('\u{f70a}'.to_string(), self.shift),845 .map_or_else(String::new, |(_, key, ..)| key.to_string()),
649 Press::Named(named) => unreachable!("No command takes {named:?}"),846 self.shift,
847 ),
650 }848 }
651 }849 }
652850
653 /// As a menu shows it on `platform`: ⌃⌥⇧⌘K on macOS, Ctrl+Alt+Shift+K elsewhere.851 /// As a menu shows it on `platform`: ⌃⌥⇧⌘K on macOS, Ctrl+Alt+Shift+K elsewhere.
654 pub fn label(self, platform: Platform) -> String {852 pub fn label(self, platform: Platform) -> String {
853 let named = |pick: fn(&(NamedKey, char, &'static str, &'static str)) -> &'static str| {
854 let Press::Named(named) = self.key else {
855 return None;
856 };
857 NAMED.iter().find(|(listed, ..)| *listed == named).map(pick)
858 };
655 if platform == Platform::MacOs {859 if platform == Platform::MacOs {
656 let (key, shift) = self.equivalent();860 let (key, shift) = self.equivalent();
657 let key = match self.key {861 let key = named(|(.., mac, _)| mac).map_or_else(|| key.to_uppercase(), str::to_owned);
658 Press::Named(NamedKey::Space) => "Space".to_owned(),
659 Press::Named(NamedKey::ArrowLeft) => "←".to_owned(),
660 Press::Named(NamedKey::ArrowRight) => "→".to_owned(),
661 Press::Named(NamedKey::F7) => "F7".to_owned(),
662 _ => key.to_uppercase(),
663 };
664 [862 [
665 (self.control, "⌃"),863 (self.control, "⌃"),
666 (self.option, "⌥"),864 (self.option, "⌥"),
...@@ -675,9 +873,9 @@ impl Chord {...@@ -675,9 +873,9 @@ impl Chord {
675 } else {873 } else {
676 let key = match self.key {874 let key = match self.key {
677 Press::Char(key) => key.to_uppercase().to_string(),875 Press::Char(key) => key.to_uppercase().to_string(),
678 Press::Named(NamedKey::ArrowLeft) => "Left".to_owned(),876 Press::Named(key) => {
679 Press::Named(NamedKey::ArrowRight) => "Right".to_owned(),877 named(|(.., pc)| pc).map_or_else(|| format!("{key:?}"), str::to_owned)
680 Press::Named(named) => format!("{named:?}"),878 }
681 };879 };
682 [880 [
683 (self.command, "Ctrl+"),881 (self.command, "Ctrl+"),
...@@ -691,13 +889,92 @@ impl Chord {...@@ -691,13 +889,92 @@ impl Chord {
691 .collect()889 .collect()
692 }890 }
693 }891 }
892
893 /// Why the chord can't be given a command on `platform`, if it can't.
894 pub fn unusable(self, platform: Platform) -> Option<Unusable> {
895 let modified = self.command || self.option || self.control;
896 let mac = platform == Platform::MacOs;
897 let typed = match self.key {
898 Press::Char(_) => !(self.command || self.control || !mac && self.option),
899 Press::Named(NamedKey::F7 | NamedKey::F11) => false,
900 Press::Named(named) => !modified || !NAMED.iter().any(|(listed, ..)| *listed == named),
901 };
902 let key = match self.key {
903 Press::Char(key) => Key::Character(key.to_string()),
904 Press::Named(named) => Key::Named(named),
905 };
906 let modifiers = Modifiers {
907 shift: self.shift,
908 option: self.option,
909 // Off macOS Control is the shortcut modifier, and comes with it.
910 control: self.control || !mac && self.command,
911 command: self.command,
912 };
913 if typed {
914 Some(Unusable::Typing)
915 } else if platform.command(&key, modifiers).is_some()
916 // The table's own chords already take theirs from text editing.
917 && !(COMMANDS.iter()).any(|command| command.chords(platform).contains(&self))
918 {
919 Some(Unusable::Editing)
920 } else {
921 (APPKIT.iter())
922 .find(|(chord, _)| mac && *chord == self)
923 .map(|(_, title)| Unusable::System(title))
924 }
925 }
926
927 /// As settings keep it, Command+Shift+K: the modifiers and the unshifted key, named.
928 fn saved(&self) -> String {
929 let key = match self.key {
930 Press::Char(key) => key.to_string(),
931 Press::Named(named) => format!("{named:?}"),
932 };
933 [
934 (self.control, "Control+"),
935 (self.option, "Option+"),
936 (self.shift, "Shift+"),
937 (self.command, "Command+"),
938 ]
939 .iter()
940 .filter(|(held, _)| *held)
941 .map(|(_, modifier)| *modifier)
942 .chain([key.as_str()])
943 .collect()
944 }
945
946 /// The chord `saved` wrote as `text`.
947 fn read(text: &str) -> Option<Self> {
948 let (modifiers, key) = text.rsplit_once('+').unwrap_or(("", text));
949 let mut chars = key.chars();
950 let key = match (chars.next(), chars.next()) {
951 (Some(key), None) => Press::Char(key),
952 _ => Press::Named(
953 NAMED
954 .iter()
955 .map(|(named, ..)| *named)
956 .find(|named| format!("{named:?}") == key)?,
957 ),
958 };
959 let mut chord = press(key);
960 for modifier in modifiers.split('+').filter(|modifier| !modifier.is_empty()) {
961 match modifier {
962 "Control" => chord.control = true,
963 "Option" => chord.option = true,
964 "Shift" => chord.shift = true,
965 "Command" => chord.command = true,
966 _ => return None,
967 }
968 }
969 Some(chord)
970 }
694}971}
695972
696/// The chord a menu or hint shows for `id` here, empty where it has none.973/// The chord a menu or hint shows for `id` here, empty where it has none.
697pub fn shortcut(id: Id) -> String {974pub fn shortcut(id: Id) -> String {
698 let chord = match id {975 let chord = match id {
699 Id::Tag(place) => tag_chord(place),976 Id::Tag(place) => tag_chord(place),
700 id => command(id).chords(Platform::CURRENT).first().copied(),977 id => chords(id).first().copied(),
701 };978 };
702 chord.map_or_else(String::new, |chord| chord.label(Platform::CURRENT))979 chord.map_or_else(String::new, |chord| chord.label(Platform::CURRENT))
703}980}
...@@ -722,11 +999,20 @@ impl State {...@@ -722,11 +999,20 @@ impl State {
722 self.window.request_redraw();999 self.window.request_redraw();
723 }1000 }
7241001
725 /// Every choice's status, in `choices` order, for the menu bar.1002 /// Every choice's status, in `choices` order, for the menu bar. Link gives up its chord,
1003 /// ⌘K, to the open palette's actions.
726 pub(crate) fn statuses(&self) -> Vec<Status> {1004 pub(crate) fn statuses(&self) -> Vec<Status> {
1005 // A chord Options records reaches it past the menu bar's disabled items.
1006 if self.recording_chord() {
1007 return vec![Status::default(); choices().count()];
1008 }
727 let format = self.format_state();1009 let format = self.format_state();
1010 let palette = self.ui.popup_open(crate::palette::id());
728 choices()1011 choices()
729 .map(|choice| self.status(&choice, &format))1012 .map(|choice| match choice {
1013 Choice::Command(Id::Link) if palette => Status::default(),
1014 choice => self.status(&choice, &format),
1015 })
730 .collect()1016 .collect()
731 }1017 }
7321018
...@@ -734,6 +1020,8 @@ impl State {...@@ -734,6 +1020,8 @@ impl State {
734 let session = self.session.as_ref();1020 let session = self.session.as_ref();
735 let welcome = session.is_none() && !self.temporary && self.sectionless.is_none();1021 let welcome = session.is_none() && !self.temporary && self.sectionless.is_none();
736 let modal = self.options.is_some()1022 let modal = self.options.is_some()
1023 || self.themes.is_some()
1024 || self.printing.open()
737 || self.link.is_some()1025 || self.link.is_some()
738 || self.tag_list.is_some()1026 || self.tag_list.is_some()
739 || self.server.is_some();1027 || self.server.is_some();
...@@ -742,6 +1030,14 @@ impl State {...@@ -742,6 +1030,14 @@ impl State {
742 // Picked drawings leave the text selection behind them unseen, so it takes no edits.1030 // Picked drawings leave the text selection behind them unseen, so it takes no edits.
743 let typing = self.view.accepts_text() && self.view.ink_selection().is_empty();1031 let typing = self.view.accepts_text() && self.view.ink_selection().is_empty();
744 let text = writable && typing;1032 let text = writable && typing;
1033 let tagged = writable
1034 && matches!(
1035 self.view.object_focus(),
1036 Some(
1037 canvas::interaction::ObjectFocus::Image(_)
1038 | canvas::interaction::ObjectFocus::File(_)
1039 )
1040 );
745 // Edit commands act on a focused field instead of the page.1041 // Edit commands act on a focused field instead of the page.
746 let field = self.ui.focused_field().is_some();1042 let field = self.ui.focused_field().is_some();
747 let [anchor, focus] = self.view.editor.selection().positions;1043 let [anchor, focus] = self.view.editor.selection().positions;
...@@ -793,7 +1089,6 @@ impl State {...@@ -793,7 +1089,6 @@ impl State {
793 | Id::Find1089 | Id::Find
794 | Id::Search1090 | Id::Search
795 | Id::ExportPdf1091 | Id::ExportPdf
796 | Id::ExportSectionPdf
797 | Id::Print => enabled(!modal && session.is_some()),1092 | Id::Print => enabled(!modal && session.is_some()),
798 Id::PageVersions => session.filter(|_| !modal).map_or(1093 Id::PageVersions => session.filter(|_| !modal).map_or(
799 Status {1094 Status {
...@@ -809,8 +1104,7 @@ impl State {...@@ -809,8 +1104,7 @@ impl State {
809 enabled: !welcome && !modal,1104 enabled: !welcome && !modal,
810 checked: Some(self.full_page),1105 checked: Some(self.full_page),
811 },1106 },
812 Id::Undo => enabled(writable && !field && self.view.editor.can_undo()),1107 Id::Undo | Id::Redo => enabled(writable && !field && self.can_step(id == Id::Redo)),
813 Id::Redo => enabled(writable && !field && self.view.editor.can_redo()),
814 Id::Cut => enabled(writable && selected),1108 Id::Cut => enabled(writable && selected),
815 Id::Copy => enabled(selected),1109 Id::Copy => enabled(selected),
816 Id::Paste => enabled(text && !field),1110 Id::Paste => enabled(text && !field),
...@@ -903,13 +1197,25 @@ impl State {...@@ -903,13 +1197,25 @@ impl State {
903 | Id::Indent1197 | Id::Indent
904 | Id::Outdent1198 | Id::Outdent
905 | Id::ClearFormatting1199 | Id::ClearFormatting
906 | Id::RemoveTags => enabled(text),1200 | Id::Styles => enabled(text),
1201 Id::Style(place) => checked(
1202 format.style.as_deref()
1203 == notebook::sidecar::themes::STYLES
1204 .get(place)
1205 .map(|(name, _)| *name),
1206 ),
1207 Id::Theme(scope) => enabled(!modal && self.theme_target(scope).is_some()),
1208 // A selected picture or file takes tags too, as in OneNote.
1209 Id::RemoveTags => enabled(text || tagged),
907 Id::Toggle(toggle) => checked(format.toggles.contains(&toggle)),1210 Id::Toggle(toggle) => checked(format.toggles.contains(&toggle)),
908 Id::Bullets => checked(format.bullets),1211 Id::Bullets => checked(format.bullets),
909 Id::Numbering => checked(format.numbering),1212 Id::Numbering => checked(format.numbering),
910 Id::Align(alignment) => checked(format.alignment == Some(alignment)),1213 Id::Align(alignment) => checked(format.alignment == Some(alignment)),
911 Id::Tag(place) => match self.tags.get(place) {1214 Id::Tag(place) => match self.tags.get(place) {
912 Some(tag) => checked(format.tags.contains(&(tag.stored(), place as u16))),1215 Some(tag) => Status {
1216 enabled: text || tagged,
1217 checked: Some(format.tags.contains(&(tag.stored(), place as u16))),
1218 },
913 None => Status::default(),1219 None => Status::default(),
914 },1220 },
915 }1221 }
...@@ -1032,19 +1338,11 @@ impl State {...@@ -1032,19 +1338,11 @@ impl State {
1032 platform::reveal(&self.notebook().ok_or("No notebook is open")?.location);1338 platform::reveal(&self.notebook().ok_or("No notebook is open")?.location);
1033 return Ok(());1339 return Ok(());
1034 }1340 }
1035 Id::ExportPdf | Id::ExportSectionPdf | Id::Print => {1341 Id::ExportPdf | Id::Print => {
1036 let scope = if id == Id::ExportSectionPdf {1342 self.open_print(id == Id::ExportPdf);
1037 crate::print::Scope::Section
1038 } else {
1039 crate::print::Scope::Page
1040 };
1041 return self.print(scope, id != Id::Print);
1042 }
1043 Id::Undo | Id::Redo => {
1044 let response = self.view.undo(id == Id::Redo)?;
1045 self.respond(response);
1046 return Ok(());1343 return Ok(());
1047 }1344 }
1345 Id::Undo | Id::Redo => return self.step(id == Id::Redo),
1048 Id::Cut | Id::Copy => {1346 Id::Cut | Id::Copy => {
1049 let response = self.view.copy(id == Id::Cut)?;1347 let response = self.view.copy(id == Id::Cut)?;
1050 self.respond(response);1348 self.respond(response);
...@@ -1133,7 +1431,7 @@ impl State {...@@ -1133,7 +1431,7 @@ impl State {
1133 else {1431 else {
1134 return Ok(());1432 return Ok(());
1135 };1433 };
1136 return self.insert_picture(std::fs::read(path)?);1434 return self.insert_picture(std::fs::read(path)?, None);
1137 }1435 }
1138 Id::Attachment => {1436 Id::Attachment => {
1139 let Some(path) = platform::pick_file("Attach File", &[]) else {1437 let Some(path) = platform::pick_file("Attach File", &[]) else {
...@@ -1227,7 +1525,27 @@ impl State {...@@ -1227,7 +1525,27 @@ impl State {
1227 Id::Align(alignment) => return format(self, Formatting::Align(alignment)),1525 Id::Align(alignment) => return format(self, Formatting::Align(alignment)),
1228 Id::Indent => return format(self, Formatting::Indent),1526 Id::Indent => return format(self, Formatting::Indent),
1229 Id::Outdent => return format(self, Formatting::Outdent),1527 Id::Outdent => return format(self, Formatting::Outdent),
1230 Id::ClearFormatting => return format(self, Formatting::Clear),1528 // Clear Formatting at a caret makes the paragraph Normal, as OneNote 2010's
1529 // Ctrl+Shift+N does; over a selection it clears the characters' formatting.
1530 Id::ClearFormatting => {
1531 let [anchor, focus] = self.view.editor.selection().positions;
1532 if anchor == focus {
1533 return format(self, Formatting::Style(self.gallery_style("p")));
1534 }
1535 return format(self, Formatting::Clear);
1536 }
1537 Id::Style(place) => {
1538 let (name, _) = notebook::sidecar::themes::STYLES[place];
1539 return format(self, Formatting::Style(self.gallery_style(name)));
1540 }
1541 Id::Styles => {
1542 self.ui.open_popup(crate::toolbar_popup("styles"));
1543 return Ok(());
1544 }
1545 Id::Theme(scope) => {
1546 self.open_themes(scope);
1547 return Ok(());
1548 }
1231 Id::Tag(place) => {1549 Id::Tag(place) => {
1232 let tag = self.tags[place].clone();1550 let tag = self.tags[place].clone();
1233 format(self, Formatting::Tag(tag, place as u16))?;1551 format(self, Formatting::Tag(tag, place as u16))?;
...@@ -1315,8 +1633,8 @@ mod tests {...@@ -1315,8 +1633,8 @@ mod tests {
1315 control,1633 control,
1316 command,1634 command,
1317 };1635 };
1318 ran(1636 Keymap::default().ran(
1319 pressed(&Key::Character(key.into()), modifiers, platform)?,1637 chord_on(&Key::Character(key.into()), modifiers, platform)?,
1320 platform,1638 platform,
1321 )1639 )
1322 };1640 };
...@@ -1371,7 +1689,7 @@ mod tests {...@@ -1371,7 +1689,7 @@ mod tests {
1371 find(gtk, "y", false, false, true, true),1689 find(gtk, "y", false, false, true, true),
1372 cfg!(windows).then_some(Id::Redo)1690 cfg!(windows).then_some(Id::Redo)
1373 );1691 );
1374 let indent = pressed(1692 let indent = chord_on(
1375 &Key::Named(NamedKey::ArrowRight),1693 &Key::Named(NamedKey::ArrowRight),
1376 Modifiers {1694 Modifiers {
1377 shift: true,1695 shift: true,
...@@ -1408,6 +1726,72 @@ mod tests {...@@ -1408,6 +1726,72 @@ mod tests {
1408 assert_eq!(label(Id::Date, Platform::Windows), "Alt+Shift+D");1726 assert_eq!(label(Id::Date, Platform::Windows), "Alt+Shift+D");
1409 }1727 }
14101728
1729 #[test]
1730 fn keymaps_keep_chords_by_command_and_drop_the_unknown() {
1731 let mut keymap = Keymap::default();
1732 let chord = named(NamedKey::PageDown).command().option().shift();
1733 keymap.set(Id::Sidebar, vec![chord, cmd('\\').control()]);
1734 keymap.set(
1735 Id::Toggle(Toggle::Bold),
1736 command(Id::Toggle(Toggle::Bold))
1737 .chords(Platform::CURRENT)
1738 .to_vec(),
1739 );
1740 let mut saved = keymap.saved();
1741 assert_eq!(
1742 saved["Sidebar"],
1743 ["Option+Shift+Command+PageDown", "Control+Command+\\"]
1744 );
1745 assert!(!saved.contains_key("Toggle(Bold)"), "defaults are not kept");
1746 saved.insert("Retired".into(), vec!["Command+J".into()]);
1747 saved.insert(
1748 "PageList".into(),
1749 vec!["Command+Hyper+J".into(), "Command+j".into()],
1750 );
1751 let read = Keymap::from_saved(&saved);
1752 assert_eq!(
1753 read.chords(Id::Sidebar, Platform::CURRENT),
1754 [chord, cmd('\\').control()]
1755 );
1756 assert_eq!(read.chords(Id::PageList, Platform::CURRENT), [cmd('j')]);
1757 }
1758
1759 #[test]
1760 fn every_default_chord_is_one_a_user_could_record() {
1761 for platform in PLATFORMS {
1762 for command in COMMANDS {
1763 for chord in command.chords(platform) {
1764 assert_eq!(
1765 chord.unusable(platform),
1766 None,
1767 "{platform:?}: {} for {}",
1768 chord.label(platform),
1769 command.title
1770 );
1771 }
1772 }
1773 }
1774 assert_eq!(key('j').unusable(Platform::MacOs), Some(Unusable::Typing));
1775 assert_eq!(
1776 cmd('q').unusable(Platform::MacOs),
1777 Some(Unusable::System("Quit Snowbound"))
1778 );
1779 assert_eq!(cmd('q').unusable(Platform::Gtk), None);
1780 assert_eq!(
1781 named(NamedKey::ArrowLeft)
1782 .command()
1783 .unusable(Platform::MacOs),
1784 Some(Unusable::Editing)
1785 );
1786 assert_eq!(
1787 command(Id::Sidebar).chords(Platform::MacOs)[0].label(Platform::MacOs),
1788 "⌘\\"
1789 );
1790 assert_eq!(
1791 command(Id::Sidebar).chords(Platform::Windows)[0].label(Platform::Windows),
1792 "Ctrl+\\"
1793 );
1794 }
1411 /// Each toolbar button chooses a command or a list's entry, so it runs, enables and1795 /// Each toolbar button chooses a command or a list's entry, so it runs, enables and
1412 /// checks as the menu bar and keyboard do.1796 /// checks as the menu bar and keyboard do.
1413 #[test]1797 #[test]
crates/snowbound/src/conflict_render.rs+1-1
...@@ -65,7 +65,7 @@ fn layout(ui: &mut Ui, session: &Session, menu: bool) -> Vec<Command> {...@@ -65,7 +65,7 @@ fn layout(ui: &mut Ui, session: &Session, menu: bool) -> Vec<Command> {
65 &section,65 &section,
66 session,66 session,
67 &HashSet::new(),67 &HashSet::new(),
68 0.0,68 (0.0, false),
69 None,69 None,
70 None,70 None,
71 );71 );
crates/snowbound/src/desktop_linux.rs+37-14
...@@ -36,6 +36,8 @@ use winit::{...@@ -36,6 +36,8 @@ use winit::{
36/// Wayland's app ID and X11's WM_CLASS, which name the desktop entry and the icon.36/// Wayland's app ID and X11's WM_CLASS, which name the desktop entry and the icon.
37pub const APP_ID: &str = "net.paperclover.snowbound";37pub const APP_ID: &str = "net.paperclover.snowbound";
38const TEMPLATE: &str = include_str!("../linux/snowbound.desktop");38const TEMPLATE: &str = include_str!("../linux/snowbound.desktop");
39/// The type of `.one` and `.onetoc2` files, which no shared-mime-info release names.
40const MIME_TYPES: &str = include_str!("../linux/onenote.xml");
39/// 512 pixels square; the smaller sizes are scaled from it.41/// 512 pixels square; the smaller sizes are scaled from it.
40const ICON: &[u8] = include_bytes!("../linux/snowbound.png");42const ICON: &[u8] = include_bytes!("../linux/snowbound.png");
41const SIZES: [u32; 8] = [16, 24, 32, 48, 64, 128, 256, 512];43const SIZES: [u32; 8] = [16, 24, 32, 48, 64, 128, 256, 512];
...@@ -75,6 +77,10 @@ fn entry(data: &Path) -> PathBuf {...@@ -75,6 +77,10 @@ fn entry(data: &Path) -> PathBuf {
75 data.join(format!("applications/{APP_ID}.desktop"))77 data.join(format!("applications/{APP_ID}.desktop"))
76}78}
7779
80fn mime_types(data: &Path) -> PathBuf {
81 data.join(format!("mime/packages/{APP_ID}.xml"))
82}
83
78fn theme_icon(data: &Path, side: u32) -> PathBuf {84fn theme_icon(data: &Path, side: u32) -> PathBuf {
79 data.join(format!("icons/hicolor/{side}x{side}/apps/{APP_ID}.png"))85 data.join(format!("icons/hicolor/{side}x{side}/apps/{APP_ID}.png"))
80}86}
...@@ -163,7 +169,12 @@ fn entry_text(exec: &Path, icon: &str) -> String {...@@ -163,7 +169,12 @@ fn entry_text(exec: &Path, icon: &str) -> String {
163 TEMPLATE169 TEMPLATE
164 .lines()170 .lines()
165 .map(|line| match line.split_once('=') {171 .map(|line| match line.split_once('=') {
166 Some(("Exec", _)) => format!("Exec={}\n", quote(exec)),172 Some(("Exec", command)) => {
173 let arguments = command
174 .split_once(' ')
175 .map_or("", |(_, arguments)| arguments);
176 format!("Exec={} {arguments}\n", quote(exec))
177 }
167 Some(("Icon", _)) => format!("Icon={icon}\n"),178 Some(("Icon", _)) => format!("Icon={icon}\n"),
168 _ => format!("{line}\n"),179 _ => format!("{line}\n"),
169 })180 })
...@@ -339,7 +350,7 @@ pub fn set_toplevel_icon(window: &Window) -> Option<()> {...@@ -339,7 +350,7 @@ pub fn set_toplevel_icon(window: &Window) -> Option<()> {
339}350}
340351
341/// Copies this executable to `~/.local/bin` unless it runs from there, adds Snowbound to the352/// Copies this executable to `~/.local/bin` unless it runs from there, adds Snowbound to the
342/// app menu, and says where Uninstall is.353/// app menu and to the apps opening OneNote's files, and says where Uninstall is.
343pub fn install() {354pub fn install() {
344 match try_install() {355 match try_install() {
345 Ok(()) => {356 Ok(()) => {
...@@ -377,10 +388,13 @@ fn try_install() -> io::Result<()> {...@@ -377,10 +388,13 @@ fn try_install() -> io::Result<()> {
377 .write_header()?388 .write_header()?
378 .write_image_data(&scaled(&icon, side))?;389 .write_image_data(&scaled(&icon, side))?;
379 }390 }
391 let types = mime_types(&data);
392 fs::create_dir_all(types.parent().ok_or(io::ErrorKind::NotFound)?)?;
393 fs::write(types, MIME_TYPES)?;
380 // Replaces this run's hidden entry, which exiting then leaves alone.394 // Replaces this run's hidden entry, which exiting then leaves alone.
381 write_entry(&entry(&data), &entry_text(&binary, APP_ID))?;395 write_entry(&entry(&data), &entry_text(&binary, APP_ID))?;
382 let _ = runtime_icon().map(fs::remove_file);396 let _ = runtime_icon().map(fs::remove_file);
383 refresh_icon_cache(&data);397 refresh_caches(&data);
384 Ok(())398 Ok(())
385}399}
386400
...@@ -398,7 +412,10 @@ pub fn uninstall() {...@@ -398,7 +412,10 @@ pub fn uninstall() {
398 }412 }
399 let mut failed = None;413 let mut failed = None;
400 let icons = SIZES.map(|side| theme_icon(&data, side));414 let icons = SIZES.map(|side| theme_icon(&data, side));
401 for path in [entry(&data), binary].iter().chain(&icons) {415 for path in [entry(&data), mime_types(&data), binary]
416 .iter()
417 .chain(&icons)
418 {
402 if let Err(error) = fs::remove_file(path)419 if let Err(error) = fs::remove_file(path)
403 && error.kind() != io::ErrorKind::NotFound420 && error.kind() != io::ErrorKind::NotFound
404 {421 {
...@@ -411,21 +428,27 @@ pub fn uninstall() {...@@ -411,21 +428,27 @@ pub fn uninstall() {
411 let _ = fs::remove_dir(folder);428 let _ = fs::remove_dir(folder);
412 }429 }
413 }430 }
414 refresh_icon_cache(&data);431 refresh_caches(&data);
415 match failed {432 match failed {
416 Some(error) => crate::platform::alert("Couldn't uninstall Snowbound", &error.to_string()),433 Some(error) => crate::platform::alert("Couldn't uninstall Snowbound", &error.to_string()),
417 None => *INSTALLED.lock().unwrap() = Installed::No,434 None => *INSTALLED.lock().unwrap() = Installed::No,
418 }435 }
419}436}
420437
421/// Brings the icon theme's cache up to date where the user keeps one.438/// Brings the icon theme's cache, the file types and the apps opening them up to date, where
422fn refresh_icon_cache(data: &Path) {439/// the desktop has the tools.
423 let _ = Command::new("gtk-update-icon-cache")440fn refresh_caches(data: &Path) {
424 .arg("-q")441 for (tool, folder) in [
425 .arg(data.join("icons/hicolor"))442 ("gtk-update-icon-cache", "icons/hicolor"),
426 .stdout(Stdio::null())443 ("update-mime-database", "mime"),
427 .stderr(Stdio::null())444 ("update-desktop-database", "applications"),
428 .status();445 ] {
446 let _ = Command::new(tool)
447 .arg(data.join(folder))
448 .stdout(Stdio::null())
449 .stderr(Stdio::null())
450 .status();
451 }
429}452}
430453
431#[cfg(test)]454#[cfg(test)]
...@@ -444,7 +467,7 @@ mod tests {...@@ -444,7 +467,7 @@ mod tests {
444 #[test]467 #[test]
445 fn only_the_hidden_entry_names_an_owner() {468 fn only_the_hidden_entry_names_an_owner() {
446 let installed = entry_text(Path::new("/bin/snowbound"), APP_ID);469 let installed = entry_text(Path::new("/bin/snowbound"), APP_ID);
447 assert!(installed.contains("\nExec=\"/bin/snowbound\"\n"));470 assert!(installed.contains("\nExec=\"/bin/snowbound\" %F\n"));
448 assert!(installed.contains(&format!("\nIcon={APP_ID}\n")));471 assert!(installed.contains(&format!("\nIcon={APP_ID}\n")));
449 assert_eq!(portable_owner(&installed), None);472 assert_eq!(portable_owner(&installed), None);
450 let hidden = installed + &format!("NoDisplay=true\n{PORTABLE}=42\n");473 let hidden = installed + &format!("NoDisplay=true\n{PORTABLE}=42\n");
crates/snowbound/src/icloud_linux.rs+6-1
...@@ -11,7 +11,11 @@ pub fn folder() -> Option<PathBuf> {...@@ -11,7 +11,11 @@ pub fn folder() -> Option<PathBuf> {
11 None11 None
12}12}
1313
14pub fn look_up(_: impl Fn() + Send + 'static) {}14pub fn look_up(_: impl Fn() + Send + Sync + 'static) {}
15
16pub fn download(_: &Path) -> usize {
17 0
18}
1519
16pub fn drive() -> Option<PathBuf> {20pub fn drive() -> Option<PathBuf> {
17 None21 None
...@@ -19,6 +23,7 @@ pub fn drive() -> Option<PathBuf> {...@@ -19,6 +23,7 @@ pub fn drive() -> Option<PathBuf> {
1923
20pub fn section(24pub fn section(
21 notebook: &Notebook,25 notebook: &Notebook,
26 _: &Path,
22 path: &str,27 path: &str,
23 notify: impl Fn() + Send + 'static,28 notify: impl Fn() + Send + 'static,
24) -> Result<Section, notebook::Error> {29) -> Result<Section, notebook::Error> {
crates/snowbound/src/icloud_macos.rs+95-10
...@@ -77,29 +77,45 @@ pub fn ubiquitous(path: &Path) -> bool {...@@ -77,29 +77,45 @@ pub fn ubiquitous(path: &Path) -> bool {
77 })77 })
78}78}
7979
80/// The app's own folder in iCloud Drive as `look_up` last found it.80/// The app's own folder in iCloud Drive as `look_up` last found it, and what reports its
81static FOLDER: Mutex<Option<PathBuf>> = Mutex::new(None);81/// notebooks coming and going.
82static FOLDER: Mutex<Option<(PathBuf, Option<Presenter>)>> = Mutex::new(None);
8283
83/// The app's own folder in iCloud Drive (its container's Documents, shown as iCloud Drive's84/// The app's own folder in iCloud Drive (its container's Documents, shown as iCloud Drive's
84/// Snowbound folder), as `look_up` last found it: while the app carries the container's85/// Snowbound folder), as `look_up` last found it: while the app carries the container's
85/// entitlement and iCloud Drive is on.86/// entitlement and iCloud Drive is on.
86pub fn folder() -> Option<PathBuf> {87pub fn folder() -> Option<PathBuf> {
87 FOLDER.lock().ok()?.clone()88 Some(FOLDER.lock().ok()?.as_ref()?.0.clone())
88}89}
8990
90/// Looks for the app's own folder in iCloud Drive on a thread of its own, as asking the91/// Looks for the app's own folder in iCloud Drive on a thread of its own, as asking the
91/// iCloud daemon can take a while, then calls `done`.92/// iCloud daemon can take a while, then calls `changed`, and again whenever a folder at its
92pub fn look_up(done: impl Fn() + Send + 'static) {93/// top comes or goes, as another device adds or removes a notebook.
94pub fn look_up(changed: impl Fn() + Send + Sync + 'static) {
93 std::thread::spawn(move || {95 std::thread::spawn(move || {
94 let found = container();96 let changed = std::sync::Arc::new(changed);
97 let found = container().map(|root| {
98 let report = std::sync::Arc::clone(&changed);
99 let presenter = presenter(&root, move |paths| {
100 if paths.iter().any(|path| !path.contains('/')) {
101 report();
102 }
103 });
104 (root, presenter)
105 });
95 if let Ok(mut folder) = FOLDER.lock() {106 if let Ok(mut folder) = FOLDER.lock() {
96 *folder = found;107 *folder = found;
97 }108 }
98 done();109 changed();
99 });110 });
100}111}
101112
113/// The container's Documents, or for trying the app out without its entitlement, the folder
114/// `SNOWBOUND_ICLOUD_FOLDER` names in its place.
102fn container() -> Option<PathBuf> {115fn container() -> Option<PathBuf> {
116 if let Some(folder) = std::env::var_os("SNOWBOUND_ICLOUD_FOLDER") {
117 return Some(folder.into());
118 }
103 if !available() {119 if !available() {
104 return None;120 return None;
105 }121 }
...@@ -183,6 +199,43 @@ fn present(path: &Path) -> io::Result<()> {...@@ -183,6 +199,43 @@ fn present(path: &Path) -> io::Result<()> {
183 ))199 ))
184}200}
185201
202/// Asks iCloud Drive for every file below `root` it keeps elsewhere, dataless or as a
203/// `.Name.icloud` placeholder: how many are not here yet.
204pub fn download(root: &Path) -> usize {
205 use std::os::macos::fs::MetadataExt;
206 let manager = unsafe { NSFileManager::defaultManager() };
207 let mut missing = 0;
208 let mut folders = vec![root.to_owned()];
209 while let Some(folder) = folders.pop() {
210 for entry in std::fs::read_dir(&folder).into_iter().flatten().flatten() {
211 let (path, name) = (entry.path(), entry.file_name());
212 let Ok(metadata) = entry.metadata() else {
213 continue;
214 };
215 if metadata.is_dir() {
216 folders.push(path);
217 continue;
218 }
219 let placeholder = name
220 .to_str()
221 .and_then(|name| name.strip_prefix('.')?.strip_suffix(".icloud"));
222 let file = match placeholder {
223 Some(real) => path.with_file_name(real),
224 None if metadata.st_flags() & SF_DATALESS != 0 => path,
225 None => continue,
226 };
227 missing += 1;
228 if let Some(url) = url(&file) {
229 let _ = unsafe { manager.startDownloadingUbiquitousItemAtURL_error(&url) };
230 }
231 }
232 }
233 missing
234}
235
236/// `st_flags` of a file whose contents the system keeps elsewhere until read.
237const SF_DATALESS: u32 = 0x4000_0000;
238
186/// A section file in iCloud Drive, read and published under file coordination, with the239/// A section file in iCloud Drive, read and published under file coordination, with the
187/// conflict versions iCloud keeps beside it.240/// conflict versions iCloud keeps beside it.
188pub struct Coordinated(pub PathBuf);241pub struct Coordinated(pub PathBuf);
...@@ -303,23 +356,39 @@ impl Remote for Coordinated {...@@ -303,23 +356,39 @@ impl Remote for Coordinated {
303 }356 }
304}357}
305358
306/// Opens the section at catalog `path` of a notebook in iCloud Drive.359/// Opens the section at catalog `path` of the notebook in iCloud Drive's folder `root`. One
360/// without a replica here yet waits for iCloud Drive to bring its file down, which the sync
361/// thread's reads would not.
307pub fn section(362pub fn section(
308 notebook: &Notebook,363 notebook: &Notebook,
364 root: &Path,
309 path: &str,365 path: &str,
310 notify: impl Fn() + Send + 'static,366 notify: impl Fn() + Send + 'static,
311) -> Result<Section, notebook::Error> {367) -> Result<Section, notebook::Error> {
368 if !notebook.replica_path(path)?.exists() {
369 fetch(&root.join(path))?;
370 }
312 let section = notebook.section_with(path, |file| Ok(Coordinated(file.to_owned())), notify)?;371 let section = notebook.section_with(path, |file| Ok(Coordinated(file.to_owned())), notify)?;
313 section.set_pause(PAUSE);372 section.set_pause(PAUSE);
314 Ok(section)373 Ok(section)
315}374}
316375
317/// Opens a section file in iCloud Drive on its own.376/// Waits for iCloud Drive to bring down `file`, where it keeps it elsewhere.
377fn fetch(file: &Path) -> io::Result<()> {
378 // Reading any of a dataless file brings all of it down.
379 coordinated(file, false, || {
380 use std::io::Read;
381 std::fs::File::open(file)?.read(&mut [0]).map(drop)
382 })?
383}
384
385/// Opens a section file in iCloud Drive on its own, once here.
318pub fn lone_section(386pub fn lone_section(
319 file: &Path,387 file: &Path,
320 cache: &Path,388 cache: &Path,
321 notify: impl Fn() + Send + 'static,389 notify: impl Fn() + Send + 'static,
322) -> Result<Section, notebook::Error> {390) -> Result<Section, notebook::Error> {
391 fetch(file)?;
323 let section = Section::open_with(file, cache, |file| Ok(Coordinated(file.to_owned())), notify)?;392 let section = Section::open_with(file, cache, |file| Ok(Coordinated(file.to_owned())), notify)?;
324 section.set_pause(PAUSE);393 section.set_pause(PAUSE);
325 Ok(section)394 Ok(section)
...@@ -385,6 +454,22 @@ declare_class!(...@@ -385,6 +454,22 @@ declare_class!(
385 self.report(url);454 self.report(url);
386 }455 }
387456
457 #[method(presentedSubitemAtURL:didMoveToURL:)]
458 unsafe fn subitem_did_move(&self, from: &NSURL, to: &NSURL) {
459 self.report(from);
460 self.report(to);
461 }
462
463 #[method(accommodatePresentedSubitemDeletionAtURL:completionHandler:)]
464 unsafe fn accommodate_subitem_deletion(
465 &self,
466 url: &NSURL,
467 done: &block2::Block<dyn Fn(*mut objc2_foundation::NSError)>,
468 ) {
469 self.report(url);
470 done.call((std::ptr::null_mut(),));
471 }
472
388 #[method(presentedSubitemAtURL:didResolveConflictVersion:)]473 #[method(presentedSubitemAtURL:didResolveConflictVersion:)]
389 unsafe fn subitem_did_resolve_conflict_version(474 unsafe fn subitem_did_resolve_conflict_version(
390 &self,475 &self,
...@@ -491,7 +576,7 @@ mod tests {...@@ -491,7 +576,7 @@ mod tests {
491 let notebook = Notebook::create(&root, &cache, Notebook::NEW_COLOR, &page).unwrap();576 let notebook = Notebook::create(&root, &cache, Notebook::NEW_COLOR, &page).unwrap();
492 assert!(ubiquitous(&root));577 assert!(ubiquitous(&root));
493 let path = notebook.catalog().sections[0].path.clone();578 let path = notebook.catalog().sections[0].path.clone();
494 let section = section(&notebook, &path, || {}).unwrap();579 let section = section(&notebook, &root, &path, || {}).unwrap();
495 let (space, ..) = section.pages().unwrap()[0].clone();580 let (space, ..) = section.pages().unwrap()[0].clone();
496 let title = section581 let title = section
497 .page(space)582 .page(space)
crates/snowbound/src/instance.rs created+247
...@@ -0,0 +1,247 @@
1//! One running app per cache folder on Windows and Linux. A second launch hands the files it
2//! was asked to open to the first and exits, as Launch Services does for the macOS bundle: two
3//! processes on one cache would wait on each other's section replicas. The first listens on a
4//! named pipe on Windows and on a Unix socket in the cache folder on Linux.
5
6use std::path::{Path, PathBuf};
7use winit::event_loop::EventLoopProxy;
8
9/// The files a launch asks to open, separated by NULs, which no path holds.
10fn encode(paths: &[PathBuf]) -> Vec<u8> {
11 let mut bytes = Vec::new();
12 for path in paths {
13 bytes.extend(path_bytes(path));
14 bytes.push(0);
15 }
16 bytes
17}
18
19fn decode(bytes: &[u8]) -> Vec<PathBuf> {
20 bytes
21 .split(|&byte| byte == 0)
22 .filter(|path| !path.is_empty())
23 .map(bytes_path)
24 .collect()
25}
26
27/// Opens what each later launch hands over, on a thread of its own.
28fn serve<C: std::io::Read + Send + 'static>(
29 proxy: EventLoopProxy<crate::UserEvent>,
30 mut accept: impl FnMut() -> Option<C> + Send + 'static,
31) {
32 std::thread::spawn(move || {
33 while let Some(mut connection) = accept() {
34 let mut bytes = Vec::new();
35 if connection.read_to_end(&mut bytes).is_ok() {
36 let _ = proxy.send_event(crate::UserEvent::Open(decode(&bytes)));
37 }
38 }
39 });
40}
41
42#[cfg(unix)]
43fn path_bytes(path: &Path) -> Vec<u8> {
44 use std::os::unix::ffi::OsStrExt;
45 path.as_os_str().as_bytes().to_vec()
46}
47
48#[cfg(unix)]
49fn bytes_path(bytes: &[u8]) -> PathBuf {
50 use std::os::unix::ffi::OsStrExt;
51 std::ffi::OsStr::from_bytes(bytes).into()
52}
53
54#[cfg(windows)]
55fn path_bytes(path: &Path) -> Vec<u8> {
56 path.to_string_lossy().into_owned().into_bytes()
57}
58
59#[cfg(windows)]
60fn bytes_path(bytes: &[u8]) -> PathBuf {
61 String::from_utf8_lossy(bytes).into_owned().into()
62}
63
64#[cfg(target_os = "linux")]
65pub use self::linux::*;
66
67#[cfg(target_os = "linux")]
68mod linux {
69 use super::*;
70 use std::{
71 io::{ErrorKind, Write},
72 os::unix::net::{UnixListener, UnixStream},
73 };
74
75 /// Listening, unless the socket couldn't be made.
76 pub struct Instance(Option<UnixListener>);
77
78 /// This launch as the running app for `cache`, or None once the running one took `paths`.
79 pub fn claim(cache: &Path, paths: &[PathBuf]) -> Option<Instance> {
80 let socket = cache.join("instance.sock");
81 for _ in 0..3 {
82 match UnixStream::connect(&socket) {
83 Ok(mut stream) => {
84 if stream.write_all(&encode(paths)).is_ok() {
85 return None;
86 }
87 }
88 // The socket of an app that ended without removing it.
89 Err(error) if error.kind() == ErrorKind::ConnectionRefused => {
90 let _ = std::fs::remove_file(&socket);
91 }
92 Err(_) => {}
93 }
94 let _ = std::fs::create_dir_all(cache);
95 match UnixListener::bind(&socket) {
96 Ok(listener) => return Some(Instance(Some(listener))),
97 // Another launch bound it first.
98 Err(error) if error.kind() == ErrorKind::AddrInUse => {}
99 Err(error) => {
100 eprintln!("Cannot listen at {}: {error}", socket.display());
101 break;
102 }
103 }
104 }
105 Some(Instance(None))
106 }
107
108 impl Instance {
109 pub fn serve(self, proxy: EventLoopProxy<crate::UserEvent>) {
110 if let Some(listener) = self.0 {
111 super::serve(proxy, move || listener.incoming().find_map(Result::ok));
112 }
113 }
114 }
115}
116
117#[cfg(windows)]
118pub use self::windows::*;
119
120#[cfg(windows)]
121mod windows {
122 use super::*;
123 use std::{fs::File, io::Write, os::windows::io::FromRawHandle};
124 use windows_sys::Win32::{
125 Foundation::{
126 ERROR_PIPE_BUSY, ERROR_PIPE_CONNECTED, GENERIC_WRITE, GetLastError,
127 INVALID_HANDLE_VALUE,
128 },
129 Storage::FileSystem::{
130 CreateFileW, FILE_FLAG_FIRST_PIPE_INSTANCE, OPEN_EXISTING, PIPE_ACCESS_INBOUND,
131 },
132 System::Pipes::{
133 ConnectNamedPipe, CreateNamedPipeW, PIPE_REJECT_REMOTE_CLIENTS, PIPE_TYPE_BYTE,
134 PIPE_UNLIMITED_INSTANCES, PIPE_WAIT, WaitNamedPipeW,
135 },
136 UI::WindowsAndMessaging::{ASFW_ANY, AllowSetForegroundWindow},
137 };
138
139 pub struct Instance {
140 name: Vec<u16>,
141 /// The pipe's first end, unless the name couldn't be claimed.
142 first: Option<File>,
143 }
144
145 /// The pipe named for `cache`, which a pipe's name may hold but for its backslashes.
146 fn pipe_name(cache: &Path) -> Vec<u16> {
147 let cache = cache.to_string_lossy().replace('\\', "/").to_lowercase();
148 format!(r"\\.\pipe\Snowbound\{cache}")
149 .encode_utf16()
150 .chain([0])
151 .collect()
152 }
153
154 /// A new end of the pipe for the next launch to write to; the first claims the name.
155 fn listen(name: &[u16], first: bool) -> Option<File> {
156 let flags = if first {
157 FILE_FLAG_FIRST_PIPE_INSTANCE
158 } else {
159 0
160 };
161 let handle = unsafe {
162 CreateNamedPipeW(
163 name.as_ptr(),
164 PIPE_ACCESS_INBOUND | flags,
165 PIPE_TYPE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS,
166 PIPE_UNLIMITED_INSTANCES,
167 0,
168 64 * 1024,
169 0,
170 std::ptr::null(),
171 )
172 };
173 (handle != INVALID_HANDLE_VALUE).then(|| unsafe { File::from_raw_handle(handle) })
174 }
175
176 /// This launch as the running app for `cache`, or None once the running one took `paths`.
177 pub fn claim(cache: &Path, paths: &[PathBuf]) -> Option<Instance> {
178 let name = pipe_name(cache);
179 for _ in 0..3 {
180 let handle = unsafe {
181 CreateFileW(
182 name.as_ptr(),
183 GENERIC_WRITE,
184 0,
185 std::ptr::null(),
186 OPEN_EXISTING,
187 0,
188 std::ptr::null_mut(),
189 )
190 };
191 if handle != INVALID_HANDLE_VALUE {
192 // The running app may then bring its window to the front.
193 unsafe { AllowSetForegroundWindow(ASFW_ANY) };
194 let mut pipe = unsafe { File::from_raw_handle(handle) };
195 if pipe.write_all(&encode(paths)).is_ok() {
196 return None;
197 }
198 } else if unsafe { GetLastError() } == ERROR_PIPE_BUSY {
199 unsafe { WaitNamedPipeW(name.as_ptr(), 2000) };
200 continue;
201 }
202 if let Some(first) = listen(&name, true) {
203 return Some(Instance {
204 name,
205 first: Some(first),
206 });
207 }
208 }
209 eprintln!("Cannot reach the running Snowbound; opening another.");
210 Some(Instance { name, first: None })
211 }
212
213 impl Instance {
214 pub fn serve(self, proxy: EventLoopProxy<crate::UserEvent>) {
215 let Instance { name, first } = self;
216 let mut waiting = first;
217 super::serve(proxy, move || {
218 loop {
219 let pipe = waiting.take()?;
220 let handle = std::os::windows::io::AsRawHandle::as_raw_handle(&pipe);
221 let connected = unsafe { ConnectNamedPipe(handle, std::ptr::null_mut()) } != 0
222 || unsafe { GetLastError() } == ERROR_PIPE_CONNECTED;
223 // The next end opens before this one closes, so the name never lapses.
224 waiting = listen(&name, false);
225 if connected {
226 return Some(pipe);
227 }
228 }
229 });
230 }
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 #[test]
239 fn handed_paths_read_back() {
240 let paths = [
241 PathBuf::from("/notes/Personal/Garden.one"),
242 PathBuf::from("/notes/a b/Open Notebook.onetoc2"),
243 ];
244 assert_eq!(decode(&encode(&paths)), paths);
245 assert!(decode(&encode(&[])).is_empty());
246 }
247}
crates/snowbound/src/keys.rs created+534
...@@ -0,0 +1,534 @@
1//! Options' Keyboard: every command in the table under its menu with its chords, which a
2//! click records anew, and which OK puts in use.
3
4use crate::commands::{self, COMMANDS, Chord, Id as Command, Keymap, Unusable};
5use crate::options::found;
6use accesskit::Role;
7use canvas::editor::Toggle;
8use draw::edit::{Key, NamedKey, Platform};
9use ui::{Axis, Flags, Spec, Ui, children, fill, fit, px};
10
11/// The menus the table's commands fall under, each from its first command on.
12const GROUPS: [(&str, Command); 9] = [
13 ("Snowbound", Command::Settings),
14 ("File", Command::NewNotebook),
15 ("Edit", Command::Undo),
16 ("View", Command::GoTo),
17 ("Insert", Command::Table),
18 ("Draw", Command::SelectType),
19 ("Format", Command::Toggle(Toggle::Bold)),
20 ("Tags", Command::CustomizeTags),
21 ("Help", Command::Help),
22];
23
24/// The Keyboard section's keymap until OK keeps it, and the chord being recorded.
25pub struct Keyboard {
26 pub keymap: Keymap,
27 recording: Option<Recording>,
28}
29
30struct Recording {
31 command: Command,
32 /// The place of the chord the new one replaces; `None` adds one.
33 replacing: Option<usize>,
34 refused: Option<Refusal>,
35}
36
37enum Refusal {
38 Unusable(Chord, Unusable),
39 /// Run by another command, or a tag.
40 Taken(Chord, Command),
41}
42
43/// Whether every word of `query` is in command `id`'s title, menu or `chords`.
44fn hit(query: &[String], id: Command, chords: &[Chord]) -> bool {
45 let labels: Vec<String> = chords.iter().map(|chord| label(*chord)).collect();
46 let mut texts = vec![commands::command(id).title, group(id)];
47 texts.extend(labels.iter().map(String::as_str));
48 found(query, &texts)
49}
50
51/// Whether the Options search finds a command by `query`, with the chords in use.
52pub fn searched(query: &[String]) -> bool {
53 let keymap = Keymap::current();
54 (COMMANDS.iter()).any(|command| {
55 hit(
56 query,
57 command.id,
58 keymap.chords(command.id, Platform::CURRENT),
59 )
60 })
61}
62
63/// The heading of the menu `id` falls under.
64fn group(id: Command) -> &'static str {
65 let at = |id| COMMANDS.iter().position(|command| command.id == id);
66 let place = at(id).unwrap_or_default();
67 GROUPS
68 .iter()
69 .rev()
70 .find(|(_, first)| at(*first).is_some_and(|first| first <= place))
71 .map_or("", |(name, _)| name)
72}
73
74fn label(chord: Chord) -> String {
75 chord.label(Platform::CURRENT)
76}
77
78fn title(id: Command) -> String {
79 match id {
80 Command::Tag(place) => format!("Tag {}", place + 1),
81 id => commands::command(id).title.trim_end_matches('…').to_owned(),
82 }
83}
84
85impl Keyboard {
86 pub fn new() -> Self {
87 Self {
88 keymap: Keymap::current(),
89 recording: None,
90 }
91 }
92
93 pub fn recording(&self) -> bool {
94 self.recording.is_some()
95 }
96
97 /// Takes a key pressed while recording: Escape stops, and a chord is given the command
98 /// unless it is unusable or another's. Reports whether the key was taken.
99 pub fn key(&mut self, key: &Key, modifiers: draw::edit::Modifiers) -> bool {
100 let Some(recording) = &mut self.recording else {
101 return false;
102 };
103 let chord = match key {
104 Key::Named(NamedKey::Escape) => {
105 self.recording = None;
106 return true;
107 }
108 Key::Named(NamedKey::Modifier | NamedKey::Other) => return true,
109 key => commands::pressed(key, modifiers),
110 };
111 let Some(chord) = chord else {
112 return true;
113 };
114 let platform = Platform::CURRENT;
115 if let Some(why) = chord.unusable(platform) {
116 recording.refused = Some(Refusal::Unusable(chord, why));
117 return true;
118 }
119 match self.keymap.ran(chord, platform) {
120 Some(owner) if owner == recording.command => self.recording = None,
121 Some(owner) => recording.refused = Some(Refusal::Taken(chord, owner)),
122 None => self.bind(chord),
123 }
124 true
125 }
126
127 /// Gives the recorded command `chord` in place of the one it replaces.
128 fn bind(&mut self, chord: Chord) {
129 let Some(recording) = self.recording.take() else {
130 return;
131 };
132 let mut chords = self.chords(recording.command);
133 match recording.replacing {
134 Some(place) => chords[place] = chord,
135 None => chords.push(chord),
136 }
137 self.keymap.set(recording.command, chords);
138 }
139
140 fn chords(&self, id: Command) -> Vec<Chord> {
141 self.keymap.chords(id, Platform::CURRENT).to_vec()
142 }
143
144 /// Takes `chord` from its owner, or with `swap` gives the owner the chord replaced, then
145 /// binds it.
146 fn take(&mut self, chord: Chord, owner: Command, swap: bool) {
147 let replaced = (self.recording.as_ref())
148 .and_then(|recording| Some(self.chords(recording.command)[recording.replacing?]));
149 // A tag yields its chord to the command given it by itself.
150 if !matches!(owner, Command::Tag(_)) {
151 let chords = self
152 .chords(owner)
153 .into_iter()
154 .filter_map(|held| match (held == chord, replaced) {
155 (false, _) => Some(held),
156 (true, Some(replaced)) if swap => Some(replaced),
157 (true, _) => None,
158 })
159 .collect();
160 self.keymap.set(owner, chords);
161 }
162 self.bind(chord);
163 }
164
165 /// Builds the section in the box open in `ui`: the commands matching every word of
166 /// `query` in their title, menu or chords, or all where none match.
167 pub fn build(&mut self, ui: &mut Ui, query: &[String]) {
168 let theme = ui.theme.clone();
169 let row = theme.font_size * 2.0;
170 ui.open(
171 "keyboard",
172 Spec {
173 axis: Axis::Y,
174 size: [fill(), children()],
175 gap: 2.0,
176 ..Spec::default()
177 },
178 );
179 ui.open(
180 "bar",
181 Spec {
182 size: [fill(), px(row)],
183 gap: 8.0,
184 ..Spec::default()
185 },
186 );
187 ui.leaf(
188 "hint",
189 Spec {
190 flags: Flags::CLIP,
191 size: [fill(), px(row)],
192 text: Some("Click a shortcut to change it"),
193 color: Some(theme.text_dim),
194 ..Spec::default()
195 },
196 );
197 if ui::button(ui, "reset-all", "Reset All").clicked {
198 self.keymap = Keymap::default();
199 self.recording = None;
200 }
201 ui.close();
202 let listed: Vec<(Command, Vec<Chord>)> = COMMANDS
203 .iter()
204 .filter(|command| commands::offered(command.id))
205 .map(|command| (command.id, self.chords(command.id)))
206 .collect();
207 let matching = |(id, chords): &&(Command, Vec<Chord>)| hit(query, *id, chords);
208 let some = listed.iter().any(|command| matching(&command));
209 let mut heading = "";
210 for (id, chords) in listed.iter().filter(|command| !some || matching(command)) {
211 let group = group(*id);
212 if group != heading {
213 heading = group;
214 ui.leaf(
215 ("group", group),
216 Spec {
217 size: [fill(), px(row)],
218 text: Some(group),
219 bold: true,
220 color: Some(theme.text_dim),
221 role: Some(Role::Heading),
222 ..Spec::default()
223 },
224 );
225 }
226 self.command_row(ui, *id, chords);
227 if self
228 .recording
229 .as_ref()
230 .is_some_and(|recording| recording.command == *id)
231 {
232 self.status(ui);
233 }
234 }
235 ui.close();
236 }
237
238 /// A command's row: its title, a chip per chord to record anew, one to add a chord, and
239 /// Reset where the user changed its chords.
240 fn command_row(&mut self, ui: &mut Ui, id: Command, chords: &[Chord]) {
241 let theme = ui.theme.clone();
242 let height = theme.font_size * 1.9;
243 let row = ui.open(
244 ("command", format!("{id:?}")),
245 Spec {
246 size: [fill(), px(height)],
247 gap: 4.0,
248 pad: [6.0, 2.0],
249 radius: 4.0,
250 hover_fill: Some(theme.hover()),
251 role: Some(Role::ListItem),
252 ..Spec::default()
253 },
254 );
255 let title = title(id);
256 if let Some(node) = ui.access(row) {
257 node.set_label(title.as_str());
258 }
259 ui.leaf(
260 "title",
261 Spec {
262 flags: Flags::CLIP,
263 size: [fill(), px(height - 4.0)],
264 text: Some(&title),
265 bold: self.keymap.customized(id),
266 ..Spec::default()
267 },
268 );
269 let recording = self
270 .recording
271 .as_ref()
272 .filter(|recording| recording.command == id)
273 .map(|recording| recording.replacing);
274 let chip = |ui: &mut Ui, part: (usize, bool), text: &str, live: bool| {
275 ui.leaf(
276 part,
277 Spec {
278 flags: Flags::CLICKABLE,
279 size: [fit(), px(height - 4.0)],
280 text: Some(text),
281 color: Some(if live { theme.accent } else { theme.text }),
282 fill: Some(theme.chip),
283 hover_border: Some(theme.accent),
284 border: live.then_some(theme.accent),
285 radius: 4.0,
286 pad: [8.0, 0.0],
287 center: true,
288 role: Some(Role::Button),
289 ..Spec::default()
290 },
291 )
292 .clicked
293 };
294 for (place, chord) in chords.iter().enumerate() {
295 let live = recording == Some(Some(place));
296 let text = if live {
297 "Type shortcut…".to_owned()
298 } else {
299 label(*chord)
300 };
301 if chip(ui, (place, false), &text, live) {
302 self.record(ui, id, Some(place));
303 }
304 }
305 let adding = recording == Some(None);
306 let text = if adding { "Type shortcut…" } else { "+" };
307 if chip(ui, (chords.len(), true), text, adding) {
308 self.record(ui, id, None);
309 }
310 if let Some(node) = ui.access(ui.id((chords.len(), true))) {
311 node.set_label(format!("Add shortcut for {title}"));
312 }
313 if self.keymap.customized(id) && ui::button(ui, "reset", "Reset").clicked {
314 self.keymap
315 .set(id, commands::command(id).chords(Platform::CURRENT).to_vec());
316 self.recording = None;
317 }
318 ui.close();
319 }
320
321 /// Records a chord for `id`, replacing its chord at `replacing` or adding one; the
322 /// keys go to the recording, not the search.
323 fn record(&mut self, ui: &mut Ui, id: Command, replacing: Option<usize>) {
324 self.recording = Some(Recording {
325 command: id,
326 replacing,
327 refused: None,
328 });
329 ui.set_focus(None);
330 }
331
332 /// The line under the list: what the recording waits for, or why it refused a chord,
333 /// with what can be done about it.
334 fn status(&mut self, ui: &mut Ui) {
335 let theme = ui.theme.clone();
336 let row = theme.font_size * 2.0;
337 let mac = Platform::CURRENT == Platform::MacOs;
338 let text = match &self.recording {
339 None => "Click a shortcut to change it".to_owned(),
340 Some(recording) => match recording.refused {
341 None => format!("Type a shortcut for {}", title(recording.command)),
342 Some(Refusal::Unusable(chord, Unusable::Typing)) => format!(
343 "{} types text. Include {}",
344 label(chord),
345 if mac { "⌘ or ⌃" } else { "Ctrl or Alt" }
346 ),
347 Some(Refusal::Unusable(chord, Unusable::Editing)) => {
348 format!("{} moves through text. Type another", label(chord))
349 }
350 Some(Refusal::Unusable(chord, Unusable::System(item))) => {
351 format!("{} is macOS's {item}. Type another", label(chord))
352 }
353 Some(Refusal::Taken(chord, owner)) => {
354 format!("{} already runs {}", label(chord), title(owner))
355 }
356 },
357 };
358 let status = ui.open(
359 "status",
360 Spec {
361 size: [fill(), px(row)],
362 gap: 8.0,
363 ..Spec::default()
364 },
365 );
366 if let Some(node) = ui.access(status) {
367 node.set_live(accesskit::Live::Polite);
368 }
369 ui.leaf(
370 "text",
371 Spec {
372 flags: Flags::CLIP,
373 size: [fill(), px(row)],
374 text: Some(&text),
375 color: Some(theme.text_dim),
376 pad: [6.0, 0.0],
377 role: Some(Role::Label),
378 ..Spec::default()
379 },
380 );
381 if let Some(recording) = &self.recording {
382 let taken = match recording.refused {
383 Some(Refusal::Taken(chord, owner)) => Some((chord, owner)),
384 _ => None,
385 };
386 let (command, replacing) = (recording.command, recording.replacing);
387 if let Some((chord, owner)) = taken {
388 // Only a command, not a tag, can take the chord replaced in exchange.
389 if replacing.is_some()
390 && !matches!(owner, Command::Tag(_))
391 && ui::button(ui, "swap", "Swap").clicked
392 {
393 self.take(chord, owner, true);
394 }
395 if ui::button(ui, "replace", "Replace").clicked {
396 self.take(chord, owner, false);
397 }
398 } else if let Some(place) = replacing
399 && ui::button(ui, "remove", "Remove").clicked
400 {
401 let mut chords = self.chords(command);
402 chords.remove(place);
403 self.keymap.set(command, chords);
404 self.recording = None;
405 }
406 if self.recording.is_some() && ui::button(ui, "cancel", "Cancel").clicked {
407 self.recording = None;
408 }
409 }
410 ui.close();
411 }
412}
413
414impl crate::State {
415 /// Gives a key pressed while Options records a chord to the recording.
416 pub(crate) fn record_chord(&mut self, event: &ui::Event) -> bool {
417 let (Some(options), ui::Event::Key { key, .. }) = (&mut self.options, event) else {
418 return false;
419 };
420 // A field focused since recording began takes the keys instead.
421 if self.ui.focused().is_some() {
422 options.keyboard.recording = None;
423 return false;
424 }
425 let modifiers = ui::edit_modifiers(self.ui.modifiers());
426 let taken = options.keyboard.key(&ui::edit_key(key), modifiers);
427 if taken {
428 self.window.request_redraw();
429 }
430 taken
431 }
432
433 /// Whether Options is recording a chord, which the menu bar must then leave to it.
434 pub(crate) fn recording_chord(&self) -> bool {
435 self.options
436 .as_ref()
437 .is_some_and(|options| options.keyboard.recording())
438 }
439
440 /// Puts `keymap` in use, and in the menu bar where it changes.
441 pub(crate) fn install_keymap(&self, keymap: Keymap) {
442 if keymap.install() {
443 crate::platform::install_menu();
444 crate::platform::update_tag_menu(&self.tags);
445 }
446 }
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452 use draw::edit::Modifiers;
453
454 fn shortcut() -> Modifiers {
455 Modifiers {
456 command: true,
457 control: Platform::CURRENT != Platform::MacOs,
458 ..Modifiers::default()
459 }
460 }
461
462 fn typed(keyboard: &mut Keyboard, key: &str, modifiers: Modifiers) {
463 assert!(keyboard.key(&Key::Character(key.into()), modifiers));
464 }
465
466 fn recording(id: Command, replacing: Option<usize>) -> Keyboard {
467 Keyboard {
468 keymap: Keymap::default(),
469 recording: Some(Recording {
470 command: id,
471 replacing,
472 refused: None,
473 }),
474 }
475 }
476
477 #[test]
478 fn a_recorded_chord_replaces_adds_or_swaps_and_refuses_typing() {
479 let bold = Command::Toggle(Toggle::Bold);
480 let italic = Command::Toggle(Toggle::Italic);
481 let platform = Platform::CURRENT;
482 let mut keyboard = recording(Command::PageList, None);
483 typed(&mut keyboard, "j", Modifiers::default());
484 assert!(matches!(
485 keyboard.recording.as_ref().unwrap().refused,
486 Some(Refusal::Unusable(_, Unusable::Typing))
487 ));
488 typed(&mut keyboard, "j", shortcut());
489 assert!(!keyboard.recording());
490 let chord = commands::cmd('j');
491 assert_eq!(keyboard.keymap.chords(Command::PageList, platform), [chord]);
492 assert_eq!(
493 keyboard.keymap.ran(chord, platform),
494 Some(Command::PageList)
495 );
496
497 // Bold's chord, taken from Italic, which gets Bold's in exchange.
498 let mut keyboard = recording(bold, Some(0));
499 typed(&mut keyboard, "i", shortcut());
500 assert!(matches!(
501 keyboard.recording.as_ref().unwrap().refused,
502 Some(Refusal::Taken(_, owner)) if owner == italic
503 ));
504 keyboard.take(commands::cmd('i'), italic, true);
505 assert_eq!(keyboard.keymap.chords(bold, platform), [commands::cmd('i')]);
506 assert_eq!(
507 keyboard.keymap.chords(italic, platform),
508 [commands::cmd('b')]
509 );
510
511 // A tag's chord, which the tag then goes without.
512 let mut keyboard = recording(Command::PageList, None);
513 typed(&mut keyboard, "1", shortcut());
514 keyboard.take(commands::cmd('1'), Command::Tag(0), false);
515 assert_eq!(
516 keyboard.keymap.ran(commands::cmd('1'), platform),
517 Some(Command::PageList)
518 );
519
520 // Chords set back to the defaults keep nothing.
521 let mut keyboard = recording(bold, Some(0));
522 typed(&mut keyboard, "b", shortcut());
523 assert_eq!(keyboard.keymap, Keymap::default());
524 }
525
526 #[test]
527 fn every_command_falls_under_a_menu() {
528 assert_eq!(group(Command::Settings), "Snowbound");
529 assert_eq!(group(Command::Paste), "Edit");
530 assert_eq!(group(Command::Sidebar), "View");
531 assert_eq!(group(Command::Toggle(Toggle::Italic)), "Format");
532 assert_eq!(group(Command::CheckForUpdates), "Help");
533 }
534}
crates/snowbound/src/library.rs+324-19
...@@ -3,6 +3,7 @@...@@ -3,6 +3,7 @@
3use canvas::{editor::NoteTag, gpu::TagArt};3use canvas::{editor::NoteTag, gpu::TagArt};
4use notebook::discover::{Folder, SectionState};4use notebook::discover::{Folder, SectionState};
5use notebook::session::{Background, Notebook, Section};5use notebook::session::{Background, Notebook, Section};
6use notebook::sidecar::themes::Themes;
6use notebook::smb::{Client, Credentials};7use notebook::smb::{Client, Credentials};
7use std::{8use std::{
8 error::Error,9 error::Error,
...@@ -312,6 +313,11 @@ pub struct Library {...@@ -312,6 +313,11 @@ pub struct Library {
312 watch: Option<Arc<crate::watch::Watch>>,313 watch: Option<Arc<crate::watch::Watch>>,
313 /// The art the notebook's tags draw with, as its `.snowbound` folder maps them.314 /// The art the notebook's tags draw with, as its `.snowbound` folder maps them.
314 tag_art: Mutex<Arc<TagArt>>,315 tag_art: Mutex<Arc<TagArt>>,
316 /// Sections opened ahead of being shown, or left open after, most recent first, which
317 /// `open` hands out before opening any.
318 kept: Mutex<crate::prefetch::Recent<String, Section>>,
319 /// The notebook's style themes, and when they were read.
320 themes: Mutex<Option<(Instant, Arc<Themes>)>>,
315}321}
316322
317impl Library {323impl Library {
...@@ -329,7 +335,7 @@ impl Library {...@@ -329,7 +335,7 @@ impl Library {
329 .and_then(|login| Self::on_share(location, mount.clone(), login, cache))335 .and_then(|login| Self::on_share(location, mount.clone(), login, cache))
330 .unwrap_or_else(|reason| Self {336 .unwrap_or_else(|reason| Self {
331 location: location.to_owned(),337 location: location.to_owned(),
332 name: mount.name(),338 name: display_name(cache, location).unwrap_or_else(|| mount.name()),
333 notebook: Err(reason),339 notebook: Err(reason),
334 server: None,340 server: None,
335 notice: None,341 notice: None,
...@@ -337,6 +343,8 @@ impl Library {...@@ -337,6 +343,8 @@ impl Library {
337 background: None,343 background: None,
338 watch: None,344 watch: None,
339 tag_art: Default::default(),345 tag_art: Default::default(),
346 kept: Mutex::new(crate::prefetch::Recent::new(KEPT)),
347 themes: Default::default(),
340 });348 });
341 }349 }
342 let mut notice = None;350 let mut notice = None;
...@@ -358,12 +366,14 @@ impl Library {...@@ -358,12 +366,14 @@ impl Library {
358 };366 };
359 Self {367 Self {
360 location: location.to_owned(),368 location: location.to_owned(),
361 name: file_name(Path::new(location)),369 name: display_name(cache, location).unwrap_or_else(|| file_name(Path::new(location))),
362 background,370 background,
363 watch,371 watch,
364 tag_art: Mutex::new(Arc::new(372 tag_art: Mutex::new(Arc::new(
365 notebook.as_ref().map(read_tag_art).unwrap_or_default(),373 notebook.as_ref().map(read_tag_art).unwrap_or_default(),
366 )),374 )),
375 kept: Mutex::new(crate::prefetch::Recent::new(KEPT)),
376 themes: Default::default(),
367 notebook: notebook.map(Some).map_err(|error| error.to_string()),377 notebook: notebook.map(Some).map_err(|error| error.to_string()),
368 server: None,378 server: None,
369 notice,379 notice,
...@@ -397,11 +407,13 @@ impl Library {...@@ -397,11 +407,13 @@ impl Library {
397 background.watch(notebook.replicas());407 background.watch(notebook.replicas());
398 Ok(Self {408 Ok(Self {
399 location: location.to_owned(),409 location: location.to_owned(),
400 name: match server_address(location) {410 name: display_name(cache, location).unwrap_or_else(|| match server_address(location) {
401 Some(_) => server.mount.name(),411 Some(_) => server.mount.name(),
402 None => file_name(Path::new(location)),412 None => file_name(Path::new(location)),
403 },413 }),
404 tag_art: Mutex::new(Arc::new(read_tag_art(&notebook))),414 tag_art: Mutex::new(Arc::new(read_tag_art(&notebook))),
415 kept: Mutex::new(crate::prefetch::Recent::new(KEPT)),
416 themes: Default::default(),
405 notebook: Ok(Some(notebook)),417 notebook: Ok(Some(notebook)),
406 server: Some(server),418 server: Some(server),
407 notice: None,419 notice: None,
...@@ -411,6 +423,17 @@ impl Library {...@@ -411,6 +423,17 @@ impl Library {
411 })423 })
412 }424 }
413425
426 /// Names the notebook `name` on this computer, leaving its folder as it is, as OneNote
427 /// 2010's Notebook Properties does; the notebook read again with `with` shows it.
428 pub fn set_display_name(&self, name: &str) -> io::Result<()> {
429 let file = display_names(&self.cache);
430 let mut names = read_display_names(&file);
431 names.insert(self.location.clone(), name.to_owned());
432 let partial = file.with_extension("partial");
433 std::fs::write(&partial, serde_json::to_vec_pretty(&names)?)?;
434 std::fs::rename(partial, file)
435 }
436
414 /// The art the notebook's tags draw with.437 /// The art the notebook's tags draw with.
415 pub fn tag_art(&self) -> Arc<TagArt> {438 pub fn tag_art(&self) -> Arc<TagArt> {
416 Arc::clone(439 Arc::clone(
...@@ -455,6 +478,79 @@ impl Library {...@@ -455,6 +478,79 @@ impl Library {
455 });478 });
456 }479 }
457480
481 /// The notebook's style themes, read again after a while so other machines' changes
482 /// reach pages opened later; a section opened on its own has none.
483 pub fn themes(&self) -> Arc<Themes> {
484 let mut kept = self
485 .themes
486 .lock()
487 .unwrap_or_else(|poisoned| poisoned.into_inner());
488 if let Some((read, themes)) = &*kept
489 && read.elapsed() < Duration::from_secs(30)
490 {
491 return Arc::clone(themes);
492 }
493 let themes = match &self.notebook {
494 Ok(Some(notebook)) => notebook.themes().unwrap_or_else(|error| {
495 eprintln!("{}: reading its themes failed: {error}", self.location);
496 Themes::default()
497 }),
498 _ => Themes::default(),
499 };
500 let themes = Arc::new(themes);
501 *kept = Some((Instant::now(), Arc::clone(&themes)));
502 themes
503 }
504
505 /// Takes `change` into the notebook's themes from now on, and keeps it in the notebook's
506 /// folder on a thread of its own.
507 pub fn save_themes(self: &Arc<Self>, change: Themes) {
508 if !matches!(self.notebook, Ok(Some(_))) {
509 return;
510 }
511 let mut themes = (*self.themes()).clone();
512 notebook::sidecar::themes::merge(&mut themes, change.clone());
513 *self
514 .themes
515 .lock()
516 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
517 Some((Instant::now(), Arc::new(themes)));
518 let library = Arc::clone(self);
519 std::thread::spawn(move || {
520 let kept = library
521 .reopen()
522 .and_then(|notebook| Ok(notebook.save_themes(change)?));
523 if let Err(error) = kept {
524 eprintln!("{}: keeping its themes failed: {error}", library.location);
525 }
526 });
527 }
528
529 /// Deletes for good, on a thread of its own, what the notebook's recycle bin has held
530 /// unchanged past OneNote's 60 days, as OneNote 2010 prunes it a while after opening.
531 pub fn purge_recycle_bin(self: &Arc<Self>) {
532 if !matches!(self.notebook, Ok(Some(_))) {
533 return;
534 }
535 let library = Arc::clone(self);
536 std::thread::spawn(move || {
537 let now = std::time::SystemTime::now()
538 .duration_since(std::time::UNIX_EPOCH)
539 .map_or(0, |unix| unix.as_secs())
540 // Time32 counts from 1980.
541 .saturating_sub(315_532_800);
542 let purged = library.reopen().and_then(|mut notebook| {
543 Ok(notebook.purge_recycle_bin(u32::try_from(now).unwrap_or(u32::MAX))?)
544 });
545 if let Err(error) = purged {
546 eprintln!(
547 "{}: emptying the recycle bin failed: {error}",
548 library.location
549 );
550 }
551 });
552 }
553
458 /// This notebook read again, the way it was opened, for changing its structure.554 /// This notebook read again, the way it was opened, for changing its structure.
459 pub fn reopen(&self) -> Result<Notebook, Box<dyn Error>> {555 pub fn reopen(&self) -> Result<Notebook, Box<dyn Error>> {
460 Ok(match &self.server {556 Ok(match &self.server {
...@@ -482,7 +578,7 @@ impl Library {...@@ -482,7 +578,7 @@ impl Library {
482 };578 };
483 Self {579 Self {
484 location: self.location.clone(),580 location: self.location.clone(),
485 name: self.name.clone(),581 name: display_name(&self.cache, &self.location).unwrap_or_else(|| self.name.clone()),
486 notebook: Ok(Some(notebook)),582 notebook: Ok(Some(notebook)),
487 server: self.server.clone(),583 server: self.server.clone(),
488 notice: self.notice.clone(),584 notice: self.notice.clone(),
...@@ -490,6 +586,8 @@ impl Library {...@@ -490,6 +586,8 @@ impl Library {
490 background: self.background.clone(),586 background: self.background.clone(),
491 watch,587 watch,
492 tag_art: Mutex::new(self.tag_art()),588 tag_art: Mutex::new(self.tag_art()),
589 kept: Mutex::new(crate::prefetch::Recent::new(KEPT)),
590 themes: Default::default(),
493 }591 }
494 }592 }
495593
...@@ -502,6 +600,8 @@ impl Library {...@@ -502,6 +600,8 @@ impl Library {
502 background,600 background,
503 watch,601 watch,
504 tag_art: Default::default(),602 tag_art: Default::default(),
603 kept: Mutex::new(crate::prefetch::Recent::new(KEPT)),
604 themes: Default::default(),
505 notebook: Ok(Some(notebook)),605 notebook: Ok(Some(notebook)),
506 server: None,606 server: None,
507 notice: None,607 notice: None,
...@@ -521,6 +621,8 @@ impl Library {...@@ -521,6 +621,8 @@ impl Library {
521 background: None,621 background: None,
522 watch: None,622 watch: None,
523 tag_art: Default::default(),623 tag_art: Default::default(),
624 kept: Mutex::new(crate::prefetch::Recent::new(KEPT)),
625 themes: Default::default(),
524 }626 }
525 }627 }
526628
...@@ -533,7 +635,16 @@ impl Library {...@@ -533,7 +635,16 @@ impl Library {
533 ) -> Result<Section, Box<dyn Error>> {635 ) -> Result<Section, Box<dyn Error>> {
534 // The background may hold the replica for a step, which takes a network round trip,636 // The background may hold the replica for a step, which takes a network round trip,
535 // or be making it as an offline copy.637 // or be making it as an offline copy.
536 let deadline = Instant::now() + TIMEOUT * 3;638 self.open_by(path, notify, Instant::now() + TIMEOUT * 3)
639 }
640
641 /// `open`, waiting until `deadline` for whatever holds the section's replica.
642 fn open_by(
643 &self,
644 path: &str,
645 notify: impl Fn() + Send + 'static,
646 deadline: Instant,
647 ) -> Result<Section, Box<dyn Error>> {
537 let notify = Arc::new(Mutex::new(notify));648 let notify = Arc::new(Mutex::new(notify));
538 let notifier = || {649 let notifier = || {
539 let notify = Arc::clone(&notify);650 let notify = Arc::clone(&notify);
...@@ -544,6 +655,14 @@ impl Library {...@@ -544,6 +655,14 @@ impl Library {
544 }655 }
545 };656 };
546 let section = loop {657 let section = loop {
658 if let Some(kept) = self
659 .kept
660 .lock()
661 .ok()
662 .and_then(|mut kept| kept.take(&path.to_owned()))
663 {
664 return Ok(kept);
665 }
547 let opened = match (&self.notebook, &self.server) {666 let opened = match (&self.notebook, &self.server) {
548 (Ok(Some(notebook)), Some(server)) => {667 (Ok(Some(notebook)), Some(server)) => {
549 let file = match server.mount.root.as_str() {668 let file = match server.mount.root.as_str() {
...@@ -567,7 +686,7 @@ impl Library {...@@ -567,7 +686,7 @@ impl Library {
567 })686 })
568 }687 }
569 (Ok(Some(notebook)), None) if self.in_icloud() => {688 (Ok(Some(notebook)), None) if self.in_icloud() => {
570 crate::icloud::section(notebook, path, notifier())689 crate::icloud::section(notebook, Path::new(&self.location), path, notifier())
571 }690 }
572 (Ok(Some(notebook)), None) => notebook.section(path, notifier()),691 (Ok(Some(notebook)), None) => notebook.section(path, notifier()),
573 (Ok(None), _) if self.in_icloud() => {692 (Ok(None), _) if self.in_icloud() => {
...@@ -595,6 +714,49 @@ impl Library {...@@ -595,6 +714,49 @@ impl Library {
595 Ok(section)714 Ok(section)
596 }715 }
597716
717 /// Opens the section at catalog `path`, unless it is kept open already, and keeps it
718 /// open for `open` to hand out. A section held elsewhere, as the one shown is, is left.
719 pub fn prefetch(
720 &self,
721 path: &str,
722 notify: impl Fn() + Send + 'static,
723 ) -> Result<Arc<notebook::Replica>, Box<dyn Error>> {
724 let section = self.open_by(path, notify, Instant::now())?;
725 let replica = Arc::clone(section.replica());
726 self.keep(path, section);
727 Ok(replica)
728 }
729
730 /// Keeps `section`, at catalog `path`, open for `open` to hand out; the least recent
731 /// past `KEPT` close.
732 pub fn keep(&self, path: &str, section: Section) {
733 let gone = match self.kept.lock() {
734 Ok(mut kept) => kept.put(path.to_owned(), section),
735 Err(_) => vec![section],
736 };
737 if !gone.is_empty() {
738 std::thread::spawn(move || close(gone));
739 }
740 }
741
742 /// Works offline, or online again, in the background and in the sections kept open.
743 pub fn set_offline(&self, offline: bool) {
744 if let Some(background) = &self.background {
745 background.set_offline(offline);
746 }
747 if let Ok(mut kept) = self.kept.lock() {
748 kept.values_mut()
749 .for_each(|section| section.set_offline(offline));
750 }
751 }
752
753 /// Closes the sections kept open, before the notebook's files change.
754 pub fn close_kept(&self) {
755 if let Ok(mut kept) = self.kept.lock() {
756 close(kept.trim(0, |_| 1));
757 }
758 }
759
598 /// Where `file`, a section file of this notebook, sits on this computer: under the760 /// Where `file`, a section file of this notebook, sits on this computer: under the
599 /// mount it was opened from when the embedded client holds it share-relative.761 /// mount it was opened from when the embedded client holds it share-relative.
600 pub fn local(&self, file: &Path) -> Option<PathBuf> {762 pub fn local(&self, file: &Path) -> Option<PathBuf> {
...@@ -619,23 +781,60 @@ impl Library {...@@ -619,23 +781,60 @@ impl Library {
619 self.folder().is_some_and(crate::icloud::ubiquitous)781 self.folder().is_some_and(crate::icloud::ubiquitous)
620 }782 }
621783
622 /// How the notebook's files are reached.784 /// Where the notebook lives, as its reader knows the place: the server and share, iCloud
623 pub fn transport(&self) -> String {785 /// Drive, a drive or this computer, then each folder down to the notebook's, left out.
624 match (&self.server, &self.notice) {786 pub fn place(&self) -> Vec<String> {
625 (Some(server), _) => format!(787 let mut place: Vec<String> = match self
626 "Snowbound’s SMB client, {}/{}",788 .server
627 server.mount.server, server.mount.share789 .as_ref()
628 ),790 .map(|server| server.mount.clone())
629 (None, Some(_)) => "The system’s mount of the share".to_owned(),791 .or_else(|| server_address(&self.location))
630 (None, None) if self.in_icloud() => "iCloud Drive".to_owned(),792 {
631 (None, None) => "Folder on this computer".to_owned(),793 Some(mount) => {
632 }794 let host = bonjour_instance(&mount.server)
795 .unwrap_or_else(|| mount.host().trim_end_matches(".local").to_owned());
796 [host, mount.share.clone()]
797 .into_iter()
798 .chain(mount.root.split('/').map(str::to_owned))
799 .filter(|part| !part.is_empty())
800 .collect()
801 }
802 None => {
803 let path = Path::new(&self.location);
804 let icloud = self.in_icloud();
805 let this = if cfg!(target_os = "macos") {
806 "This Mac"
807 } else {
808 "This Computer"
809 };
810 let tops = [
811 (
812 icloud.then(crate::icloud::folder).flatten(),
813 "iCloud Drive/Snowbound",
814 ),
815 (icloud.then(crate::icloud::drive).flatten(), "iCloud Drive"),
816 (std::env::var_os("HOME").map(PathBuf::from), this),
817 (Some(PathBuf::from("/Volumes")), ""),
818 ];
819 let (top, rest) = tops
820 .into_iter()
821 .find_map(|(top, name)| Some((name, path.strip_prefix(top?).ok()?)))
822 .unwrap_or((this, path));
823 top.split('/')
824 .map(str::to_owned)
825 .chain(rest.iter().map(|part| part.to_string_lossy().into_owned()))
826 .filter(|part| !part.is_empty() && part != "/")
827 .collect()
828 }
829 };
830 place.pop();
831 place
633 }832 }
634833
635 /// Names the section at catalog `path` across every open notebook, for remembering834 /// Names the section at catalog `path` across every open notebook, for remembering
636 /// its pages.835 /// its pages.
637 pub fn key(&self, path: &str) -> String {836 pub fn key(&self, path: &str) -> String {
638 format!("{}\n{path}", self.location)837 key(&self.location, path)
639 }838 }
640839
641 /// The readable sections of the folder at catalog path `folder`, as tabs in its order.840 /// The readable sections of the folder at catalog path `folder`, as tabs in its order.
...@@ -660,6 +859,21 @@ impl Library {...@@ -660,6 +859,21 @@ impl Library {
660 }859 }
661 }860 }
662861
862 /// The file identity of the section at catalog `path`.
863 pub fn section_identity(&self, path: &str) -> Option<[u8; 16]> {
864 let Ok(Some(notebook)) = &self.notebook else {
865 return None;
866 };
867 let mut folders = vec![notebook.catalog()];
868 while let Some(folder) = folders.pop() {
869 if let Some(section) = folder.sections.iter().find(|section| section.path == path) {
870 return Some(section.file_id);
871 }
872 folders.extend(&folder.groups);
873 }
874 None
875 }
876
663 /// The first readable section, searching groups after sections, as OneNote opens a877 /// The first readable section, searching groups after sections, as OneNote opens a
664 /// notebook.878 /// notebook.
665 pub fn first_section(&self) -> Option<String> {879 pub fn first_section(&self) -> Option<String> {
...@@ -696,6 +910,31 @@ impl Library {...@@ -696,6 +910,31 @@ impl Library {
696 false910 false
697 }911 }
698912
913 /// The notebook's colour, COLORREF, as its table of contents holds it.
914 pub fn color(&self) -> Option<u32> {
915 self.catalog()?.toc.as_ref()?.color
916 }
917
918 /// Whether the notebook lists sections not on this device yet, as iCloud Drive keeps
919 /// them elsewhere.
920 pub fn downloading(&self) -> bool {
921 let Some(catalog) = self.catalog() else {
922 return false;
923 };
924 let mut folders = vec![catalog];
925 while let Some(folder) = folders.pop() {
926 if folder
927 .unavailable
928 .iter()
929 .any(|entry| entry.reason == notebook::discover::Reason::Evicted)
930 {
931 return true;
932 }
933 folders.extend(&folder.groups);
934 }
935 false
936 }
937
699 /// The notebook's folders of sections, for the sidebar.938 /// The notebook's folders of sections, for the sidebar.
700 pub fn catalog(&self) -> Option<&Folder> {939 pub fn catalog(&self) -> Option<&Folder> {
701 match &self.notebook {940 match &self.notebook {
...@@ -715,6 +954,28 @@ pub fn server_address(location: &str) -> Option<Mount> {...@@ -715,6 +954,28 @@ pub fn server_address(location: &str) -> Option<Mount> {
715}954}
716955
717/// Whether the folder at `path` is the notebook's recycle bin, which OneNote keeps out of its lists.956/// Whether the folder at `path` is the notebook's recycle bin, which OneNote keeps out of its lists.
957/// `Library::key` for the notebook at `location`, open or not.
958pub fn key(location: &str, path: &str) -> String {
959 format!("{location}\n{path}")
960}
961
962/// Where this computer keeps the display names notebooks were given, by location: OneNote
963/// 2010 keeps them in its local cache, never in the notebook.
964fn display_names(cache: &Path) -> PathBuf {
965 cache.join("display-names.json")
966}
967
968fn read_display_names(file: &Path) -> std::collections::BTreeMap<String, String> {
969 std::fs::read(file)
970 .ok()
971 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
972 .unwrap_or_default()
973}
974
975fn display_name(cache: &Path, location: &str) -> Option<String> {
976 read_display_names(&display_names(cache)).remove(location)
977}
978
718pub fn recycle_bin(path: &str) -> bool {979pub fn recycle_bin(path: &str) -> bool {
719 path.rsplit('/').next() == Some("OneNote_RecycleBin")980 path.rsplit('/').next() == Some("OneNote_RecycleBin")
720}981}
...@@ -781,6 +1042,17 @@ fn watcher(...@@ -781,6 +1042,17 @@ fn watcher(
781 .map(Arc::new)1042 .map(Arc::new)
782}1043}
7831044
1045/// How many sections a notebook keeps open besides the one shown.
1046const KEPT: usize = 3;
1047
1048fn close(sections: Vec<Section>) {
1049 for section in sections {
1050 if let Err(error) = section.close() {
1051 eprintln!("Synchronization stopped: {error}");
1052 }
1053 }
1054}
1055
784pub fn file_name(path: &Path) -> String {1056pub fn file_name(path: &Path) -> String {
785 path.canonicalize()1057 path.canonicalize()
786 .ok()1058 .ok()
...@@ -890,6 +1162,37 @@ pub fn locate(path: &Path) -> Located {...@@ -890,6 +1162,37 @@ pub fn locate(path: &Path) -> Located {
890mod tests {1162mod tests {
891 use super::*;1163 use super::*;
8921164
1165 /// A section kept open is what `open` hands out next, without opening it again, and
1166 /// readying a section another holds leaves it to that one at once.
1167 #[test]
1168 fn kept_sections_open_at_once() {
1169 let root = std::env::temp_dir().join(format!("snowbound-kept-{}", std::process::id()));
1170 let _ = std::fs::remove_dir_all(&root);
1171 let notebook = root.join("Personal");
1172 std::fs::create_dir_all(&notebook).unwrap();
1173 let sample =
1174 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/sample-notebook/Personal");
1175 for entry in std::fs::read_dir(sample).unwrap() {
1176 let entry = entry.unwrap();
1177 std::fs::copy(entry.path(), notebook.join(entry.file_name())).unwrap();
1178 }
1179 let library = Library::notebook(notebook.to_str().unwrap(), &root.join("cache"));
1180 let [first, second] = [0, 1].map(|tab| library.tabs("")[tab].path.clone());
1181 let readied = library.prefetch(&first, || {}).unwrap();
1182 let opened = library.open(&first, || {}).unwrap();
1183 assert!(Arc::ptr_eq(&readied, opened.replica()));
1184 drop(readied);
1185 let start = Instant::now();
1186 assert!(library.prefetch(&first, || {}).is_err());
1187 assert!(start.elapsed() < Duration::from_secs(1));
1188 library.keep(&first, opened);
1189 library.prefetch(&second, || {}).unwrap();
1190 library.close_kept();
1191 library.open(&first, || {}).unwrap().close().unwrap();
1192 drop(library);
1193 std::fs::remove_dir_all(&root).unwrap();
1194 }
1195
893 #[cfg(unix)]1196 #[cfg(unix)]
894 #[test]1197 #[test]
895 fn within_mount_resolves_symlinks() {1198 fn within_mount_resolves_symlinks() {
...@@ -1051,6 +1354,8 @@ mod tests {...@@ -1051,6 +1354,8 @@ mod tests {
1051 background: None,1354 background: None,
1052 watch: None,1355 watch: None,
1053 tag_art: Default::default(),1356 tag_art: Default::default(),
1357 kept: Mutex::new(crate::prefetch::Recent::new(KEPT)),
1358 themes: Default::default(),
1054 };1359 };
1055 let shown = |root, file| library(root).local(Path::new(file));1360 let shown = |root, file| library(root).local(Path::new(file));
1056 let under = Some(PathBuf::from("/Volumes/agent/lab/Group/New Section 1.one"));1361 let under = Some(PathBuf::from("/Volumes/agent/lab/Group/New Section 1.one"));
crates/snowbound/src/linux.rs+6-1
...@@ -206,11 +206,16 @@ impl Clipboard {...@@ -206,11 +206,16 @@ impl Clipboard {
206 .unwrap_or_default()206 .unwrap_or_default()
207 }207 }
208208
209 pub fn get_html(&mut self) -> Option<String> {
210 self.through_x11(|clipboard| clipboard.get().html().ok())
211 }
212
209 pub fn get_picture(&mut self) -> Option<Vec<u8>> {213 pub fn get_picture(&mut self) -> Option<Vec<u8>> {
210 self.through_x11(|clipboard| crate::paste::bitmap(clipboard.get_image().ok()?))214 self.through_x11(|clipboard| crate::paste::bitmap(clipboard.get_image().ok()?))
211 }215 }
212216
213 /// Files and pictures come through X11 on Wayland too, whose clipboard Xwayland shares.217 /// Files, pages and pictures come through X11 on Wayland too, whose clipboard Xwayland
218 /// shares.
214 fn through_x11<T>(219 fn through_x11<T>(
215 &mut self,220 &mut self,
216 read: impl FnOnce(&mut arboard::Clipboard) -> Option<T>,221 read: impl FnOnce(&mut arboard::Clipboard) -> Option<T>,
crates/snowbound/src/macos.rs+33
...@@ -193,6 +193,10 @@ impl Clipboard {...@@ -193,6 +193,10 @@ impl Clipboard {
193 self.0.get().file_list().unwrap_or_default()193 self.0.get().file_list().unwrap_or_default()
194 }194 }
195195
196 pub fn get_html(&mut self) -> Option<String> {
197 self.0.get().html().ok()
198 }
199
196 /// The pasteboard's PNG as copied, keeping its resolution, or else its TIFF as a PNG.200 /// The pasteboard's PNG as copied, keeping its resolution, or else its TIFF as a PNG.
197 pub fn get_picture(&mut self) -> Option<Vec<u8>> {201 pub fn get_picture(&mut self) -> Option<Vec<u8>> {
198 unsafe {202 unsafe {
...@@ -1300,9 +1304,38 @@ pub fn event_loop(headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLo...@@ -1300,9 +1304,38 @@ pub fn event_loop(headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLo
1300 let event_loop = builder.build()?;1304 let event_loop = builder.build()?;
1301 QUIT.set(event_loop.create_proxy())1305 QUIT.set(event_loop.create_proxy())
1302 .expect("Only one application event loop is created");1306 .expect("Only one application event loop is created");
1307 // Winit's delegate gains -application:openFiles:, which every macOS from 10.6 sends for
1308 // the Finder's and `open`'s documents, before the launch finishes on a cold launch.
1309 unsafe {
1310 let app = NSApplication::sharedApplication(MainThreadMarker::new_unchecked());
1311 let delegate: Retained<AnyObject> = msg_send_id![&app, delegate];
1312 let mut class = ClassBuilder::new("SnowboundApplicationDelegate", delegate.class())
1313 .expect("Unique application delegate class");
1314 class.add_method(
1315 sel!(application:openFiles:),
1316 open_files as unsafe extern "C" fn(_, _, _, _),
1317 );
1318 let class = class.register();
1319 // The subclass adds no ivars, so the existing allocation remains valid.
1320 AnyObject::set_class(&delegate, class);
1321 }
1303 Ok(event_loop)1322 Ok(event_loop)
1304}1323}
13051324
1325unsafe extern "C" fn open_files(
1326 _: &AnyObject,
1327 _: Sel,
1328 app: &NSApplication,
1329 files: &objc2_foundation::NSArray<NSString>,
1330) {
1331 if let Some(proxy) = QUIT.get() {
1332 let paths = files.iter().map(|file| file.to_string().into()).collect();
1333 let _ = proxy.send_event(crate::UserEvent::Open(paths));
1334 }
1335 // NSApplicationDelegateReplySuccess.
1336 let _: () = unsafe { msg_send![app, replyToOpenOrPrint: 0usize] };
1337}
1338
1306/// Replaces Winit's application menu with the menu bar, whose items the table validates.1339/// Replaces Winit's application menu with the menu bar, whose items the table validates.
1307pub fn install_menu() {1340pub fn install_menu() {
1308 let mtm = MainThreadMarker::new().expect("Menus belong to the main thread");1341 let mtm = MainThreadMarker::new().expect("Menus belong to the main thread");
crates/snowbound/src/main.rs+580-87
...@@ -15,6 +15,9 @@ mod history;...@@ -15,6 +15,9 @@ mod history;
15#[cfg_attr(not(target_os = "macos"), path = "icloud_linux.rs")]15#[cfg_attr(not(target_os = "macos"), path = "icloud_linux.rs")]
16#[cfg_attr(target_os = "macos", path = "icloud_macos.rs")]16#[cfg_attr(target_os = "macos", path = "icloud_macos.rs")]
17mod icloud;17mod icloud;
18#[cfg_attr(target_os = "macos", allow(dead_code))]
19mod instance;
20mod keys;
18mod library;21mod library;
19mod link;22mod link;
20mod manage;23mod manage;
...@@ -35,7 +38,9 @@ mod paste;...@@ -35,7 +38,9 @@ mod paste;
35#[cfg_attr(target_os = "macos", path = "macos.rs")]38#[cfg_attr(target_os = "macos", path = "macos.rs")]
36#[cfg_attr(windows, path = "windows.rs")]39#[cfg_attr(windows, path = "windows.rs")]
37mod platform;40mod platform;
41mod prefetch;
38mod print;42mod print;
43mod properties;
39#[cfg_attr(target_os = "linux", path = "print_linux.rs")]44#[cfg_attr(target_os = "linux", path = "print_linux.rs")]
40#[cfg_attr(target_os = "macos", path = "print_macos.rs")]45#[cfg_attr(target_os = "macos", path = "print_macos.rs")]
41#[cfg_attr(windows, path = "print_windows.rs")]46#[cfg_attr(windows, path = "print_windows.rs")]
...@@ -57,6 +62,8 @@ mod surface;...@@ -57,6 +62,8 @@ mod surface;
57mod sync;62mod sync;
58mod tags;63mod tags;
59mod templates;64mod templates;
65mod themes;
66mod undo;
60mod update;67mod update;
61mod video;68mod video;
62mod watch;69mod watch;
...@@ -203,6 +210,12 @@ enum UserEvent {...@@ -203,6 +210,12 @@ enum UserEvent {
203 /// The iCloud account signed out, signed in or switched, or iCloud Drive was turned off.210 /// The iCloud account signed out, signed in or switched, or iCloud Drive was turned off.
204 #[cfg_attr(not(target_os = "macos"), allow(dead_code))]211 #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
205 ICloudAccount,212 ICloudAccount,
213 /// The app's iCloud Drive folder was looked up, or a notebook came or went at its top.
214 #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
215 ICloudFolder,
216 /// The system or a later launch asked to open notebook folders, tables of contents or
217 /// sections, as the Finder does with a double-clicked file; none brings the window forward.
218 Open(Vec<PathBuf>),
206}219}
207220
208/// Asks the event loop for a frame from any thread.221/// Asks the event loop for a frame from any thread.
...@@ -228,6 +241,11 @@ impl Clipboard {...@@ -228,6 +241,11 @@ impl Clipboard {
228 let files = clipboard.get_files();241 let files = clipboard.get_files();
229 if !files.is_empty() {242 if !files.is_empty() {
230 Some(paste::Pasted::Files(files))243 Some(paste::Pasted::Files(files))
244 } else if let Some(html) = clipboard
245 .get_html()
246 .filter(|html| html.to_ascii_lowercase().contains("<img"))
247 {
248 Some(paste::Pasted::Page(html))
231 } else if let Some(text) = clipboard.get_text().ok().filter(|text| !text.is_empty())249 } else if let Some(text) = clipboard.get_text().ok().filter(|text| !text.is_empty())
232 {250 {
233 Some(paste::Pasted::Text(text))251 Some(paste::Pasted::Text(text))
...@@ -307,6 +325,8 @@ struct App {...@@ -307,6 +325,8 @@ struct App {
307 screenshot: Option<PathBuf>,325 screenshot: Option<PathBuf>,
308 /// What the window starts from besides its input, until it opens.326 /// What the window starts from besides its input, until it opens.
309 launch: Option<settings::Launch>,327 launch: Option<settings::Launch>,
328 /// Files and folders to open, as File, Open does, once the window opens.
329 opening: Vec<PathBuf>,
310 state: Option<State>,330 state: Option<State>,
311 startup_error: Option<Box<dyn Error>>,331 startup_error: Option<Box<dyn Error>>,
312}332}
...@@ -330,6 +350,20 @@ enum Input {...@@ -330,6 +350,20 @@ enum Input {
330 },350 },
331}351}
332352
353impl Input {
354 /// Lists the notebook folder `root` and shows it first, where the window shows notebooks.
355 fn show(&mut self, root: &Path) -> std::io::Result<()> {
356 if let Input::Notebooks { locations, current } = self {
357 let location = std::path::absolute(root)?.to_string_lossy().into_owned();
358 if !locations.contains(&location) {
359 locations.push(location.clone());
360 }
361 *current = Some(location);
362 }
363 Ok(())
364 }
365}
366
333/// Asks the event loop to poll sections when their synchronization reports.367/// Asks the event loop to poll sections when their synchronization reports.
334fn notify(proxy: EventLoopProxy<UserEvent>) -> impl Fn() + Send + 'static {368fn notify(proxy: EventLoopProxy<UserEvent>) -> impl Fn() + Send + 'static {
335 move || {369 move || {
...@@ -337,6 +371,13 @@ fn notify(proxy: EventLoopProxy<UserEvent>) -> impl Fn() + Send + 'static {...@@ -337,6 +371,13 @@ fn notify(proxy: EventLoopProxy<UserEvent>) -> impl Fn() + Send + 'static {
337 }371 }
338}372}
339373
374/// Asks the event loop to list the app's iCloud Drive folder again.
375fn icloud_listed(proxy: EventLoopProxy<UserEvent>) -> impl Fn() + Send + Sync + 'static {
376 move || {
377 let _ = proxy.send_event(UserEvent::ICloudFolder);
378 }
379}
380
340/// The open section and the page the editor shows.381/// The open section and the page the editor shows.
341struct Session {382struct Session {
342 section: notebook::session::Section,383 section: notebook::session::Section,
...@@ -501,6 +542,7 @@ struct Opening {...@@ -501,6 +542,7 @@ struct Opening {
501 scene: (PageScene, [f32; 2]),542 scene: (PageScene, [f32; 2]),
502 editor: CanvasEditor,543 editor: CanvasEditor,
503 since: Instant,544 since: Instant,
545 requested: Instant,
504}546}
505547
506/// How long an opening page waits for its pictures before showing without them.548/// How long an opening page waits for its pictures before showing without them.
...@@ -519,8 +561,8 @@ enum Command {...@@ -519,8 +561,8 @@ enum Command {
519 OpenFromServer(Option<String>),561 OpenFromServer(Option<String>),
520 /// Asks where to keep a new notebook and creates it.562 /// Asks where to keep a new notebook and creates it.
521 NewNotebook,563 NewNotebook,
522 /// Opens the notebooks in the app's iCloud Drive folder, making one there if it has none.564 /// Asks for a name and creates a notebook at the top of the app's iCloud Drive folder.
523 UseICloud,565 NewICloudNotebook,
524 /// Opens the Snowbound Guide from the user's documents, copying it there first.566 /// Opens the Snowbound Guide from the user's documents, copying it there first.
525 OpenGuide,567 OpenGuide,
526 /// Adds Snowbound to the app menu.568 /// Adds Snowbound to the app menu.
...@@ -600,6 +642,9 @@ struct State {...@@ -600,6 +642,9 @@ struct State {
600 /// The newest read requested; older ones are dropped when they finish.642 /// The newest read requested; older ones are dropped when they finish.
601 loading: u64,643 loading: u64,
602 opening: Option<Opening>,644 opening: Option<Opening>,
645 /// The section tab, if another, that the newest read opens, and when it was asked for.
646 switching: Option<(Option<usize>, Instant)>,
647 prefetch: prefetch::Prefetch,
603 /// Where each page was left this run, by `Library::key` and page space, as OneNote648 /// Where each page was left this run, by `Library::key` and page space, as OneNote
604 /// returns to it until the notebook closes.649 /// returns to it until the notebook closes.
605 places: HashMap<(String, ExGuid), Place>,650 places: HashMap<(String, ExGuid), Place>,
...@@ -607,6 +652,8 @@ struct State {...@@ -607,6 +652,8 @@ struct State {
607 last_pages: HashMap<String, ExGuid>,652 last_pages: HashMap<String, ExGuid>,
608 /// Pages visited, for Back and Forward.653 /// Pages visited, for Back and Forward.
609 trail: navigation::Trail,654 trail: navigation::Trail,
655 /// What Undo and Redo take back across pages and sections.
656 undo: undo::Timeline,
610 swipe: navigation::Swipe,657 swipe: navigation::Swipe,
611 /// Asks for a frame when a worker thread finishes something the page shows.658 /// Asks for a frame when a worker thread finishes something the page shows.
612 redraw: std::task::Waker,659 redraw: std::task::Waker,
...@@ -629,13 +676,30 @@ struct State {...@@ -629,13 +676,30 @@ struct State {
629 folded: HashSet<String>,676 folded: HashSet<String>,
630 /// The open context menu: what it was opened on, and where.677 /// The open context menu: what it was opened on, and where.
631 menu: Option<(menus::Target, [f32; 2])>,678 menu: Option<(menus::Target, [f32; 2])>,
679 /// The notebook the sync status popup shows, opened from its context menu at a point;
680 /// `None` shows the open section's, below the toolbar's button.
681 sync_notebook: Option<(Arc<Library>, [f32; 2])>,
682 /// What the palette's actions menu is on while it is open.
683 actions: Option<menus::Target>,
684 /// An action on a page of another section, done once that section opens on the page.
685 after_open: Option<(ExGuid, menus::Action)>,
632 /// The Options dialog's choices while it is open.686 /// The Options dialog's choices while it is open.
633 options: Option<options::Options>,687 options: Option<options::Options>,
688 /// The Themes dialog while it is open.
689 themes: Option<themes::Dialog>,
690 /// Print Preview and Settings: the choices last made, and the dialog's while open.
691 printing: print::Printing,
634 updates: update::Updates,692 updates: update::Updates,
635 /// The Link dialog's fields while it is open.693 /// The Link dialog's fields while it is open.
636 link: Option<link::LinkDialog>,694 link: Option<link::LinkDialog>,
695 properties: Option<properties::Properties>,
637 /// Open Notebook from Server while it is open.696 /// Open Notebook from Server while it is open.
638 server: Option<server::Connect>,697 server: Option<server::Connect>,
698 /// New iCloud Notebook while it is open.
699 new_icloud: Option<manage::Naming>,
700 /// Notebooks in iCloud Drive a thread is reading, or following the download of, by
701 /// location.
702 icloud_reading: HashSet<String>,
639 /// The user's tag list, which the toolbar, menus and Ctrl+1 to Ctrl+9 apply.703 /// The user's tag list, which the toolbar, menus and Ctrl+1 to Ctrl+9 apply.
640 tags: Vec<canvas::editor::NoteTag>,704 tags: Vec<canvas::editor::NoteTag>,
641 /// The Customize Tags dialog's list while it is open.705 /// The Customize Tags dialog's list while it is open.
...@@ -649,8 +713,6 @@ struct State {...@@ -649,8 +713,6 @@ struct State {
649 hide_spelling: bool,713 hide_spelling: bool,
650 /// Options' "Use pen pressure sensitivity": a tablet pen's strokes follow its pressure.714 /// Options' "Use pen pressure sensitivity": a tablet pen's strokes follow its pressure.
651 pen_pressure: bool,715 pen_pressure: bool,
652 /// Options' Default font, which new text and titles take.
653 default_font: settings::DefaultFont,
654 /// Options' "Page tabs appear on the left".716 /// Options' "Page tabs appear on the left".
655 page_tabs_left: bool,717 page_tabs_left: bool,
656 /// Options' "Navigation bar appears on the left" off: the notebooks on the right.718 /// Options' "Navigation bar appears on the left" off: the notebooks on the right.
...@@ -790,6 +852,7 @@ impl State {...@@ -790,6 +852,7 @@ impl State {
790 platform::install_text_input(&window);852 platform::install_text_input(&window);
791 // Beneath the surface's layer, which is added over it.853 // Beneath the surface's layer, which is added over it.
792 let backdrop = visible && platform::install_backdrop(&window);854 let backdrop = visible && platform::install_backdrop(&window);
855 commands::Keymap::from_saved(&stored.keys).install();
793 platform::install_menu();856 platform::install_menu();
794 let access_adapter =857 let access_adapter =
795 accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy.clone());858 accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy.clone());
...@@ -812,7 +875,7 @@ impl State {...@@ -812,7 +875,7 @@ impl State {
812 icloud::on_account_change(move || {875 icloud::on_account_change(move || {
813 let _ = account.send_event(UserEvent::ICloudAccount);876 let _ = account.send_event(UserEvent::ICloudAccount);
814 });877 });
815 icloud::look_up(notify(proxy.clone()));878 icloud::look_up(icloud_listed(proxy.clone()));
816 let mut notebooks = Vec::new();879 let mut notebooks = Vec::new();
817 let mut session = None;880 let mut session = None;
818 let mut sectionless = None;881 let mut sectionless = None;
...@@ -859,6 +922,10 @@ impl State {...@@ -859,6 +922,10 @@ impl State {
859 if let Err(error) = &library.notebook {922 if let Err(error) = &library.notebook {
860 eprintln!("Cannot open the notebook at {}: {error}", library.location);923 eprintln!("Cannot open the notebook at {}: {error}", library.location);
861 }924 }
925 // Drawn offscreen, the app leaves the notebooks as they are.
926 if visible {
927 library.purge_recycle_bin();
928 }
862 }929 }
863 let shown = notebooks930 let shown = notebooks
864 .iter()931 .iter()
...@@ -946,6 +1013,7 @@ impl State {...@@ -946,6 +1013,7 @@ impl State {
946 let spelling = spell::dictionary()1013 let spelling = spell::dictionary()
947 .map(|dictionary| canvas::spelling::Spelling::new(dictionary, redraw.clone()));1014 .map(|dictionary| canvas::spelling::Spelling::new(dictionary, redraw.clone()));
948 let updates = update::Updates::start(visible && !stored.manual_updates, proxy.clone());1015 let updates = update::Updates::start(visible && !stored.manual_updates, proxy.clone());
1016 let prefetch = prefetch::Prefetch::new(Arc::clone(&layouts), redraw.clone());
949 let mut state = Self {1017 let mut state = Self {
950 author: stored.user_name.unwrap_or_else(platform::user_name),1018 author: stored.user_name.unwrap_or_else(platform::user_name),
951 window,1019 window,
...@@ -972,14 +1040,22 @@ impl State {...@@ -972,14 +1040,22 @@ impl State {
972 dismissed: HashSet::new(),1040 dismissed: HashSet::new(),
973 title_focus: None,1041 title_focus: None,
974 menu: None,1042 menu: None,
1043 sync_notebook: None,
1044 actions: None,
1045 after_open: None,
975 link: None,1046 link: None,
1047 properties: None,
976 server: None,1048 server: None,
1049 new_icloud: None,
1050 icloud_reading: HashSet::new(),
977 tags: stored1051 tags: stored
978 .tags1052 .tags
979 .unwrap_or_else(canvas::editor::NoteTag::defaults),1053 .unwrap_or_else(canvas::editor::NoteTag::defaults),
980 tag_list: None,1054 tag_list: None,
981 text_menu: None,1055 text_menu: None,
982 options: None,1056 options: None,
1057 themes: None,
1058 printing: print::Printing::default(),
983 updates,1059 updates,
984 color_scheme: stored.color_scheme,1060 color_scheme: stored.color_scheme,
985 light_pages: stored.light_pages,1061 light_pages: stored.light_pages,
...@@ -1016,12 +1092,19 @@ impl State {...@@ -1016,12 +1092,19 @@ impl State {
1016 ime_allowed: true,1092 ime_allowed: true,
1017 clipboard,1093 clipboard,
1018 loads: mpsc::channel(),1094 loads: mpsc::channel(),
1095 prefetch,
1019 layouts,1096 layouts,
1020 loading: 0,1097 loading: 0,
1021 opening: None,1098 opening: None,
1099 switching: None,
1022 places: HashMap::new(),1100 places: HashMap::new(),
1023 last_pages: HashMap::new(),1101 last_pages: HashMap::new(),
1024 trail: navigation::Trail::default(),1102 trail: {
1103 let mut trail = navigation::Trail::default();
1104 trail.recent = stored.recent;
1105 trail
1106 },
1107 undo: undo::Timeline::default(),
1025 swipe: navigation::Swipe::default(),1108 swipe: navigation::Swipe::default(),
1026 access_adapter,1109 access_adapter,
1027 accessibility: accessibility::Accessibility::default(),1110 accessibility: accessibility::Accessibility::default(),
...@@ -1029,13 +1112,13 @@ impl State {...@@ -1029,13 +1112,13 @@ impl State {
1029 spelling,1112 spelling,
1030 hide_spelling: stored.hide_spelling,1113 hide_spelling: stored.hide_spelling,
1031 pen_pressure: !stored.ignore_pen_pressure,1114 pen_pressure: !stored.ignore_pen_pressure,
1032 default_font: stored.default_font,
1033 page_tabs_left: stored.page_tabs_left,1115 page_tabs_left: stored.page_tabs_left,
1034 navigation_bar_right: stored.navigation_bar_right,1116 navigation_bar_right: stored.navigation_bar_right,
1035 servers: stored.servers,1117 servers: stored.servers,
1036 correction: None,1118 correction: None,
1037 };1119 };
1038 state.view.snap_to_grid = !stored.ignore_grid;1120 state.view.snap_to_grid = !stored.ignore_grid;
1121 state.view.editor.default_font = stored.default_font;
1039 // A notebook opened from its server that couldn't sign in asks to, as the Finder does.1122 // A notebook opened from its server that couldn't sign in asks to, as the Finder does.
1040 let unsigned = state.notebooks.iter().find(|library| {1123 let unsigned = state.notebooks.iter().find(|library| {
1041 library.notebook.is_err() && library::server_address(&library.location).is_some()1124 library.notebook.is_err() && library::server_address(&library.location).is_some()
...@@ -1044,6 +1127,7 @@ impl State {...@@ -1044,6 +1127,7 @@ impl State {
1044 state.commands.push(Command::OpenFromServer(Some(location)));1127 state.commands.push(Command::OpenFromServer(Some(location)));
1045 }1128 }
1046 state.visited();1129 state.visited();
1130 state.prefetch_around();
1047 state.show_spelling();1131 state.show_spelling();
1048 state.title();1132 state.title();
1049 platform::update_tag_menu(&state.tags);1133 platform::update_tag_menu(&state.tags);
...@@ -1090,7 +1174,10 @@ impl State {...@@ -1090,7 +1174,10 @@ impl State {
1090 lap("drawn", start);1174 lap("drawn", start);
1091 self.sync_index(false, Vec::new());1175 self.sync_index(false, Vec::new());
1092 let commands = std::mem::take(&mut self.commands);1176 let commands = std::mem::take(&mut self.commands);
1093 let follow = !commands.is_empty() || self.ui.wants_frame() || self.opening.is_some();1177 let follow = !commands.is_empty()
1178 || self.ui.wants_frame()
1179 || self.opening.is_some()
1180 || self.switching.is_some();
1094 for command in commands {1181 for command in commands {
1095 self.apply(command)?;1182 self.apply(command)?;
1096 }1183 }
...@@ -1118,8 +1205,12 @@ impl State {...@@ -1118,8 +1205,12 @@ impl State {
1118 platform::cover_border_line(&mut self.ui, size[0]);1205 platform::cover_border_line(&mut self.ui, size[0]);
1119 let (section, open_tab, open_page) = self.build()?;1206 let (section, open_tab, open_page) = self.build()?;
1120 self.options_dialog();1207 self.options_dialog();
1208 self.themes_dialog();
1209 self.print_dialog();
1121 self.server_dialog();1210 self.server_dialog();
1211 self.new_icloud_dialog();
1122 self.link_dialog()?;1212 self.link_dialog()?;
1213 self.properties_dialog();
1123 self.customize_tags();1214 self.customize_tags();
1124 self.palette();1215 self.palette();
1125 self.sync_popup()?;1216 self.sync_popup()?;
...@@ -1185,7 +1276,8 @@ impl State {...@@ -1185,7 +1276,8 @@ impl State {
1185 let target = self.ui.theme.section(section_color(1276 let target = self.ui.theme.section(section_color(
1186 self.session1277 self.session
1187 .as_ref()1278 .as_ref()
1188 .and_then(|session| session.tabs[session.tab].color),1279 .zip(self.open_tab())
1280 .and_then(|(session, tab)| session.tabs[tab].color),
1189 ));1281 ));
1190 self.ui.icon_palette = self.ui.theme.icon_palette(1282 self.ui.icon_palette = self.ui.theme.icon_palette(
1191 target.accent,1283 target.accent,
...@@ -1235,7 +1327,12 @@ impl State {...@@ -1235,7 +1327,12 @@ impl State {
1235 ..Spec::default()1327 ..Spec::default()
1236 },1328 },
1237 );1329 );
1238 let sidebar = self.sidebar(&theme);1330 let sidebar = self.sidebar_width();
1331 if !self.navigation_bar_right {
1332 self.sidebar(&theme, sidebar);
1333 }
1334 // Where the notebook button floats over the tab row while the sidebar is shut.
1335 let beside = 1.0 - sidebar / sidebar::WIDTH;
1239 self.ui.open(1336 self.ui.open(
1240 "main",1337 "main",
1241 Spec {1338 Spec {
...@@ -1267,10 +1364,11 @@ impl State {...@@ -1267,10 +1364,11 @@ impl State {
1267 // outlines start where the button's square ends; the room shrinks on the sidebar's1364 // outlines start where the button's square ends; the room shrinks on the sidebar's
1268 // easing, so the tabs ease with it.1365 // easing, so the tabs ease with it.
1269 let room = TAB_ROW - FRAME - self.rounding() - ui::SHADOW[0];1366 let room = TAB_ROW - FRAME - self.rounding() - ui::SHADOW[0];
1367 let room = if self.navigation_bar_right { 0.0 } else { room };
1270 self.ui.leaf(1368 self.ui.leaf(
1271 "rail",1369 "rail",
1272 Spec {1370 Spec {
1273 size: [px(room * (1.0 - sidebar / sidebar::WIDTH)), px(1.0)],1371 size: [px(room * beside), px(1.0)],
1274 ..Spec::default()1372 ..Spec::default()
1275 },1373 },
1276 );1374 );
...@@ -1301,6 +1399,7 @@ impl State {...@@ -1301,6 +1399,7 @@ impl State {
1301 .collect();1399 .collect();
1302 let lit = self.page_drop(row);1400 let lit = self.page_drop(row);
1303 let dragged = self.dragged_tab(row);1401 let dragged = self.dragged_tab(row);
1402 let shown = self.open_tab().unwrap_or(session.tab);
1304 let ui::shell::Tabs {1403 let ui::shell::Tabs {
1305 clicked,1404 clicked,
1306 context,1405 context,
...@@ -1313,7 +1412,7 @@ impl State {...@@ -1313,7 +1412,7 @@ impl State {
1313 &mut self.ui,1412 &mut self.ui,
1314 row,1413 row,
1315 &tabs,1414 &tabs,
1316 session.tab,1415 shown,
1317 lit,1416 lit,
1318 dragged,1417 dragged,
1319 &section,1418 &section,
...@@ -1373,6 +1472,12 @@ impl State {...@@ -1373,6 +1472,12 @@ impl State {
1373 }1472 }
1374 };1473 };
1375 self.commands.extend(clicked);1474 self.commands.extend(clicked);
1475 if let Some(count) = self.session.as_ref().map(|session| session.tabs.len())
1476 && let Some(tab) =
1477 (0..count).find(|tab| self.ui.signal(ui::shell::tab_id(row, *tab)).hovered)
1478 {
1479 self.prefetch_section(tab);
1480 }
1376 self.ui.leaf(1481 self.ui.leaf(
1377 "space",1482 "space",
1378 Spec {1483 Spec {
...@@ -1383,10 +1488,15 @@ impl State {...@@ -1383,10 +1488,15 @@ impl State {
1383 if self.session.is_some() {1488 if self.session.is_some() {
1384 self.page_tools(&theme);1489 self.page_tools(&theme);
1385 }1490 }
1491 let button = if self.navigation_bar_right && !self.temporary {
1492 TAB_ROW * beside
1493 } else {
1494 0.0
1495 };
1386 self.ui.leaf(1496 self.ui.leaf(
1387 "trail",1497 "trail",
1388 Spec {1498 Spec {
1389 size: [px(self.trailing() - FRAME), px(1.0)],1499 size: [px(self.trailing() - FRAME + button), px(1.0)],
1390 ..Spec::default()1500 ..Spec::default()
1391 },1501 },
1392 );1502 );
...@@ -1402,6 +1512,11 @@ impl State {...@@ -1402,6 +1512,11 @@ impl State {
1402 ..Spec::default()1512 ..Spec::default()
1403 },1513 },
1404 );1514 );
1515 // OneNote's "Page tabs appear on the left" lists them before the page.
1516 let mut open_page = None;
1517 if self.page_tabs_left {
1518 open_page = self.page_list(&theme, &section, row);
1519 }
1405 self.ui.open(1520 self.ui.open(
1406 "column",1521 "column",
1407 Spec {1522 Spec {
...@@ -1427,6 +1542,9 @@ impl State {...@@ -1427,6 +1542,9 @@ impl State {
1427 self.ui.close();1542 self.ui.close();
1428 self.ui.close();1543 self.ui.close();
1429 self.ui.close();1544 self.ui.close();
1545 if self.navigation_bar_right {
1546 self.sidebar(&theme, sidebar);
1547 }
1430 self.sidebar_button(&theme, height);1548 self.sidebar_button(&theme, height);
1431 self.ui.close();1549 self.ui.close();
1432 self.context_menu();1550 self.context_menu();
...@@ -1445,12 +1563,20 @@ impl State {...@@ -1445,12 +1563,20 @@ impl State {
1445 if let Some(node) = self.ui.access(page()) {1563 if let Some(node) = self.ui.access(page()) {
1446 node.set_tree_id(PAGE_TREE);1564 node.set_tree_id(PAGE_TREE);
1447 }1565 }
1448 self.template_strip(&theme);1566 // A page opening has neither the templates nor the scroll of the page leaving.
1567 let opening = self.loading().is_some();
1568 if !opening {
1569 self.template_strip(&theme);
1570 }
1449 if let Some(rect) = self.ui.rect(page()) {1571 if let Some(rect) = self.ui.rect(page()) {
1450 self.transport(&theme, rect)?;1572 self.transport(&theme, rect)?;
1451 }1573 }
1452 let scroll = self.view.scroll();1574 let scroll = self.view.scroll();
1453 for (index, axis) in [Axis::X, Axis::Y].into_iter().enumerate() {1575 for (index, axis) in [Axis::X, Axis::Y]
1576 .into_iter()
1577 .filter(|_| !opening)
1578 .enumerate()
1579 {
1454 if let Some(offset) = ui::scrollbar(1580 if let Some(offset) = ui::scrollbar(
1455 &mut self.ui,1581 &mut self.ui,
1456 index,1582 index,
...@@ -1491,9 +1617,14 @@ impl State {...@@ -1491,9 +1617,14 @@ impl State {
1491 }1617 }
1492 self.page_events(signal.events)?;1618 self.page_events(signal.events)?;
1493 self.ui.close();1619 self.ui.close();
1494 let open_page = self.page_list(&theme, &section, row);1620 if !self.page_tabs_left {
1621 open_page = self.page_list(&theme, &section, row);
1622 }
1495 self.ui.close();1623 self.ui.close();
1496 self.ui.close();1624 self.ui.close();
1625 if self.navigation_bar_right {
1626 self.sidebar(&theme, sidebar);
1627 }
1497 self.task_pane(&theme);1628 self.task_pane(&theme);
1498 self.sidebar_button(&theme, height);1629 self.sidebar_button(&theme, height);
1499 self.ui.close();1630 self.ui.close();
...@@ -1523,6 +1654,7 @@ impl State {...@@ -1523,6 +1654,7 @@ impl State {
1523 // Where the open page tab meets the page, concentric with its neighbours.1654 // Where the open page tab meets the page, concentric with its neighbours.
1524 let join = PILL_MARGIN + rounding;1655 let join = PILL_MARGIN + rounding;
1525 let [left, top, right, bottom] = page;1656 let [left, top, right, bottom] = page;
1657 let left_tabs = self.page_tabs_left;
1526 let tab = open_page1658 let tab = open_page
1527 .and_then(|id| self.ui.rect(id))1659 .and_then(|id| self.ui.rect(id))
1528 .map(|row| [row[0], row[1], row[2], row[3] - ROW_GAP])1660 .map(|row| [row[0], row[1], row[2], row[3] - ROW_GAP])
...@@ -1532,8 +1664,12 @@ impl State {...@@ -1532,8 +1664,12 @@ impl State {
1532 row[1].max(panel[1]).max(top),1664 row[1].max(panel[1]).max(top),
1533 row[3].min(panel[3]).min(bottom),1665 row[3].min(panel[3]).min(bottom),
1534 ];1666 ];
1535 let reach = row[2].min(panel[2]);1667 // How far the tab reaches out from the page's side.
1536 (reach - right > 2.0 * join && end - start > 2.0 * rounding).then(|| {1668 let (reach, out) = match left_tabs {
1669 true => (row[0].max(panel[0]), left - row[0].max(panel[0])),
1670 false => (row[2].min(panel[2]), row[2].min(panel[2]) - right),
1671 };
1672 (out > 2.0 * join && end - start > 2.0 * rounding).then(|| {
1537 // A tab too near the page's corner joins it along the edge.1673 // A tab too near the page's corner joins it along the edge.
1538 let start = if start < top + rounding + join {1674 let start = if start < top + rounding + join {
1539 top1675 top
...@@ -1548,9 +1684,24 @@ impl State {...@@ -1548,9 +1684,24 @@ impl State {
1548 [start, end, reach]1684 [start, end, reach]
1549 })1685 })
1550 });1686 });
1551 let mut outline = vec![([left, top], rounding)];1687 // Clockwise from the top left, the open tab's bump on the side it stands.
1688 let mut outline = Vec::new();
1552 match tab {1689 match tab {
1690 Some([start, end, reach]) if left_tabs => {
1691 if start > top {
1692 outline.push(([left, top], rounding));
1693 }
1694 outline.extend([([right, top], rounding), ([right, bottom], rounding)]);
1695 if end < bottom {
1696 outline.extend([([left, bottom], rounding), ([left, end], join)]);
1697 }
1698 outline.extend([([reach, end], rounding), ([reach, start], rounding)]);
1699 if start > top {
1700 outline.push(([left, start], join));
1701 }
1702 }
1553 Some([start, end, reach]) => {1703 Some([start, end, reach]) => {
1704 outline.push(([left, top], rounding));
1554 if start > top {1705 if start > top {
1555 outline.extend([([right, top], rounding), ([right, start], join)]);1706 outline.extend([([right, top], rounding), ([right, start], join)]);
1556 }1707 }
...@@ -1558,10 +1709,15 @@ impl State {...@@ -1558,10 +1709,15 @@ impl State {
1558 if end < bottom {1709 if end < bottom {
1559 outline.extend([([right, end], join), ([right, bottom], rounding)]);1710 outline.extend([([right, end], join), ([right, bottom], rounding)]);
1560 }1711 }
1712 outline.push(([left, bottom], rounding));
1561 }1713 }
1562 None => outline.extend([([right, top], rounding), ([right, bottom], rounding)]),1714 None => outline.extend([
1715 ([left, top], rounding),
1716 ([right, top], rounding),
1717 ([right, bottom], rounding),
1718 ([left, bottom], rounding),
1719 ]),
1563 }1720 }
1564 outline.push(([left, bottom], rounding));
1565 let height = (frame[3] - frame[1]).max(1.0);1721 let height = (frame[3] - frame[1]).max(1.0);
1566 let paper = self.paper().color;1722 let paper = self.paper().color;
1567 self.ui.round_corners(&outline, paper, |y| {1723 self.ui.round_corners(&outline, paper, |y| {
...@@ -1573,7 +1729,9 @@ impl State {...@@ -1573,7 +1729,9 @@ impl State {
1573 // the open tab stands on it.1729 // the open tab stands on it.
1574 let [start, end] = [frame[0], frame[2]];1730 let [start, end] = [frame[0], frame[2]];
1575 let outer = platform::corner_radius(&self.window);1731 let outer = platform::corner_radius(&self.window);
1576 let beside = outer * start / sidebar::WIDTH;1732 let window = self.ui.rect(Id::ROOT).map_or(end, |root| root[2]);
1733 let [beside, beside_right] =
1734 [start, window - end].map(|gap| (outer * gap / sidebar::WIDTH).min(outer));
1577 let strip = self.ui.theme.strip;1735 let strip = self.ui.theme.strip;
1578 // The border runs half its width inside the frame, on whole device pixels at any1736 // The border runs half its width inside the frame, on whole device pixels at any
1579 // scale, its corners concentric with the fill's. The fill is cut at the border's1737 // scale, its corners concentric with the fill's. The fill is cut at the border's
...@@ -1585,7 +1743,7 @@ impl State {...@@ -1585,7 +1743,7 @@ impl State {
1585 &[1743 &[
1586 ([left, top], (outer - cut).max(0.0)),1744 ([left, top], (outer - cut).max(0.0)),
1587 ([right, top], (outer - cut).max(0.0)),1745 ([right, top], (outer - cut).max(0.0)),
1588 ([right, frame[3]], 0.0),1746 ([right, frame[3]], (beside_right - cut).max(0.0)),
1589 ([left, frame[3]], (beside - cut).max(0.0)),1747 ([left, frame[3]], (beside - cut).max(0.0)),
1590 ],1748 ],
1591 paper,1749 paper,
...@@ -1603,7 +1761,7 @@ impl State {...@@ -1603,7 +1761,7 @@ impl State {
1603 .map_or([end; 2], |tab| ui::shell::tab_base(tab, TAB_ROW))1761 .map_or([end; 2], |tab| ui::shell::tab_base(tab, TAB_ROW))
1604 .map(|x| x.clamp(left, right));1762 .map(|x| x.clamp(left, right));
1605 // The border runs down both sides to the window's bottom, round the bottom corner1763 // The border runs down both sides to the window's bottom, round the bottom corner
1606 // beside the sidebar.1764 // beside the sidebar, on whichever side it stands.
1607 self.ui.border(1765 self.ui.border(
1608 &[1766 &[
1609 ([west + beside, frame[3]], 0.0),1767 ([west + beside, frame[3]], 0.0),
...@@ -1618,7 +1776,8 @@ impl State {...@@ -1618,7 +1776,8 @@ impl State {
1618 &[1776 &[
1619 ([toe, north], 0.0),1777 ([toe, north], 0.0),
1620 ([east, north], inner(outer)),1778 ([east, north], inner(outer)),
1621 ([east, frame[3]], 0.0),1779 ([east, frame[3]], inner(beside_right)),
1780 ([east - beside_right, frame[3]], 0.0),
1622 ],1781 ],
1623 false,1782 false,
1624 section.edge,1783 section.edge,
...@@ -1756,6 +1915,7 @@ impl State {...@@ -1756,6 +1915,7 @@ impl State {
1756 let session = self.session.as_ref();1915 let session = self.session.as_ref();
1757 let update = self.updates.status();1916 let update = self.updates.status();
1758 let label = format!("{:.0}%", self.view.zoom() * 100.0);1917 let label = format!("{:.0}%", self.view.zoom() * 100.0);
1918 let sheet = self.gallery_sheet();
1759 let drawing_pens = self.pens();1919 let drawing_pens = self.pens();
1760 let ui = &mut self.ui;1920 let ui = &mut self.ui;
1761 let text = theme.text;1921 let text = theme.text;
...@@ -1850,6 +2010,22 @@ impl State {...@@ -1850,6 +2010,22 @@ impl State {
1850 },2010 },
1851 );2011 );
1852 divider(ui, theme);2012 divider(ui, theme);
2013 // Styles sits left of the font box (Clover, 2026-09-30), and like it never folds.
2014 let styles = toolbar_popup("styles");
2015 let anchor = if status_of(Cmd::Styles).enabled {
2016 ui::shell::menu_button(ui, "styles", art::STYLES, None, styles)
2017 } else {
2018 ui::shell::unavailable(ui, "styles", art::STYLES, text, true);
2019 ui::Anchor::Below(ui.rect(ui.id("styles")).unwrap_or_default())
2020 };
2021 let shown = notebook::sidecar::themes::STYLES
2022 .iter()
2023 .find(|(name, _)| state.style.as_deref() == Some(*name))
2024 .map_or("Styles".to_owned(), |(_, label)| format!("Styles: {label}"));
2025 ui::popup::tooltip(ui, &shown, "", None);
2026 if let Some(id) = themes::gallery(ui, styles, anchor, &sheet, state.style.as_deref()) {
2027 choice = Some(Choice::Command(id));
2028 }
1853 let combo = ui.id("font");2029 let combo = ui.id("font");
1854 // It gives up room last of all, once every group has folded.2030 // It gives up room last of all, once every group has folded.
1855 let width = ui::Extent {2031 let width = ui::Extent {
...@@ -2546,8 +2722,9 @@ impl State {...@@ -2546,8 +2722,9 @@ impl State {
2546 self.ui.close();2722 self.ui.close();
2547 }2723 }
25482724
2549 /// The section's pages as tabs down the frame's right side, returning the open page's2725 /// The section's pages as tabs down the frame's right side, or its left with OneNote's
2550 /// tab, which is the page's colour and joins it.2726 /// "Page tabs appear on the left", returning the open page's tab, which is the page's
2727 /// colour and joins it.
2551 fn page_list(&mut self, theme: &Theme, section: &ui::Section, tabs: Id) -> Option<Id> {2728 fn page_list(&mut self, theme: &Theme, section: &ui::Section, tabs: Id) -> Option<Id> {
2552 let session = self.session.as_ref()?;2729 let session = self.session.as_ref()?;
2553 let panel = self.ui.id("panel");2730 let panel = self.ui.id("panel");
...@@ -2567,6 +2744,11 @@ impl State {...@@ -2567,6 +2744,11 @@ impl State {
2567 node.set_label("Pages");2744 node.set_label("Pages");
2568 node.set_orientation(accesskit::Orientation::Vertical);2745 node.set_orientation(accesskit::Orientation::Vertical);
2569 }2746 }
2747 // Another section's pages are on their way.
2748 if self.loading().is_some() && self.switching.is_some_and(|(tab, _)| tab.is_some()) {
2749 self.ui.close();
2750 return None;
2751 }
2570 let found = self2752 let found = self
2571 .search2753 .search
2572 .found_in(&session.library.key(&session.tabs[session.tab].path));2754 .found_in(&session.library.key(&session.tabs[session.tab].path));
...@@ -2578,7 +2760,7 @@ impl State {...@@ -2578,7 +2760,7 @@ impl State {
2578 section,2760 section,
2579 session,2761 session,
2580 &found,2762 &found,
2581 rounding,2763 (rounding, self.page_tabs_left),
2582 self.renaming.as_mut(),2764 self.renaming.as_mut(),
2583 dragged,2765 dragged,
2584 );2766 );
...@@ -2587,7 +2769,12 @@ impl State {...@@ -2587,7 +2769,12 @@ impl State {
2587 .map(|(page, version)| Command::OpenVersion { page, version }),2769 .map(|(page, version)| Command::OpenVersion { page, version }),
2588 );2770 );
2589 if let Some((space, point)) = rows.context {2771 if let Some((space, point)) = rows.context {
2590 self.menu = Some((menus::Target::Page(space), point));2772 let target = menus::Target::Page {
2773 library: Arc::clone(&session.library),
2774 path: session.tabs[session.tab].path.clone(),
2775 space,
2776 };
2777 self.menu = Some((target, point));
2591 self.ui.open_popup(menus::id());2778 self.ui.open_popup(menus::id());
2592 }2779 }
2593 if let Some(space) = rows.renamed {2780 if let Some(space) = rows.renamed {
...@@ -2600,12 +2787,19 @@ impl State {...@@ -2600,12 +2787,19 @@ impl State {
2600 if !self.dragged() {2787 if !self.dragged() {
2601 self.commands.extend(rows.clicked.map(Command::OpenPage));2788 self.commands.extend(rows.clicked.map(Command::OpenPage));
2602 }2789 }
2790 if let Some(space) = rows.hovered {
2791 self.prefetch_page(space);
2792 }
2603 self.ui.close();2793 self.ui.close();
2604 rows.open2794 rows.open
2605 }2795 }
26062796
2607 /// Hands the page the events routed to its box, in its device pixels.2797 /// Hands the page the events routed to its box, in its device pixels.
2608 fn page_events(&mut self, events: Vec<ui::Event>) -> Result<(), Box<dyn Error>> {2798 fn page_events(&mut self, events: Vec<ui::Event>) -> Result<(), Box<dyn Error>> {
2799 // The page shown is on its way out.
2800 if self.loading().is_some() {
2801 return Ok(());
2802 }
2609 let read_only = self.session.as_ref().is_some_and(Session::read_only);2803 let read_only = self.session.as_ref().is_some_and(Session::read_only);
2610 let scale = self.ui.scale();2804 let scale = self.ui.scale();
2611 let corner = self.ui.rect(page()).unwrap_or_default();2805 let corner = self.ui.rect(page()).unwrap_or_default();
...@@ -2706,16 +2900,39 @@ impl State {...@@ -2706,16 +2900,39 @@ impl State {
2706 fn apply(&mut self, command: Command) -> Result<(), Box<dyn Error>> {2900 fn apply(&mut self, command: Command) -> Result<(), Box<dyn Error>> {
2707 match command {2901 match command {
2708 Command::OpenSection(library, path) => {2902 Command::OpenSection(library, path) => {
2903 let tab = match &self.session {
2904 // Back to the open section before another one opened.
2905 Some(session)
2906 if self.switching.is_some()
2907 && Arc::ptr_eq(&session.library, &library)
2908 && session.tabs[session.tab].path == path =>
2909 {
2910 self.stop_loading();
2911 return Ok(());
2912 }
2913 Some(session) if session.library.location == library.location => {
2914 session.tabs.iter().position(|tab| tab.path == path)
2915 }
2916 _ => None,
2917 };
2918 self.switching = Some((tab, Instant::now()));
2709 let notify = notify(self.proxy.clone());2919 let notify = notify(self.proxy.clone());
2710 let last = self.last_pages.get(&library.key(&path)).copied();2920 let last = self.last_pages.get(&library.key(&path)).copied();
2711 self.load(move || {2921 self.load(move || {
2922 let start = Instant::now();
2712 let section = library.open(&path, notify)?;2923 let section = library.open(&path, notify)?;
2924 lap("switch section open", start);
2713 let (session, page) = read_session(section, library, path, last)?;2925 let (session, page) = read_session(section, library, path, last)?;
2714 Ok(Loaded::Section(Box::new(session), page))2926 Ok(Loaded::Section(Box::new(session), page))
2715 });2927 });
2716 }2928 }
2717 Command::OpenPage(space) => {2929 Command::OpenPage(space) => {
2718 let session = self.session.as_ref().ok_or("No section is open")?;2930 let session = self.session.as_ref().ok_or("No section is open")?;
2931 if self.switching.is_some() && session.space == space && session.version.is_none() {
2932 self.stop_loading();
2933 return Ok(());
2934 }
2935 self.switching = Some((None, Instant::now()));
2719 let read = session.reader(space);2936 let read = session.reader(space);
2720 self.load(move || Ok(Loaded::Page(space, read()?)));2937 self.load(move || Ok(Loaded::Page(space, read()?)));
2721 }2938 }
...@@ -2773,12 +2990,17 @@ impl State {...@@ -2773,12 +2990,17 @@ impl State {
2773 }2990 }
2774 Command::OpenFromServer(location) => self.open_server(location.as_deref()),2991 Command::OpenFromServer(location) => self.open_server(location.as_deref()),
2775 Command::NewNotebook => self.new_notebook()?,2992 Command::NewNotebook => self.new_notebook()?,
2776 Command::UseICloud => self.use_icloud()?,2993 Command::NewICloudNotebook => self.new_icloud_notebook(),
2777 Command::OpenGuide => self.open_guide()?,2994 Command::OpenGuide => self.open_guide()?,
2778 #[cfg(target_os = "linux")]2995 #[cfg(target_os = "linux")]
2779 Command::Install => desktop::install(),2996 Command::Install => desktop::install(),
2780 Command::CloseNotebook(library) => self.close_notebook(&library),2997 Command::CloseNotebook(library) => self.close_notebook(&library),
2781 Command::Structure(library, change) => self.restructure(library, change),2998 Command::Structure(library, change) => {
2999 if let Some(undo) = undo::structure_undo(&library, &change) {
3000 self.undo.record(undo);
3001 }
3002 self.restructure(library, change);
3003 }
2782 // The index follows the section's page list.3004 // The index follows the section's page list.
2783 Command::NewPage { under } => {3005 Command::NewPage { under } => {
2784 self.new_page(under)?;3006 self.new_page(under)?;
...@@ -2812,8 +3034,12 @@ impl State {...@@ -2812,8 +3034,12 @@ impl State {
2812 self.loading += 1;3034 self.loading += 1;
2813 let (id, sender, redraw) = (self.loading, self.loads.0.clone(), self.redraw.clone());3035 let (id, sender, redraw) = (self.loading, self.loads.0.clone(), self.redraw.clone());
2814 let layouts = Arc::clone(&self.layouts);3036 let layouts = Arc::clone(&self.layouts);
3037 let requested = Instant::now();
3038 let scenes = Arc::clone(&self.prefetch.scenes);
3039 let open = self.session.as_ref().map(Session::key);
2815 std::thread::spawn(move || {3040 std::thread::spawn(move || {
2816 let laid = read().and_then(|loaded| {3041 let laid = read().and_then(|loaded| {
3042 lap("switch read", requested);
2817 let (shown, page) = match loaded {3043 let (shown, page) = match loaded {
2818 Loaded::Section(session, page) => (Shown::Section(session), page),3044 Loaded::Section(session, page) => (Shown::Section(session), page),
2819 Loaded::Created(session, page) => (Shown::Created(session), page),3045 Loaded::Created(session, page) => (Shown::Created(session), page),
...@@ -2822,8 +3048,28 @@ impl State {...@@ -2822,8 +3048,28 @@ impl State {
2822 Loaded::Library(library, path) => return Ok(Laid::Library(library, path)),3048 Loaded::Library(library, path) => return Ok(Laid::Library(library, path)),
2823 };3049 };
2824 let mut engine = layouts.lock().map_err(|_| "Page layout failed")?;3050 let mut engine = layouts.lock().map_err(|_| "Page layout failed")?;
2825 let (scene, editor) = PageScene::from_page(page, &mut engine)?;3051 let start = Instant::now();
3052 let key = match &shown {
3053 Shown::Section(session) | Shown::Created(session) => {
3054 Some((session.key(), session.space))
3055 }
3056 Shown::Page(space) => open.map(|open| (open, *space)),
3057 Shown::Version(..) => None,
3058 };
3059 // A page shown or prepared lately keeps the pictures it drew.
3060 let kept =
3061 key.and_then(|(key, space)| prefetch::Prefetch::take(&scenes, &key, space));
3062 let (scene, editor) = match kept {
3063 Some(mut scene) => {
3064 let mut editor = CanvasEditor::from_page(page, &mut engine)?;
3065 scene.refresh(&mut editor, &mut engine)?;
3066 (scene, editor)
3067 }
3068 None => PageScene::from_page(page, &mut engine)?,
3069 };
3070 lap("switch layout", start);
2826 Ok(Laid::Page(Box::new(Opening {3071 Ok(Laid::Page(Box::new(Opening {
3072 requested,
2827 loaded: shown,3073 loaded: shown,
2828 scene: (scene, [0.0; 2]),3074 scene: (scene, [0.0; 2]),
2829 editor,3075 editor,
...@@ -2846,11 +3092,19 @@ impl State {...@@ -2846,11 +3092,19 @@ impl State {
2846 Ok(Laid::Page(opening)) => self.opening = Some(*opening),3092 Ok(Laid::Page(opening)) => self.opening = Some(*opening),
2847 Ok(Laid::Library(library, path)) => self.adopt(library, path.as_deref())?,3093 Ok(Laid::Library(library, path)) => self.adopt(library, path.as_deref())?,
2848 Err(error) => {3094 Err(error) => {
3095 if id == self.loading {
3096 self.switching = None;
3097 }
2849 eprintln!("{error}");3098 eprintln!("{error}");
2850 platform::alert("Couldn't open", &error);3099 platform::alert("Couldn't open", &error);
2851 }3100 }
2852 }3101 }
2853 }3102 }
3103 let paper = canvas::gpu::Paper {
3104 color: self.ui.theme.paper,
3105 ink: self.ui.theme.paper_ink,
3106 };
3107 self.prefetch.draw(&self.view, paper, &self.redraw);
2854 let Some(mut opening) = self.opening.take() else {3108 let Some(mut opening) = self.opening.take() else {
2855 return Ok(());3109 return Ok(());
2856 };3110 };
...@@ -2867,15 +3121,27 @@ impl State {...@@ -2867,15 +3121,27 @@ impl State {
2867 self.opening = Some(opening);3121 self.opening = Some(opening);
2868 return Ok(());3122 return Ok(());
2869 }3123 }
3124 lap("switch pictures", opening.since);
3125 let requested = opening.requested;
2870 // A recording goes on the page it started on.3126 // A recording goes on the page it started on.
2871 self.stop_recording(true)?;3127 self.stop_recording(true)?;
2872 // The page shown so far keeps what was typed while the next one loaded.3128 // The page shown so far keeps what was typed while the next one loaded.
2873 self.persist()?;3129 self.persist()?;
3130 let mut leaving = self
3131 .session
3132 .as_ref()
3133 .filter(|session| !session.read_only())
3134 .map(|session| session.space);
2874 if let Some(session) = &self.session {3135 if let Some(session) = &self.session {
2875 let key = session.library.key(&session.tabs[session.tab].path);3136 let key = session.key();
2876 self.places3137 self.places
2877 .insert((key.clone(), session.space), self.view.place());3138 .insert((key.clone(), session.space), self.view.place());
2878 self.last_pages.insert(key, session.space);3139 self.last_pages.insert(key.clone(), session.space);
3140 if session.version.is_none()
3141 && let Some(scene) = self.view.scene.take()
3142 {
3143 self.prefetch.keep(key, session.space, scene);
3144 }
2879 }3145 }
2880 let created = matches!(opening.loaded, Shown::Created(_));3146 let created = matches!(opening.loaded, Shown::Created(_));
2881 match opening.loaded {3147 match opening.loaded {
...@@ -2894,14 +3160,23 @@ impl State {...@@ -2894,14 +3160,23 @@ impl State {
2894 Some(library) => *library = Arc::clone(&session.library),3160 Some(library) => *library = Arc::clone(&session.library),
2895 None => self.notebooks.push(Arc::clone(&session.library)),3161 None => self.notebooks.push(Arc::clone(&session.library)),
2896 }3162 }
2897 // The notebook's background sync takes the section over once it is closed.3163 if session.library.in_icloud() && session.library.downloading() {
3164 self.fetch(Arc::clone(&session.library), None);
3165 }
3166 // The section left stays open a while for coming back to; the notebook's
3167 // background sync takes it over once it is closed.
2898 if let Some(previous) = self.session.replace(*session) {3168 if let Some(previous) = self.session.replace(*session) {
2899 let section = previous.section;3169 let library = &previous.library;
2900 std::thread::spawn(move || {3170 if self.notebooks.iter().any(|open| Arc::ptr_eq(open, library)) {
2901 if let Err(error) = section.close() {3171 library.keep(&previous.tabs[previous.tab].path, previous.section);
2902 eprintln!("Synchronization stopped: {error}");3172 } else {
2903 }3173 let section = previous.section;
2904 });3174 std::thread::spawn(move || {
3175 if let Err(error) = section.close() {
3176 eprintln!("Synchronization stopped: {error}");
3177 }
3178 });
3179 }
2905 }3180 }
2906 self.sectionless = None;3181 self.sectionless = None;
2907 if other_notebook {3182 if other_notebook {
...@@ -2918,15 +3193,41 @@ impl State {...@@ -2918,15 +3193,41 @@ impl State {
2918 session.change = None;3193 session.change = None;
2919 // Pages created, moved or deleted since the list was read.3194 // Pages created, moved or deleted since the list was read.
2920 session.pages = session.section.pages()?;3195 session.pages = session.section.pages()?;
3196 leaving = leaving.filter(|left| session.pages.iter().any(|(s, ..)| s == left));
2921 }3197 }
2922 }3198 }
2923 let place = self.session.as_ref().and_then(|session| {3199 let place = self.session.as_ref().and_then(|session| {
2924 let key = session.library.key(&session.tabs[session.tab].path);3200 let key = session.library.key(&session.tabs[session.tab].path);
2925 self.places.get(&(key, session.space)).copied()3201 self.places.get(&(key, session.space)).copied()
2926 });3202 });
2927 self.view.open(opening.editor, Some(opening.scene), place);3203 let left = self.view.open(opening.editor, Some(opening.scene), place);
3204 // Each page takes up the history it was left with.
3205 if let Some(leaving) = leaving {
3206 self.undo.park(leaving, left);
3207 }
3208 let shown = self
3209 .session
3210 .as_ref()
3211 .filter(|session| !session.read_only())
3212 .map(|session| session.space);
3213 if let Some(parked) = shown.and_then(|space| self.undo.resume(space)) {
3214 self.view.resume(parked)?;
3215 }
3216 lap("switch shown", requested);
3217 self.switching = None;
2928 self.visited();3218 self.visited();
3219 if let Some((space, action)) = self.after_open.take()
3220 && let Some(session) = self.session.as_ref().filter(|session| session.space == space)
3221 {
3222 let target = menus::Target::Page {
3223 library: Arc::clone(&session.library),
3224 path: session.tabs[session.tab].path.clone(),
3225 space,
3226 };
3227 self.act_on(target, action);
3228 }
2929 self.opened()?;3229 self.opened()?;
3230 self.prefetch_around();
2930 self.refind(true)?;3231 self.refind(true)?;
2931 if self.title_focus.take().is_some_and(|space| {3232 if self.title_focus.take().is_some_and(|space| {
2932 self.session3233 self.session
...@@ -2958,6 +3259,9 @@ impl State {...@@ -2958,6 +3259,9 @@ impl State {
2958 /// the open section, now at catalog `path` in it, stays open, reopened where it moved.3259 /// the open section, now at catalog `path` in it, stays open, reopened where it moved.
2959 /// Without `path` the notebook has no sections left, and one of its shows none.3260 /// Without `path` the notebook has no sections left, and one of its shows none.
2960 fn adopt(&mut self, library: Arc<Library>, path: Option<&str>) -> Result<(), Box<dyn Error>> {3261 fn adopt(&mut self, library: Arc<Library>, path: Option<&str>) -> Result<(), Box<dyn Error>> {
3262 if library.in_icloud() && library.downloading() {
3263 self.fetch(Arc::clone(&library), None);
3264 }
2961 match self3265 match self
2962 .notebooks3266 .notebooks
2963 .iter_mut()3267 .iter_mut()
...@@ -2978,9 +3282,10 @@ impl State {...@@ -2978,9 +3282,10 @@ impl State {
2978 if let Some(session) = self.session.take() {3282 if let Some(session) = self.session.take() {
2979 session.section.close()?;3283 session.section.close()?;
2980 }3284 }
2981 self.sectionless = Some(library);3285 self.sectionless = Some(Arc::clone(&library));
2982 self.title();3286 self.title();
2983 }3287 }
3288 self.show_arrived(&library);
2984 return Ok(());3289 return Ok(());
2985 };3290 };
2986 let Some(session) = &mut self.session else {3291 let Some(session) = &mut self.session else {
...@@ -3014,6 +3319,9 @@ impl State {...@@ -3014,6 +3319,9 @@ impl State {
30143319
3015 /// Follows a page shown in place of another.3320 /// Follows a page shown in place of another.
3016 fn opened(&mut self) -> Result<(), Box<dyn Error>> {3321 fn opened(&mut self) -> Result<(), Box<dyn Error>> {
3322 if let Err(error) = self.wear_theme() {
3323 eprintln!("Restyling the page failed: {error}");
3324 }
3017 // A page a search result shows leaves the keys with the search.3325 // A page a search result shows leaves the keys with the search.
3018 if !search::takes_text(self.ui.focused()) {3326 if !search::takes_text(self.ui.focused()) {
3019 self.ui.set_focus(Some(page()));3327 self.ui.set_focus(Some(page()));
...@@ -3220,6 +3528,9 @@ impl State {...@@ -3220,6 +3528,9 @@ impl State {
3220 session.pages = session.section.pages()?;3528 session.pages = session.section.pages()?;
3221 }3529 }
3222 self.edited(vec![space]);3530 self.edited(vec![space]);
3531 let editor = &self.view.editor;
3532 self.undo
3533 .edited(space, editor.history_depth(), editor.typing().is_some());
3223 Ok(())3534 Ok(())
3224 }3535 }
32253536
...@@ -3610,19 +3921,32 @@ impl State {...@@ -3610,19 +3921,32 @@ impl State {
3610 self.view.tag_art = session.library.tag_art();3921 self.view.tag_art = session.library.tag_art();
3611 }3922 }
3612 let theme = &self.ui.theme;3923 let theme = &self.ui.theme;
3613 let page_primitives = self.view.primitives(TextColors {
3614 caret: theme.caret,
3615 selection: if self.page_focused {
3616 theme.selection
3617 } else {
3618 theme.inactive_selection
3619 },
3620 paper,
3621 })?;
3622 lap("page primitives", start);
3623 let scale = self.ui.scale();3924 let scale = self.ui.scale();
3624 let corner = self.ui.rect(page()).unwrap_or_default();3925 let corner = self.ui.rect(page()).unwrap_or_default();
3625 let viewport = self.view.viewport;3926 // A page taking a while to open shows its outline, drawn in points from the corner.
3927 let (page_primitives, viewport) = match self.loading() {
3928 Some(since) => (
3929 prefetch::skeleton(paper, since),
3930 canvas::gpu::Viewport {
3931 scale,
3932 origin: [0.0; 2],
3933 ..self.view.viewport
3934 },
3935 ),
3936 None => (
3937 self.view.primitives(TextColors {
3938 caret: theme.caret,
3939 selection: if self.page_focused {
3940 theme.selection
3941 } else {
3942 theme.inactive_selection
3943 },
3944 paper,
3945 })?,
3946 self.view.viewport,
3947 ),
3948 };
3949 lap("page primitives", start);
3626 let interface = self.ui.layers();3950 let interface = self.ui.layers();
3627 let layers: Vec<_> = interface3951 let layers: Vec<_> = interface
3628 .iter()3952 .iter()
...@@ -3673,17 +3997,18 @@ impl State {...@@ -3673,17 +3997,18 @@ impl State {
3673 (x0..x1).contains(&x) && (y0..y1).contains(&y)3997 (x0..x1).contains(&x) && (y0..y1).contains(&y)
3674 }3998 }
36753999
4000 /// Window point `point` in the page's device pixels.
4001 fn page_point(&self, point: [f32; 2]) -> [f32; 2] {
4002 let [left, top, ..] = self.ui.rect(page()).unwrap_or_default();
4003 let scale = self.ui.scale();
4004 [(point[0] - left) * scale, (point[1] - top) * scale]
4005 }
4006
3676 /// Zooms the page by `factor` about the pointer, when it is over the page.4007 /// Zooms the page by `factor` about the pointer, when it is over the page.
3677 fn pinch(&mut self, factor: f32) -> Result<(), Box<dyn Error>> {4008 fn pinch(&mut self, factor: f32) -> Result<(), Box<dyn Error>> {
3678 if self.over_page() {4009 if self.over_page() {
3679 // The pointer here, as the page's own lags by the moves queued for the next frame.4010 // The pointer here, as the page's own lags by the moves queued for the next frame.
3680 let [left, top, ..] = self.ui.rect(page()).unwrap_or_default();4011 let response = self.view.pinch(factor, self.page_point(self.pointer))?;
3681 let scale = self.ui.scale();
3682 let anchor = [
3683 (self.pointer[0] - left) * scale,
3684 (self.pointer[1] - top) * scale,
3685 ];
3686 let response = self.view.pinch(factor, anchor)?;
3687 self.respond(response);4012 self.respond(response);
3688 }4013 }
3689 Ok(())4014 Ok(())
...@@ -3755,10 +4080,15 @@ impl State {...@@ -3755,10 +4080,15 @@ impl State {
3755 }4080 }
3756 return;4081 return;
3757 }4082 }
3758 // On macOS the menu bar takes the chords of the items it enables first.4083 if self.record_chord(&event) {
4084 return;
4085 }
4086 // On macOS the menu bar takes the chords of the items it enables first. The open
4087 // palette takes ⌘K, Link's chord, for its actions.
3759 if let ui::Event::Key { key, .. } = &event4088 if let ui::Event::Key { key, .. } = &event
3760 && let Some(id) =4089 && let Some(id) =
3761 commands::find(&ui::edit_key(key), ui::edit_modifiers(self.ui.modifiers()))4090 commands::find(&ui::edit_key(key), ui::edit_modifiers(self.ui.modifiers()))
4091 && !(id == commands::Id::Link && self.ui.popup_open(palette::id()))
3762 {4092 {
3763 self.choose(commands::Choice::Command(id));4093 self.choose(commands::Choice::Command(id));
3764 } else {4094 } else {
...@@ -3977,6 +4307,8 @@ struct Rows {...@@ -3977,6 +4307,8 @@ struct Rows {
3977 context: Option<(ExGuid, [f32; 2])>,4307 context: Option<(ExGuid, [f32; 2])>,
3978 /// The page held down, which a drag moves.4308 /// The page held down, which a drag moves.
3979 held: Option<ExGuid>,4309 held: Option<ExGuid>,
4310 /// A page other than the open one under the pointer.
4311 hovered: Option<ExGuid>,
3980 /// A page version clicked: the page, and the version's context.4312 /// A page version clicked: the page, and the version's context.
3981 version: Option<(ExGuid, ExGuid)>,4313 version: Option<(ExGuid, ExGuid)>,
3982 /// The page whose tab was pressed twice in a row, which renames it.4314 /// The page whose tab was pressed twice in a row, which renames it.
...@@ -4013,7 +4345,7 @@ fn page_rows(...@@ -4013,7 +4345,7 @@ fn page_rows(
4013 section: &ui::Section,4345 section: &ui::Section,
4014 session: &Session,4346 session: &Session,
4015 found: &HashSet<ExGuid>,4347 found: &HashSet<ExGuid>,
4016 rounding: f32,4348 shape: (f32, bool),
4017 mut renaming: Option<&mut rename::Renaming>,4349 mut renaming: Option<&mut rename::Renaming>,
4018 dragged: Option<PageDrag>,4350 dragged: Option<PageDrag>,
4019) -> Rows {4351) -> Rows {
...@@ -4058,7 +4390,7 @@ fn page_rows(...@@ -4058,7 +4390,7 @@ fn page_rows(
4058 section,4390 section,
4059 session,4391 session,
4060 found,4392 found,
4061 rounding,4393 shape,
4062 renaming.as_deref_mut(),4394 renaming.as_deref_mut(),
4063 (space, title, *level),4395 (space, title, *level),
4064 shift,4396 shift,
...@@ -4085,7 +4417,7 @@ fn page_rows(...@@ -4085,7 +4417,7 @@ fn page_rows(
4085 section,4417 section,
4086 session,4418 session,
4087 found,4419 found,
4088 rounding,4420 shape,
4089 renaming,4421 renaming,
4090 (space, title, *level),4422 (space, title, *level),
4091 top - laid,4423 top - laid,
...@@ -4106,7 +4438,7 @@ fn page_row(...@@ -4106,7 +4438,7 @@ fn page_row(
4106 section: &ui::Section,4438 section: &ui::Section,
4107 session: &Session,4439 session: &Session,
4108 found: &HashSet<ExGuid>,4440 found: &HashSet<ExGuid>,
4109 rounding: f32,4441 shape: (f32, bool),
4110 renaming: Option<&mut rename::Renaming>,4442 renaming: Option<&mut rename::Renaming>,
4111 (space, title, level): (&ExGuid, &String, u32),4443 (space, title, level): (&ExGuid, &String, u32),
4112 shift: f32,4444 shift: f32,
...@@ -4135,8 +4467,11 @@ fn page_row(...@@ -4135,8 +4467,11 @@ fn page_row(
4135 if selected && !lifted {4467 if selected && !lifted {
4136 rows.open = Some(ui.id(space));4468 rows.open = Some(ui.id(space));
4137 }4469 }
4138 let (signal, kept) = page_tab(ui, theme, section, space, tab, selected, rounding);4470 let (signal, kept) = page_tab(ui, theme, section, space, tab, selected, shape);
4139 rows.kept = rows.kept.or(kept);4471 rows.kept = rows.kept.or(kept);
4472 if signal.hovered && !selected {
4473 rows.hovered = Some(*space);
4474 }
4140 if signal.clicked && !selected {4475 if signal.clicked && !selected {
4141 rows.clicked = Some(*space);4476 rows.clicked = Some(*space);
4142 }4477 }
...@@ -4176,7 +4511,7 @@ fn page_row(...@@ -4176,7 +4511,7 @@ fn page_row(
4176 rows.open = Some(ui.id(version.context));4511 rows.open = Some(ui.id(version.context));
4177 }4512 }
4178 height += ROW;4513 height += ROW;
4179 if page_tab(ui, theme, &muted, &version.context, tab, selected, rounding)4514 if page_tab(ui, theme, &muted, &version.context, tab, selected, shape)
4180 .04515 .0
4181 .clicked4516 .clicked
4182 && !selected4517 && !selected
...@@ -4208,7 +4543,7 @@ fn page_row(...@@ -4208,7 +4543,7 @@ fn page_row(
4208 rows.open = Some(ui.id(version.space));4543 rows.open = Some(ui.id(version.space));
4209 }4544 }
4210 height += ROW;4545 height += ROW;
4211 if page_tab(ui, theme, &muted, &version.space, tab, selected, rounding)4546 if page_tab(ui, theme, &muted, &version.space, tab, selected, shape)
4212 .04547 .0
4213 .clicked4548 .clicked
4214 && !selected4549 && !selected
...@@ -4237,8 +4572,9 @@ struct PageTab<'a> {...@@ -4237,8 +4572,9 @@ struct PageTab<'a> {
4237 lifted: bool,4572 lifted: bool,
4238}4573}
42394574
4240/// A page's tab down the frame's right side: the open one is the page's colour and joins4575/// A page's tab down the frame's side, the left where `shape` says so beside its `rounding`:
4241/// it, the others float free of it as pills rounded like the page.4576/// the open one is the page's colour and joins it, the others float free of it as pills
4577/// rounded like the page.
4242fn page_tab(4578fn page_tab(
4243 ui: &mut Ui,4579 ui: &mut Ui,
4244 theme: &Theme,4580 theme: &Theme,
...@@ -4246,7 +4582,7 @@ fn page_tab(...@@ -4246,7 +4582,7 @@ fn page_tab(
4246 id: &ExGuid,4582 id: &ExGuid,
4247 tab: PageTab,4583 tab: PageTab,
4248 selected: bool,4584 selected: bool,
4249 rounding: f32,4585 (rounding, left): (f32, bool),
4250) -> (ui::Signal, Option<bool>) {4586) -> (ui::Signal, Option<bool>) {
4251 // A rename field's text stands where the label did.4587 // A rename field's text stands where the label did.
4252 let pad = 10.0 + 16.0 * tab.indent as f324588 let pad = 10.0 + 16.0 * tab.indent as f32
...@@ -4280,13 +4616,20 @@ fn page_tab(...@@ -4280,13 +4616,20 @@ fn page_tab(
4280 fill4616 fill
4281 }4617 }
4282 };4618 };
4619 // A pill stands off the page by its margin and off the window's edge a little more.
4620 let [page_side, edge_side] = [PILL_MARGIN, 6.0];
4621 let [start, end] = if left {
4622 [edge_side, page_side]
4623 } else {
4624 [page_side, edge_side]
4625 };
4283 let spec = Spec {4626 let spec = Spec {
4284 fill: Some(tint(section.tab)),4627 fill: Some(tint(section.tab)),
4285 hover_fill: Some(tint(section.hover())),4628 hover_fill: Some(tint(section.hover())),
4286 radius: rounding,4629 radius: rounding,
4287 inset: [PILL_MARGIN, 0.0, 6.0, ROW_GAP],4630 inset: [start, 0.0, end, ROW_GAP],
4288 // The label keeps its place as the tab opens and closes.4631 // The label keeps its place as the tab opens and closes.
4289 pad: [pad - PILL_MARGIN, 0.0],4632 pad: [if left { pad } else { pad - start }, 0.0],
4290 ..spec4633 ..spec
4291 };4634 };
4292 let color = if tab.dim {4635 let color = if tab.dim {
...@@ -4743,6 +5086,7 @@ fn artwork(id: commands::Id) -> Option<&'static [&'static str]> {...@@ -4743,6 +5086,7 @@ fn artwork(id: commands::Id) -> Option<&'static [&'static str]> {
4743 Cmd::Toggle(Toggle::Subscript) => art::SUBSCRIPT,5086 Cmd::Toggle(Toggle::Subscript) => art::SUBSCRIPT,
4744 Cmd::Toggle(Toggle::Superscript) => art::SUPERSCRIPT,5087 Cmd::Toggle(Toggle::Superscript) => art::SUPERSCRIPT,
4745 Cmd::ClearFormatting => art::CLEAR_FORMATTING,5088 Cmd::ClearFormatting => art::CLEAR_FORMATTING,
5089 Cmd::Styles => art::STYLES,
4746 Cmd::Highlight => art::HIGHLIGHTER,5090 Cmd::Highlight => art::HIGHLIGHTER,
4747 Cmd::FontColor => art::FONT_COLOR,5091 Cmd::FontColor => art::FONT_COLOR,
4748 Cmd::Bullets => art::BULLETS,5092 Cmd::Bullets => art::BULLETS,
...@@ -4971,7 +5315,7 @@ impl ApplicationHandler<UserEvent> for App {...@@ -4971,7 +5315,7 @@ impl ApplicationHandler<UserEvent> for App {
4971 let event = match event {5315 let event = match event {
4972 UserEvent::Picture(bytes) => {5316 UserEvent::Picture(bytes) => {
4973 if let Some(state) = &mut self.state {5317 if let Some(state) = &mut self.state {
4974 if let Err(error) = state.insert_picture(bytes) {5318 if let Err(error) = state.insert_picture(bytes, None) {
4975 eprintln!("{error}");5319 eprintln!("{error}");
4976 }5320 }
4977 state.window.request_redraw();5321 state.window.request_redraw();
...@@ -5005,6 +5349,12 @@ impl ApplicationHandler<UserEvent> for App {...@@ -5005,6 +5349,12 @@ impl ApplicationHandler<UserEvent> for App {
5005 }5349 }
5006 return;5350 return;
5007 }5351 }
5352 UserEvent::ICloudFolder => {
5353 if let Some(state) = &mut self.state {
5354 state.list_icloud();
5355 }
5356 return;
5357 }
5008 UserEvent::ICloudAccount => {5358 UserEvent::ICloudAccount => {
5009 if let Some(state) = &mut self.state {5359 if let Some(state) = &mut self.state {
5010 state.icloud_account_changed();5360 state.icloud_account_changed();
...@@ -5012,6 +5362,21 @@ impl ApplicationHandler<UserEvent> for App {...@@ -5012,6 +5362,21 @@ impl ApplicationHandler<UserEvent> for App {
5012 }5362 }
5013 return;5363 return;
5014 }5364 }
5365 UserEvent::Open(paths) => {
5366 match &mut self.state {
5367 Some(state) => {
5368 for path in paths {
5369 state.open_path(&path);
5370 }
5371 state.window.set_minimized(false);
5372 state.window.focus_window();
5373 state.window.request_redraw();
5374 }
5375 // A cold launch's documents arrive before the window opens.
5376 None => self.opening.extend(paths),
5377 }
5378 return;
5379 }
5015 UserEvent::Update => {5380 UserEvent::Update => {
5016 if let Some(state) = &mut self.state {5381 if let Some(state) = &mut self.state {
5017 state.updated();5382 state.updated();
...@@ -5115,10 +5480,19 @@ impl ApplicationHandler<UserEvent> for App {...@@ -5115,10 +5480,19 @@ impl ApplicationHandler<UserEvent> for App {
5115 if self.state.is_some() {5480 if self.state.is_some() {
5116 return;5481 return;
5117 }5482 }
5483 let mut input = self.input.take().unwrap();
5484 // The window starts at the notebook asked for rather than switching to it.
5485 for path in &self.opening {
5486 if let library::Located::Notebook { root, .. } = library::locate(path)
5487 && let Err(error) = input.show(&root)
5488 {
5489 eprintln!("Cannot open {}: {error}", root.display());
5490 }
5491 }
5118 match pollster::block_on(State::new(5492 match pollster::block_on(State::new(
5119 event_loop,5493 event_loop,
5120 self.proxy.clone(),5494 self.proxy.clone(),
5121 self.input.take().unwrap(),5495 input,
5122 &self.substitutes,5496 &self.substitutes,
5123 self.screenshot.is_none(),5497 self.screenshot.is_none(),
5124 self.launch.take().expect("The window opens once"),5498 self.launch.take().expect("The window opens once"),
...@@ -5129,7 +5503,12 @@ impl ApplicationHandler<UserEvent> for App {...@@ -5129,7 +5503,12 @@ impl ApplicationHandler<UserEvent> for App {
5129 self.startup_error = state.screenshot(prefix).err();5503 self.startup_error = state.screenshot(prefix).err();
5130 event_loop.exit();5504 event_loop.exit();
5131 }5505 }
5132 _ => self.state = Some(state),5506 _ => {
5507 for path in self.opening.drain(..) {
5508 state.open_path(&path);
5509 }
5510 self.state = Some(state);
5511 }
5133 },5512 },
5134 Err(error) => {5513 Err(error) => {
5135 self.startup_error = Some(error);5514 self.startup_error = Some(error);
...@@ -5193,6 +5572,9 @@ impl ApplicationHandler<UserEvent> for App {...@@ -5193,6 +5572,9 @@ impl ApplicationHandler<UserEvent> for App {
5193 if !focused {5572 if !focused {
5194 // Someone switching to another device finds their edits there.5573 // Someone switching to another device finds their edits there.
5195 state.publish_now(std::time::Duration::ZERO);5574 state.publish_now(std::time::Duration::ZERO);
5575 } else {
5576 // Notebooks another device added or removed meanwhile.
5577 state.list_icloud();
5196 }5578 }
5197 state.window.request_redraw();5579 state.window.request_redraw();
5198 }5580 }
...@@ -5247,7 +5629,7 @@ impl ApplicationHandler<UserEvent> for App {...@@ -5247,7 +5629,7 @@ impl ApplicationHandler<UserEvent> for App {
5247 WindowEvent::Ime(ime) => state.input(ui::Event::Ime(ime)),5629 WindowEvent::Ime(ime) => state.input(ui::Event::Ime(ime)),
5248 WindowEvent::DroppedFile(path) => {5630 WindowEvent::DroppedFile(path) => {
5249 let at = platform::drop_point(&state.window);5631 let at = platform::drop_point(&state.window);
5250 state.attach(&path, at)?;5632 state.place_file(&path, at)?;
5251 state.window.request_redraw();5633 state.window.request_redraw();
5252 }5634 }
5253 WindowEvent::PinchGesture { delta, .. } => state.pinch(1.0 + delta as f32)?,5635 WindowEvent::PinchGesture { delta, .. } => state.pinch(1.0 + delta as f32)?,
...@@ -5425,6 +5807,7 @@ fn launch() -> Result<(), Box<dyn Error>> {...@@ -5425,6 +5807,7 @@ fn launch() -> Result<(), Box<dyn Error>> {
5425 let mut cache = None;5807 let mut cache = None;
5426 let mut settings_file = None;5808 let mut settings_file = None;
5427 let mut screenshot = None;5809 let mut screenshot = None;
5810 let mut opening = Vec::new();
5428 while let Some(arg) = args.next() {5811 while let Some(arg) = args.next() {
5429 if arg == "--substitute-font" {5812 if arg == "--substitute-font" {
5430 substitutes.push(PathBuf::from(5813 substitutes.push(PathBuf::from(
...@@ -5485,6 +5868,9 @@ fn launch() -> Result<(), Box<dyn Error>> {...@@ -5485,6 +5868,9 @@ fn launch() -> Result<(), Box<dyn Error>> {
5485 .to_str()5868 .to_str()
5486 .ok_or("The page title must be valid Unicode.")?,5869 .ok_or("The page title must be valid Unicode.")?,
5487 )?);5870 )?);
5871 } else if library::locate(Path::new(&arg)) != library::Located::Nothing {
5872 // A file the desktop opens with Snowbound, as a double-clicked section.
5873 opening.push(std::path::absolute(arg)?);
5488 } else {5874 } else {
5489 positional.push(arg);5875 positional.push(arg);
5490 }5876 }
...@@ -5499,7 +5885,7 @@ fn launch() -> Result<(), Box<dyn Error>> {...@@ -5499,7 +5885,7 @@ fn launch() -> Result<(), Box<dyn Error>> {
5499 }5885 }
5500 if positional.len() > 2 {5886 if positional.len() > 2 {
5501 return Err(5887 return Err(
5502 "Usage: snowbound [TEXT_FILE] [WIDTH_POINTS] [--reference SECTION PAGE_TITLE | --page SECTION PAGE_TITLE | --section SECTION PAGE_TITLE | --notebook FOLDER] [--cache DIR] [--settings FILE] [--screenshot PNG_PREFIX] [--substitute-font FONT_FILE]..."5888 "Usage: snowbound [NOTEBOOK_FOLDER | SECTION.one | TOC.onetoc2]... [TEXT_FILE] [WIDTH_POINTS] [--reference SECTION PAGE_TITLE | --page SECTION PAGE_TITLE | --section SECTION PAGE_TITLE | --notebook FOLDER] [--cache DIR] [--settings FILE] [--screenshot PNG_PREFIX] [--substitute-font FONT_FILE]..."
5503 .into(),5889 .into(),
5504 );5890 );
5505 }5891 }
...@@ -5531,16 +5917,14 @@ fn launch() -> Result<(), Box<dyn Error>> {...@@ -5531,16 +5917,14 @@ fn launch() -> Result<(), Box<dyn Error>> {
5531 } else if editable {5917 } else if editable {
5532 Input::Page(reference.unwrap())5918 Input::Page(reference.unwrap())
5533 } else if positional.is_empty() && reference.is_none() {5919 } else if positional.is_empty() && reference.is_none() {
5534 let mut locations = saved.notebooks.clone();5920 let mut input = Input::Notebooks {
5535 let mut current = saved.current.clone();5921 locations: saved.notebooks.clone(),
5922 current: saved.current.clone(),
5923 };
5536 if let Some(root) = notebook {5924 if let Some(root) = notebook {
5537 let location = std::path::absolute(root)?.to_string_lossy().into_owned();5925 input.show(&root)?;
5538 if !locations.contains(&location) {
5539 locations.push(location.clone());
5540 }
5541 current = Some(location);
5542 }5926 }
5543 Input::Notebooks { locations, current }5927 input
5544 } else {5928 } else {
5545 Input::Notes {5929 Input::Notes {
5546 document: TextDocument::new(5930 document: TextDocument::new(
...@@ -5552,10 +5936,22 @@ fn launch() -> Result<(), Box<dyn Error>> {...@@ -5552,10 +5936,22 @@ fn launch() -> Result<(), Box<dyn Error>> {
5552 reference,5936 reference,
5553 }5937 }
5554 };5938 };
5939 #[cfg(not(target_os = "macos"))]
5940 let instance = match (&input, &screenshot) {
5941 (Input::Notebooks { .. }, None) => match instance::claim(&cache, &opening) {
5942 Some(instance) => Some(instance),
5943 None => return Ok(()),
5944 },
5945 _ => None,
5946 };
5555 if screenshot.is_some() {5947 if screenshot.is_some() {
5556 screenshot::prepare();5948 screenshot::prepare();
5557 }5949 }
5558 let event_loop = platform::event_loop(screenshot.is_some())?;5950 let event_loop = platform::event_loop(screenshot.is_some())?;
5951 #[cfg(not(target_os = "macos"))]
5952 if let Some(instance) = instance {
5953 instance.serve(event_loop.create_proxy());
5954 }
5559 if let Some(script) = std::env::var_os("SNOWBOUND_REPLAY") {5955 if let Some(script) = std::env::var_os("SNOWBOUND_REPLAY") {
5560 replay(std::fs::read_to_string(script)?, event_loop.create_proxy())?;5956 replay(std::fs::read_to_string(script)?, event_loop.create_proxy())?;
5561 }5957 }
...@@ -5568,6 +5964,7 @@ fn launch() -> Result<(), Box<dyn Error>> {...@@ -5568,6 +5964,7 @@ fn launch() -> Result<(), Box<dyn Error>> {
5568 saved,5964 saved,
5569 cache,5965 cache,
5570 }),5966 }),
5967 opening,
5571 substitutes,5968 substitutes,
5572 screenshot,5969 screenshot,
5573 state: None,5970 state: None,
...@@ -5589,6 +5986,26 @@ mod tests {...@@ -5589,6 +5986,26 @@ mod tests {
5589 use super::*;5986 use super::*;
5590 use canvas::document::TextPosition;5987 use canvas::document::TextPosition;
55915988
5989 /// A notebook opened from the desktop is listed once and shown first, whether it was
5990 /// listed already or not.
5991 #[test]
5992 fn an_opened_notebook_is_listed_once_and_shown() {
5993 let root = std::env::temp_dir();
5994 let [listed, opened] = ["Listed", "Opened"].map(|name| root.join(name));
5995 let location = |path: &Path| path.to_string_lossy().into_owned();
5996 let mut input = Input::Notebooks {
5997 locations: vec![location(&listed)],
5998 current: None,
5999 };
6000 input.show(&opened).unwrap();
6001 input.show(&listed).unwrap();
6002 let Input::Notebooks { locations, current } = input else {
6003 unreachable!()
6004 };
6005 assert_eq!(locations, [location(&listed), location(&opened)]);
6006 assert_eq!(current, Some(location(&listed)));
6007 }
6008
5592 /// The ops an editing session records reach the section through `apply` and read back6009 /// The ops an editing session records reach the section through `apply` and read back
5593 /// as the editor's page.6010 /// as the editor's page.
5594 #[test]6011 #[test]
...@@ -5778,4 +6195,80 @@ mod tests {...@@ -5778,4 +6195,80 @@ mod tests {
5778 theirs.close().unwrap();6195 theirs.close().unwrap();
5779 std::fs::remove_dir_all(&directory).unwrap();6196 std::fs::remove_dir_all(&directory).unwrap();
5780 }6197 }
6198
6199 /// Times opening each section of a notebook and each of its pages, phase by phase, as a
6200 /// switch does: `SNOWBOUND_SWITCH_NOTEBOOK=FOLDER`, or a share through `ONESTORE_SMB_LAB`
6201 /// (`HOST:PORT`, share `agent`) at `ONESTORE_SMB_LAB_ROOT` with `ONESTORE_SMB_LAB_USER`
6202 /// and `ONESTORE_SMB_LAB_PASSWORD`. The first pass starts without replicas.
6203 #[test]
6204 #[ignore = "measures a notebook named by the environment"]
6205 fn switch_timings() {
6206 let cache = std::env::temp_dir().join(format!("snowbound-switch-{}", std::process::id()));
6207 let _ = std::fs::remove_dir_all(&cache);
6208 let open = || match std::env::var("SNOWBOUND_SWITCH_NOTEBOOK") {
6209 Ok(folder) => Library::notebook(&folder, &cache),
6210 Err(_) => {
6211 let user = std::env::var("ONESTORE_SMB_LAB_USER").ok();
6212 let mount = library::Mount {
6213 server: std::env::var("ONESTORE_SMB_LAB").unwrap(),
6214 share: "agent".into(),
6215 user: user.clone(),
6216 domain: String::new(),
6217 root: std::env::var("ONESTORE_SMB_LAB_ROOT").unwrap(),
6218 };
6219 let login = library::Login {
6220 user: user.unwrap_or_default(),
6221 password: std::env::var("ONESTORE_SMB_LAB_PASSWORD").unwrap_or_default(),
6222 domain: String::new(),
6223 };
6224 Library::on_share(&mount.url(), mount, login, &cache).unwrap()
6225 }
6226 };
6227 let ms = |start: Instant| start.elapsed().as_secs_f64() * 1e3;
6228 let paper = canvas::gpu::Paper {
6229 color: [1.0; 4],
6230 ink: [0.0, 0.0, 0.0, 1.0],
6231 };
6232 let mut engine = TextEngine::default();
6233 for pass in ["cold", "warm"] {
6234 let start = Instant::now();
6235 let library = Arc::new(open());
6236 eprintln!("{pass}\tnotebook\t{:.1}", ms(start));
6237 for tab in library.tabs("") {
6238 let start = Instant::now();
6239 let section = library.open(&tab.path, || {}).unwrap();
6240 let opened = ms(start);
6241 let start = Instant::now();
6242 let (session, page) =
6243 read_session(section, Arc::clone(&library), tab.path.clone(), None).unwrap();
6244 let read = ms(start);
6245 let start = Instant::now();
6246 let (mut scene, editor) = PageScene::from_page(page, &mut engine).unwrap();
6247 let laid = ms(start);
6248 let start = Instant::now();
6249 scene.settle(Some(&editor), 2.0, paper);
6250 eprintln!(
6251 "{pass}\tsection {}\topen {opened:.1}\tread {read:.1}\tlayout {laid:.1}\tpictures {:.1}",
6252 tab.name,
6253 ms(start)
6254 );
6255 for (space, title, _) in session.pages.iter().skip(1) {
6256 let start = Instant::now();
6257 let page = session.section.page(*space).unwrap();
6258 let read = ms(start);
6259 let start = Instant::now();
6260 let (mut scene, editor) = PageScene::from_page(page, &mut engine).unwrap();
6261 let laid = ms(start);
6262 let start = Instant::now();
6263 scene.settle(Some(&editor), 2.0, paper);
6264 eprintln!(
6265 "{pass}\tpage {title}\tread {read:.1}\tlayout {laid:.1}\tpictures {:.1}",
6266 ms(start)
6267 );
6268 }
6269 session.section.close().unwrap();
6270 }
6271 }
6272 let _ = std::fs::remove_dir_all(&cache);
6273 }
5781}6274}
crates/snowbound/src/manage.rs+395-116
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1//! Changes to notebooks, sections, section groups and pages, as OneNote 2010 makes them:1//! Changes to notebooks, sections, section groups and pages, as OneNote 2010 makes them:
2//! structure through `notebook::session::Notebook`, pages as ops on the open section.2//! structure through `notebook::session::Notebook`, pages as ops on the open section.
33
4use crate::{Command, Library, Loaded, State, notify, platform, read_session};4use crate::{Command, Library, Loaded, State, notify, platform, read_session, undo::Change};
5use canvas::template::Template;5use canvas::template::Template;
6use notebook::session::Notebook;6use notebook::session::Notebook;
7use onestore::{7use onestore::{
...@@ -9,7 +9,7 @@ use onestore::{...@@ -9,7 +9,7 @@ use onestore::{
9 op::{Edit, Op, PageOp, SectionOp},9 op::{Edit, Op, PageOp, SectionOp},
10 page::{Image, Page, PageObject},10 page::{Image, Page, PageObject},
11};11};
12use std::{error::Error, sync::Arc};12use std::{error::Error, path::Path, sync::Arc};
1313
14/// A change to a notebook's sections and groups, by catalog path.14/// A change to a notebook's sections and groups, by catalog path.
15pub enum Structure {15pub enum Structure {
...@@ -37,6 +37,44 @@ pub enum Structure {...@@ -37,6 +37,44 @@ pub enum Structure {
37 folder: String,37 folder: String,
38 paths: Vec<String>,38 paths: Vec<String>,
39 },39 },
40 /// Moves a section or group back into a folder, which then takes the order `paths`, as
41 /// Undo takes back a move.
42 Return {
43 path: String,
44 folder: String,
45 paths: Vec<String>,
46 },
47 /// Colours a section's tab (COLORREF); none is OneNote's None.
48 Color {
49 path: String,
50 color: Option<u32>,
51 },
52 /// Notebook Properties: the display name on this computer, and a colour picked for the
53 /// notebook.
54 Properties {
55 name: String,
56 color: Option<u32>,
57 },
58}
59
60/// New iCloud Notebook while it is open: the name typed, and why the last was refused.
61pub struct Naming {
62 name: String,
63 problem: Option<String>,
64}
65
66fn naming() -> ui::Id {
67 ui::Id::ROOT.child("new icloud notebook")
68}
69
70fn naming_field() -> ui::Id {
71 naming().child("name")
72}
73
74/// Whether the notebook at `location` is a folder at the top of the app's iCloud Drive folder,
75/// which the sidebar lists as long as it is there.
76pub fn in_icloud_folder(location: &str) -> bool {
77 crate::icloud::folder().is_some_and(|root| Path::new(location).parent() == Some(&root))
40}78}
4179
42/// `current`, the path of a section, after `from` moved to `to`: a section moved itself,80/// `current`, the path of a section, after `from` moved to `to`: a section moved itself,
...@@ -69,9 +107,16 @@ fn unused(taken: &[String], base: &str, numbered: bool) -> String {...@@ -69,9 +107,16 @@ fn unused(taken: &[String], base: &str, numbered: bool) -> String {
69}107}
70108
71impl State {109impl State {
72 /// A page OneNote 2010 would create now: titled, with its date and time.110 /// A page OneNote 2010 would create now: titled in the Default font's face, with its date
73 fn dated_page(&self, before: Option<ExGuid>) -> Result<PageCreation, Box<dyn Error>> {111 /// and time.
74 let creation = PageCreation::new(before, Some(""), &self.author)?;112 pub(crate) fn dated_page(
113 &self,
114 before: Option<ExGuid>,
115 ) -> Result<PageCreation, Box<dyn Error>> {
116 let creation = PageCreation::new(before, Some(""), &self.author)?.titled_in(
117 &self.view.editor.default_font.face,
118 self.view.editor.default_font.color,
119 )?;
75 let [date, time] = platform::date_text(creation.created());120 let [date, time] = platform::date_text(creation.created());
76 Ok(creation.dated(&date, &time)?)121 Ok(creation.dated(&date, &time)?)
77 }122 }
...@@ -113,6 +158,12 @@ impl State {...@@ -113,6 +158,12 @@ impl State {
113 ops,158 ops,
114 },159 },
115 )?;160 )?;
161 let shown = session.space;
162 self.made(Change::Delete {
163 pages: vec![space],
164 show: Some(shown),
165 created: true,
166 });
116 self.title_focus = Some(space);167 self.title_focus = Some(space);
117 self.commands.push(Command::OpenPage(space));168 self.commands.push(Command::OpenPage(space));
118 Ok(())169 Ok(())
...@@ -211,23 +262,292 @@ impl State {...@@ -211,23 +262,292 @@ impl State {
211 self.create_notebook(root)262 self.create_notebook(root)
212 }263 }
213264
214 /// Opens the notebooks in the app's own iCloud Drive folder, or makes one there.265 /// New iCloud Notebook: asks for a name, the first free of "iCloud Notebook", "iCloud
215 pub(crate) fn use_icloud(&mut self) -> Result<(), Box<dyn Error>> {266 /// Notebook 2"…, for a notebook at the top of the app's iCloud Drive folder.
216 let folder = crate::icloud::folder().ok_or("iCloud Drive is off")?;267 pub(crate) fn new_icloud_notebook(&mut self) {
217 let mut notebooks: Vec<std::path::PathBuf> = std::fs::read_dir(&folder)?268 let taken: Vec<String> = self
218 .flatten()269 .notebooks
219 .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))270 .iter()
220 .filter(|entry| !entry.file_name().to_string_lossy().starts_with('.'))271 .map(|library| library.name.clone())
221 .map(|entry| entry.path())
222 .collect();272 .collect();
223 notebooks.sort();273 self.new_icloud = Some(Naming {
224 if notebooks.is_empty() {274 name: unused(&taken, "iCloud Notebook", false),
225 return self.create_notebook(folder.join("My Notebook"));275 problem: None,
276 });
277 self.ui.open_popup(naming());
278 self.ui.focus_all(naming_field());
279 }
280
281 /// Builds New iCloud Notebook while it is open.
282 pub(crate) fn new_icloud_dialog(&mut self) {
283 use ui::{Anchor, Axis, Spec, children, fill, px};
284 let Some(dialog) = &mut self.new_icloud else {
285 return;
286 };
287 if !self.ui.popup_open(naming()) {
288 self.new_icloud = None;
289 return;
290 }
291 let ui = &mut self.ui;
292 let theme = ui.theme.clone();
293 let row = theme.font_size * 2.0;
294 let entered =
295 ui::popup::navigation(ui, &[naming_field()], &[winit::keyboard::NamedKey::Enter])
296 .contains(&winit::keyboard::NamedKey::Enter);
297 ui.open_as(
298 naming(),
299 Spec {
300 axis: Axis::Y,
301 size: [px(320.0), children()],
302 fill: Some(theme.popup),
303 border: Some(theme.chip),
304 shadow: Some(theme.shadow),
305 radius: 8.0,
306 pad: [16.0, 12.0],
307 gap: 6.0,
308 anchor: Some(Anchor::Dialog),
309 role: Some(accesskit::Role::Dialog),
310 ..Spec::default()
311 },
312 );
313 if let Some(node) = ui.access(naming()) {
314 node.set_label("New iCloud Notebook");
226 }315 }
227 for notebook in notebooks {316 ui.leaf(
228 self.open_notebook(notebook.to_string_lossy().into_owned(), None);317 "title",
318 Spec {
319 size: [fill(), px(row)],
320 text: Some("New iCloud Notebook"),
321 bold: true,
322 role: Some(accesskit::Role::Heading),
323 ..Spec::default()
324 },
325 );
326 ui::text_field(
327 ui,
328 naming_field(),
329 &mut dialog.name,
330 "",
331 Spec {
332 size: [fill(), px(row)],
333 fill: Some(theme.base),
334 border: Some(theme.accent),
335 radius: 4.0,
336 pad: [6.0, 0.0],
337 ..Spec::default()
338 },
339 );
340 crate::name(ui, naming_field(), "Name");
341 if let Some(problem) = &dialog.problem {
342 ui.leaf(
343 "problem",
344 Spec {
345 size: [fill(), ui::fit()],
346 text: Some(problem),
347 overflow: ui::Overflow::Wrap,
348 pad: [0.0, 4.0],
349 role: Some(accesskit::Role::Status),
350 ..Spec::default()
351 },
352 );
229 }353 }
230 Ok(())354 ui.open(
355 "buttons",
356 Spec {
357 size: [fill(), children()],
358 pad: [0.0, 8.0],
359 gap: 8.0,
360 ..Spec::default()
361 },
362 );
363 ui.leaf(
364 "space",
365 Spec {
366 size: [fill(), px(1.0)],
367 ..Spec::default()
368 },
369 );
370 let cancel = ui::button(ui, "cancel", "Cancel").clicked;
371 let create = ui::button(ui, "create", "Create").clicked || entered;
372 ui.close();
373 ui.close();
374 if cancel {
375 self.ui.close_popup(naming());
376 self.ui.set_focus(Some(crate::page()));
377 self.new_icloud = None;
378 } else if create {
379 let name = dialog.name.trim().to_owned();
380 let folder = crate::icloud::folder();
381 dialog.problem = match &folder {
382 None => Some("Turn on iCloud Drive in System Settings.".into()),
383 Some(_) if name.is_empty() => Some("Enter a name.".into()),
384 Some(_) if name.starts_with('.') || name.contains(['/', ':']) => {
385 Some("Use a name without “/”, “:” or a leading “.”.".into())
386 }
387 Some(folder) if folder.join(&name).exists() => Some(format!(
388 "“{name}” is already in iCloud Drive. Choose another name."
389 )),
390 Some(_) => None,
391 };
392 if let (None, Some(folder)) = (&dialog.problem, folder) {
393 self.ui.close_popup(naming());
394 self.new_icloud = None;
395 if let Err(error) = self.create_notebook(folder.join(name)) {
396 platform::alert("Couldn't create the notebook", &error.to_string());
397 }
398 }
399 }
400 }
401
402 /// Lists the folders at the top of the app's iCloud Drive folder in the sidebar, each a
403 /// notebook, as they come and go, and brings down what iCloud Drive keeps elsewhere of
404 /// every notebook in it.
405 pub(crate) fn list_icloud(&mut self) {
406 let proxy = self.proxy.clone();
407 // Listing a folder iCloud Drive has not brought down yet waits for it.
408 std::thread::spawn(move || {
409 let listed = crate::icloud::folder().and_then(|root| {
410 let mut listed: Vec<String> = std::fs::read_dir(&root)
411 .ok()?
412 .flatten()
413 .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))
414 .filter(|entry| !entry.file_name().to_string_lossy().starts_with('.'))
415 .map(|entry| entry.path().to_string_lossy().into_owned())
416 .collect();
417 listed.sort();
418 Some(listed)
419 });
420 let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| {
421 if let Some(listed) = listed {
422 state.listed_icloud(listed);
423 }
424 for library in state.notebooks.clone() {
425 if library.in_icloud() {
426 state.fetch(library, None);
427 }
428 }
429 Ok(())
430 })));
431 });
432 }
433
434 /// Takes `listed`, the notebook folders at the top of the app's iCloud Drive folder, in
435 /// place of those the sidebar lists from it.
436 fn listed_icloud(&mut self, listed: Vec<String>) {
437 let gone: Vec<Arc<Library>> = self
438 .notebooks
439 .iter()
440 .filter(|library| {
441 in_icloud_folder(&library.location) && !listed.contains(&library.location)
442 })
443 .cloned()
444 .collect();
445 for library in gone {
446 eprintln!("DEBUG gone {} listed {listed:?}", library.location);
447 self.close_notebook(&library);
448 }
449 for location in listed {
450 if self.notebooks.iter().any(|open| open.location == location)
451 || !self.icloud_reading.insert(location.clone())
452 {
453 continue;
454 }
455 let (cache, proxy) = (self.cache.clone(), self.proxy.clone());
456 std::thread::spawn(move || {
457 let library = Arc::new(Library::notebook(&location, &cache));
458 let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| {
459 state.icloud_reading.remove(&location);
460 if state.notebooks.iter().any(|open| open.location == location) {
461 return Ok(());
462 }
463 state.notebooks.push(Arc::clone(&library));
464 state.save_settings();
465 state.show_arrived(&library);
466 state.fetch(library, None);
467 Ok(())
468 })));
469 });
470 }
471 }
472
473 /// Shows `library`, a notebook that just arrived or whose sections did, where no other
474 /// notebook is shown: at its first section, or without one while none is here yet.
475 pub(crate) fn show_arrived(&mut self, library: &Arc<Library>) {
476 if self.session.is_some()
477 || self
478 .sectionless
479 .as_ref()
480 .is_some_and(|shown| shown.location != library.location)
481 {
482 return;
483 }
484 let first = library.first_section();
485 self.sectionless = Some(Arc::clone(library));
486 self.title();
487 if let Some(path) = first {
488 self.commands
489 .push(Command::OpenSection(Arc::clone(library), path));
490 }
491 }
492
493 /// Brings down the files of `library`, a notebook in iCloud Drive, that iCloud Drive keeps
494 /// elsewhere, `missing` of them when last asked, and reads the notebook again as they
495 /// arrive, until all have.
496 pub(crate) fn fetch(&mut self, library: Arc<Library>, missing: Option<usize>) {
497 let Some(root) = library.folder().map(Path::to_owned) else {
498 return;
499 };
500 if missing.is_none() && !self.icloud_reading.insert(library.location.clone()) {
501 return;
502 }
503 let proxy = self.proxy.clone();
504 std::thread::spawn(move || {
505 if missing.is_some() {
506 std::thread::sleep(std::time::Duration::from_secs(2));
507 }
508 let now = crate::icloud::download(&root);
509 let arrived = missing.map_or(now == 0 && library.downloading(), |before| now < before);
510 let read = arrived
511 .then(|| {
512 library
513 .reopen()
514 .map(|notebook| Arc::new(library.with(notebook)))
515 })
516 .and_then(|read| {
517 read.inspect_err(|error| eprintln!("{}: {error}", library.location))
518 .ok()
519 });
520 let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| {
521 let Some(listed) = state
522 .notebooks
523 .iter()
524 .find(|open| open.location == library.location)
525 .cloned()
526 else {
527 state.icloud_reading.remove(&library.location);
528 return Ok(());
529 };
530 let current = match read {
531 Some(read) => {
532 let path = state
533 .session
534 .as_ref()
535 .filter(|session| session.library.location == read.location)
536 .map(|session| session.tabs[session.tab].path.clone())
537 .filter(|path| read.contains(path));
538 state.adopt(Arc::clone(&read), path.as_deref())?;
539 read
540 }
541 None => listed,
542 };
543 if now == 0 {
544 state.icloud_reading.remove(&current.location);
545 } else {
546 state.fetch(current, Some(now));
547 }
548 Ok(())
549 })));
550 });
231 }551 }
232552
233 /// Creates a notebook in the new folder `root` and opens it.553 /// Creates a notebook in the new folder `root` and opens it.
...@@ -252,6 +572,7 @@ impl State {...@@ -252,6 +572,7 @@ impl State {
252 /// folders go with the account. Replicas holding edits stay, the open section's also572 /// folders go with the account. Replicas holding edits stay, the open section's also
253 /// exported as a recovery archive; signing in again and reopening publishes them.573 /// exported as a recovery archive; signing in again and reopening publishes them.
254 pub(crate) fn icloud_account_changed(&mut self) {574 pub(crate) fn icloud_account_changed(&mut self) {
575 eprintln!("DEBUG account changed");
255 let closing: Vec<Arc<Library>> = self576 let closing: Vec<Arc<Library>> = self
256 .notebooks577 .notebooks
257 .iter()578 .iter()
...@@ -281,13 +602,14 @@ impl State {...@@ -281,13 +602,14 @@ impl State {
281 for library in closing {602 for library in closing {
282 self.close_notebook(&library);603 self.close_notebook(&library);
283 }604 }
284 crate::icloud::look_up(notify(self.proxy.clone()));605 crate::icloud::look_up(crate::icloud_listed(self.proxy.clone()));
285 }606 }
286607
287 /// Closes `library`: its files stay, its offline copies go unless edits wait in them,608 /// Closes `library`: its files stay, its offline copies go unless edits wait in them,
288 /// and the sidebar and the next launch leave it out.609 /// and the sidebar and the next launch leave it out.
289 pub(crate) fn close_notebook(&mut self, library: &Arc<Library>) {610 pub(crate) fn close_notebook(&mut self, library: &Arc<Library>) {
290 self.notebooks.retain(|open| !Arc::ptr_eq(open, library));611 self.notebooks.retain(|open| !Arc::ptr_eq(open, library));
612 self.undo.close(&library.location);
291 if let Some(background) = &library.background {613 if let Some(background) = &library.background {
292 background.discard();614 background.discard();
293 }615 }
...@@ -336,6 +658,8 @@ impl State {...@@ -336,6 +658,8 @@ impl State {
336 .map(|session| session.tabs[session.tab].path.clone());658 .map(|session| session.tabs[session.tab].path.clone());
337 let notify = notify(self.proxy.clone());659 let notify = notify(self.proxy.clone());
338 self.load(move || {660 self.load(move || {
661 // A section kept open would hold a file the change moves.
662 library.close_kept();
339 let mut notebook = library.reopen()?;663 let mut notebook = library.reopen()?;
340 let names = |notebook: &Notebook, folder: &str| -> Vec<String> {664 let names = |notebook: &Notebook, folder: &str| -> Vec<String> {
341 let mut folders = vec![notebook.catalog()];665 let mut folders = vec![notebook.catalog()];
...@@ -356,6 +680,11 @@ impl State {...@@ -356,6 +680,11 @@ impl State {
356 }680 }
357 Vec::new()681 Vec::new()
358 };682 };
683 // A notebook not shown stays so through a change of its colours or name.
684 let stays = matches!(
685 change,
686 Structure::Color { .. } | Structure::Properties { .. }
687 );
359 // The open section where the change leaves it, and the section to show.688 // The open section where the change leaves it, and the section to show.
360 let (followed, created) = match change {689 let (followed, created) = match change {
361 Structure::NewSection { folder } => {690 Structure::NewSection { folder } => {
...@@ -391,10 +720,36 @@ impl State {...@@ -391,10 +720,36 @@ impl State {
391 notebook.reorder(&folder, &paths)?;720 notebook.reorder(&folder, &paths)?;
392 (current, None)721 (current, None)
393 }722 }
723 Structure::Return {
724 path,
725 folder,
726 paths,
727 } => {
728 let moved = notebook.move_entry(&path, &folder)?;
729 let paths: Vec<&str> = paths.iter().map(String::as_str).collect();
730 notebook.reorder(&folder, &paths)?;
731 (current.map(|current| follow(&current, &path, &moved)), None)
732 }
733 Structure::Color { path, color } => {
734 notebook.set_section_color(&path, color)?;
735 (current, None)
736 }
737 Structure::Properties { name, color } => {
738 if name != library.name {
739 library.set_display_name(&name)?;
740 }
741 if let Some(color) = color {
742 notebook.set_color(color)?;
743 }
744 (current, None)
745 }
394 };746 };
395 let fresh = created.is_some();747 let fresh = created.is_some();
396 let open = created.or(followed.clone());748 let open = created.or(followed.clone());
397 let library = Arc::new(library.with(notebook));749 let library = Arc::new(library.with(notebook));
750 if stays && followed.is_none() {
751 return Ok(Loaded::Library(library, None));
752 }
398 let Some(path) = shown(&library, open) else {753 let Some(path) = shown(&library, open) else {
399 return Ok(Loaded::Library(library, None));754 return Ok(Loaded::Library(library, None));
400 };755 };
...@@ -416,115 +771,38 @@ impl State {...@@ -416,115 +771,38 @@ impl State {
416 /// Deletes pages of the open section as OneNote 2010 does: each goes to the notebook's771 /// Deletes pages of the open section as OneNote 2010 does: each goes to the notebook's
417 /// recycle bin, then leaves the section. A section left without pages gains a new one.772 /// recycle bin, then leaves the section. A section left without pages gains a new one.
418 pub(crate) fn delete_pages(&mut self, spaces: Vec<ExGuid>) -> Result<(), Box<dyn Error>> {773 pub(crate) fn delete_pages(&mut self, spaces: Vec<ExGuid>) -> Result<(), Box<dyn Error>> {
419 self.persist()?;774 self.change_pages(Change::Delete {
420 let session = self.session.as_ref().ok_or("No section is open")?;775 pages: spaces,
421 let pages: Vec<Page> = spaces776 show: None,
422 .iter()777 created: false,
423 .map(|space| session.section.page(*space))778 })
424 .collect::<Result<_, _>>()?;
425 let remaining: Vec<ExGuid> = session
426 .pages
427 .iter()
428 .map(|(space, ..)| *space)
429 .filter(|space| !spaces.contains(space))
430 .collect();
431 // OneNote shows the page after the first deleted one, or the one before it.
432 let at = session
433 .pages
434 .iter()
435 .position(|(space, ..)| spaces.contains(space))
436 .unwrap_or_default();
437 let next = session.pages[at..]
438 .iter()
439 .chain(session.pages[..at].iter().rev())
440 .map(|(space, ..)| *space)
441 .find(|space| remaining.contains(space));
442 let mut ops = vec![Op::Section(SectionOp::Delete(spaces))];
443 let next = match next {
444 Some(next) => next,
445 None => {
446 let creation = self.dated_page(None)?;
447 let space = creation.space();
448 ops.push(Op::Section(SectionOp::Create(creation)));
449 space
450 }
451 };
452 let notebook =
453 matches!(session.library.notebook, Ok(Some(_))).then(|| Arc::clone(&session.library));
454 let replica = Arc::clone(session.section.replica());
455 let author = self.author.clone();
456 self.load(move || {
457 if let Some(library) = notebook {
458 library.reopen()?.recycle_pages(&pages, &author)?;
459 }
460 replica.apply(
461 &author,
462 Edit {
463 at: crate::filetime(),
464 ops,
465 },
466 )?;
467 Ok(Loaded::Page(next, replica.page(next)?))
468 });
469 Ok(())
470 }779 }
471780
472 /// Moves page `space` of the open section to the end of the section at catalog `path`,781 /// Moves page `space` of the open section to the end of the section at catalog `path`,
473 /// as OneNote moves a page dropped on a section's tab: the page keeps its identity,782 /// as OneNote moves a page dropped on a section's tab: the page keeps its identity,
474 /// title, date and content there and leaves this section.783 /// title, date and content there and leaves this section.
475 pub(crate) fn move_page(&mut self, space: ExGuid, path: String) -> Result<(), Box<dyn Error>> {784 pub(crate) fn move_page(&mut self, space: ExGuid, path: String) -> Result<(), Box<dyn Error>> {
476 self.persist()?;
477 let session = self.session.as_ref().ok_or("No section is open")?;785 let session = self.session.as_ref().ok_or("No section is open")?;
478 let import = notebook::session::moved(&session.section.page(space)?, &self.author)?;786 let target = session
479 let at = session787 .library
480 .pages788 .section_identity(&path)
481 .iter()789 .ok_or("That section is not in the notebook")?;
482 .position(|(listed, ..)| *listed == space)790 self.change_pages(Change::Send {
483 .unwrap_or_default();791 page: space,
484 let next = session.pages[at + 1..]792 target,
485 .iter()793 })
486 .chain(session.pages[..at].iter().rev())
487 .map(|(listed, ..)| *listed)
488 .next();
489 let mut ops = vec![Op::Section(SectionOp::Delete(vec![space]))];
490 let next = match next {
491 Some(next) => next,
492 None => {
493 let fresh = self.dated_page(None)?;
494 let space = fresh.space();
495 ops.push(Op::Section(SectionOp::Create(fresh)));
496 space
497 }
498 };
499 let library = Arc::clone(&session.library);
500 let replica = Arc::clone(session.section.replica());
501 let author = self.author.clone();
502 self.load(move || {
503 let target = library.open(&path, || {})?;
504 target.replica().apply(
505 &author,
506 Edit {
507 at: crate::filetime(),
508 ops: vec![import],
509 },
510 )?;
511 target.close()?;
512 replica.apply(
513 &author,
514 Edit {
515 at: crate::filetime(),
516 ops,
517 },
518 )?;
519 Ok(Loaded::Page(next, replica.page(next)?))
520 });
521 Ok(())
522 }794 }
523795
524 /// Moves or indents pages of the open section, in order.796 /// Moves or indents pages of the open section, in order.
525 pub(crate) fn edit_pages(&mut self, edits: Vec<PageEdit>) -> Result<(), Box<dyn Error>> {797 pub(crate) fn edit_pages(&mut self, edits: Vec<PageEdit>) -> Result<(), Box<dyn Error>> {
526 self.persist()?;798 self.persist()?;
527 let session = self.session.as_mut().ok_or("No section is open")?;799 let session = self.session.as_mut().ok_or("No section is open")?;
800 let order = session
801 .pages
802 .iter()
803 .map(|(space, _, level)| (*space, *level))
804 .collect();
805 let moved = edits.iter().map(PageEdit::space).collect();
528 session.section.apply(806 session.section.apply(
529 &self.author,807 &self.author,
530 Edit {808 Edit {
...@@ -533,6 +811,7 @@ impl State {...@@ -533,6 +811,7 @@ impl State {
533 },811 },
534 )?;812 )?;
535 session.pages = session.section.pages()?;813 session.pages = session.section.pages()?;
814 self.made(Change::Arrange { order, moved });
536 Ok(())815 Ok(())
537 }816 }
538}817}
crates/snowbound/src/meeting.rs+1
...@@ -102,6 +102,7 @@ pub fn content(page: &Page) -> Result<Page, Box<dyn Error>> {...@@ -102,6 +102,7 @@ pub fn content(page: &Page) -> Result<Page, Box<dyn Error>> {
102 Definition {102 Definition {
103 kind: Kind::Style {103 kind: Kind::Style {
104 name: Some("p".into()),104 name: Some("p".into()),
105 next: None,
105 },106 },
106 format: Format {107 format: Format {
107 language: None,108 language: None,
crates/snowbound/src/menubar.rs+34-7
...@@ -68,7 +68,6 @@ const MENUS: &[Item] = &[...@@ -68,7 +68,6 @@ const MENUS: &[Item] = &[
68 C(Id::ShowNotebook),68 C(Id::ShowNotebook),
69 S,69 S,
70 C(Id::ExportPdf),70 C(Id::ExportPdf),
71 C(Id::ExportSectionPdf),
72 S,71 S,
73 C(Id::Print),72 C(Id::Print),
74 ],73 ],
...@@ -158,6 +157,31 @@ const MENUS: &[Item] = &[...@@ -158,6 +157,31 @@ const MENUS: &[Item] = &[
158 Item::Menu(157 Item::Menu(
159 "Format",158 "Format",
160 &[159 &[
160 Item::Menu(
161 "Style",
162 &[
163 C(Id::Style(0)),
164 C(Id::Style(1)),
165 C(Id::Style(2)),
166 C(Id::Style(3)),
167 C(Id::Style(4)),
168 C(Id::Style(5)),
169 C(Id::Style(6)),
170 C(Id::Style(7)),
171 C(Id::Style(8)),
172 C(Id::Style(9)),
173 C(Id::Style(10)),
174 ],
175 ),
176 Item::Menu(
177 "Theme",
178 &[
179 C(Id::Theme(crate::themes::Scope::Page)),
180 C(Id::Theme(crate::themes::Scope::Section)),
181 C(Id::Theme(crate::themes::Scope::Notebook)),
182 ],
183 ),
184 S,
161 Item::Menu("Font", &[Item::Fonts]),185 Item::Menu("Font", &[Item::Fonts]),
162 Item::Menu("Size", &[Item::Sizes]),186 Item::Menu("Size", &[Item::Sizes]),
163 S,187 S,
...@@ -264,11 +288,10 @@ fn menu(...@@ -264,11 +288,10 @@ fn menu(
264 for item in items {288 for item in items {
265 match item {289 match item {
266 Item::Command(id) => {290 Item::Command(id) => {
267 let command = commands::command(*id);
268 choose(291 choose(
269 &Choice::Command(*id),292 &Choice::Command(*id),
270 command.title,293 commands::command(*id).title,
271 command.mac.first().copied(),294 commands::chords(*id).first().copied(),
272 );295 );
273 }296 }
274 Item::System(title, action, chord) => {297 Item::System(title, action, chord) => {
...@@ -320,8 +343,12 @@ pub fn tags(mtm: MainThreadMarker, target: &AnyObject, menu: &NSMenu, tags: &[No...@@ -320,8 +343,12 @@ pub fn tags(mtm: MainThreadMarker, target: &AnyObject, menu: &NSMenu, tags: &[No
320 menu.addItem(&NSMenuItem::separatorItem(mtm));343 menu.addItem(&NSMenuItem::separatorItem(mtm));
321 }344 }
322 for id in [Id::CustomizeTags, Id::RemoveTags, Id::FindTags] {345 for id in [Id::CustomizeTags, Id::RemoveTags, Id::FindTags] {
323 let command = commands::command(id);346 choose(
324 choose(menu, id, command.title, command.mac.first().copied());347 menu,
348 id,
349 commands::command(id).title,
350 commands::chords(id).first().copied(),
351 );
325 }352 }
326}353}
327354
...@@ -451,7 +478,7 @@ mod tests {...@@ -451,7 +478,7 @@ mod tests {
451 }478 }
452 // The keyboard's first chord for each command is on its menu item.479 // The keyboard's first chord for each command is on its menu item.
453 for command in commands::COMMANDS {480 for command in commands::COMMANDS {
454 let Some(chord) = command.mac.first() else {481 let Some(chord) = commands::chords(command.id).first().copied() else {
455 continue;482 continue;
456 };483 };
457 let line = tree484 let line = tree
crates/snowbound/src/menus.rs+620-116
...@@ -1,7 +1,12 @@...@@ -1,7 +1,12 @@
1//! Context menus on pages, sections, section groups and notebooks, with OneNote 2010's1//! Context menus on pages, sections, section groups and notebooks, with OneNote 2010's
2//! commands in its words, and moving pages by dragging their tabs.2//! commands in its words, and moving pages by dragging their tabs.
33
4use crate::{Command, Library, State, manage::Structure, platform};4use crate::{
5 Command, Library, State,
6 commands::{self, Choice},
7 manage::Structure,
8 platform,
9};
5use onestore::{ExGuid, PageEdit};10use onestore::{ExGuid, PageEdit};
6use std::sync::Arc;11use std::sync::Arc;
7use ui::{Anchor, Id, popup::Item};12use ui::{Anchor, Id, popup::Item};
...@@ -46,12 +51,94 @@ impl Drag {...@@ -46,12 +51,94 @@ impl Drag {
46 }51 }
47}52}
4853
49/// What a context menu was opened on.54/// What a context menu, or the palette's actions, are on.
55#[derive(Clone)]
50pub enum Target {56pub enum Target {
51 Page(ExGuid),57 /// A page of a section, open or not.
52 Section { library: Arc<Library>, path: String },58 Page {
53 Group { library: Arc<Library>, path: String },59 library: Arc<Library>,
60 path: String,
61 space: ExGuid,
62 },
63 Section {
64 library: Arc<Library>,
65 path: String,
66 },
67 Group {
68 library: Arc<Library>,
69 path: String,
70 },
54 Notebook(Arc<Library>),71 Notebook(Arc<Library>),
72 /// A notebook shown lately but closed since, by location.
73 Closed(String),
74 /// A server saved to reconnect to, by address.
75 Server(String),
76 Command(commands::Id),
77}
78
79/// What can be done to a target, as its context menu and the palette's actions list it.
80#[derive(Clone, Debug, PartialEq)]
81pub enum Action {
82 Open,
83 Run,
84 Rename,
85 Delete,
86 /// Opens the submenu of the places `MoveTo` takes it.
87 Move,
88 /// Moves it to a section, or into a folder, by catalog path.
89 MoveTo(String),
90 CopyLink,
91 NewPage,
92 NewSubpage,
93 MakeSubpage,
94 PromoteSubpage,
95 Versions(bool),
96 NewSection,
97 NewGroup,
98 Reveal,
99 Close,
100 /// Opens the Themes dialog for it.
101 Theme,
102 /// Opens the notebook's sync status.
103 SyncStatus,
104 /// Opens the submenu of the colours `Color` gives a section.
105 Colors,
106 /// A section's colour, COLORREF; none is OneNote's None.
107 Color(Option<u32>),
108 Sync,
109 NewNotebook,
110 /// Moves a notebook up the notebook list, or down.
111 Raise(bool),
112 Properties,
113}
114
115/// OneNote 2010's section and notebook colours, COLORREF, with their names in its Section
116/// Color menu's order (`corpus/section-color/native`).
117pub(crate) const SECTION_COLORS: [(u32, &str); 16] = [
118 (0xe4a88a, "Blue"),
119 (0x69d8ff, "Yellow"),
120 (0x97c9b7, "Green"),
121 (0x9795ee, "Red"),
122 (0xde9eb4, "Purple"),
123 (0xaeba91, "Cyan"),
124 (0x78b0f6, "Orange"),
125 (0xbba4d5, "Magenta"),
126 (0xd2bb9b, "Blue Mist"),
127 (0xb79cab, "Purple Mist"),
128 (0x99d1e8, "Tan"),
129 (0x6ff9f5, "Lemon"),
130 (0x92e7ad, "Apple"),
131 (0xcabc4d, "Teal"),
132 (0x7575ba, "Red Chalk"),
133 (0xaa9595, "Silver"),
134];
135
136/// A place Move offers.
137struct Destination {
138 name: String,
139 path: String,
140 icon: &'static [&'static str],
141 tint: Option<[f32; 4]>,
55}142}
56143
57pub fn id() -> Id {144pub fn id() -> Id {
...@@ -134,115 +221,328 @@ impl State {...@@ -134,115 +221,328 @@ impl State {
134 self.menu = None;221 self.menu = None;
135 return;222 return;
136 }223 }
137 let item = |text| Item {224 let (target, point) = (target.clone(), *point);
138 text,225 let mut actions: Vec<(Action, Item)> = self.actions(&target);
139 ..Default::default()226 // A notebook's menu heads with its sync status in a line, which opens the whole.
227 let status = match &target {
228 Target::Notebook(library) => self.session.as_ref().map(|session| {
229 let (text, icon) = crate::sync::line(library, session);
230 (Arc::clone(library), text, icon)
231 }),
232 _ => None,
140 };233 };
141 let items: Vec<Item> = match target {234 if let Some((_, text, icon)) = &status {
142 Target::Page(space) => {235 actions[0].1.separated = true;
143 let pages = self236 let item = Item {
144 .session237 text,
145 .as_ref()238 icon: Some(*icon),
146 .map_or(&[][..], |session| &session.pages[..]);239 ..Item::default()
240 };
241 actions.insert(0, (Action::SyncStatus, item));
242 }
243 let action = self.action_menu(id(), Anchor::Point(point), None, &target, &actions);
244 match (action, status) {
245 (Some(Action::SyncStatus), Some((library, ..))) => {
246 self.menu = None;
247 self.ui.close_popup(id());
248 self.show_sync(library, point);
249 }
250 (Some(action), _) => {
251 self.menu = None;
252 self.act_on(target, action);
253 }
254 (None, _) => {}
255 }
256 }
257
258 /// What can be done to `target`, in its context menu's order, as each shows there.
259 pub(crate) fn actions(&self, target: &Target) -> Vec<(Action, Item<'static>)> {
260 // An action, its label, whether it is disabled, and whether a rule starts its group.
261 let item = |action: Action, text, disabled, separated| {
262 let submenu = matches!(action, Action::Move | Action::Colors);
263 let item = Item {
264 text,
265 disabled,
266 separated,
267 submenu,
268 ..Item::default()
269 };
270 (action, item)
271 };
272 let nowhere = || self.destinations(target).is_empty();
273 match target {
274 Target::Page {
275 library,
276 path,
277 space,
278 } => {
279 let mut actions = vec![
280 item(Action::Rename, "Rename", false, false),
281 item(Action::Delete, "Delete", false, false),
282 item(Action::Move, "Move to Section", nowhere(), false),
283 item(Action::CopyLink, "Copy Link to Page", false, true),
284 item(Action::NewPage, "New Page", false, true),
285 // OneNote offers this beside New Page, which is a plain + here.
286 item(Action::NewSubpage, "New Subpage", false, false),
287 item(Action::Theme, "Theme…", false, true),
288 ];
289 // Levels and versions are known once the section is open.
290 let Some(session) = self.session.as_ref().filter(|_| self.open(library, path))
291 else {
292 return actions;
293 };
294 let pages = &session.pages;
147 let at = pages.iter().position(|(listed, ..)| listed == space);295 let at = pages.iter().position(|(listed, ..)| listed == space);
148 let level = at.map_or(1, |at| pages[at].2);296 let level = at.map_or(1, |at| pages[at].2);
149 let above = at297 let above = at
150 .and_then(|at| at.checked_sub(1))298 .and_then(|at| at.checked_sub(1))
151 .map_or(0, |above| pages[above].2);299 .map_or(0, |above| pages[above].2);
152 let (versions, shown) = self.session.as_ref().map_or((false, false), |session| {300 let shown = session.shown_history == Some(*space);
153 (301 let versions = if shown {
154 !session.page_versions(*space).is_empty(),302 "Hide Page Versions"
155 session.shown_history == Some(*space),303 } else {
156 )304 "Show Page Versions"
157 });305 };
306 actions.extend([
307 item(
308 Action::MakeSubpage,
309 "Make Subpage",
310 level > above || level >= 3,
311 true,
312 ),
313 item(Action::PromoteSubpage, "Promote Subpage", level <= 1, false),
314 item(
315 Action::Versions(!shown),
316 versions,
317 session.page_versions(*space).is_empty(),
318 true,
319 ),
320 ]);
321 actions
322 }
323 Target::Section { library, path } | Target::Group { library, path } => {
324 let mut actions = vec![
325 item(Action::Rename, "Rename", false, false),
326 item(Action::Delete, "Delete", false, false),
327 item(Action::Move, "Move", nowhere(), false),
328 item(Action::NewSection, "New Section", false, true),
329 item(Action::NewGroup, "New Section Group", false, false),
330 ];
331 if matches!(target, Target::Section { .. }) {
332 actions.extend([
333 item(Action::Colors, "Section Color", false, true),
334 item(
335 Action::Reveal,
336 platform::SHOW_FILE,
337 section_file(library, path).is_none(),
338 false,
339 ),
340 item(Action::Theme, "Theme…", false, false),
341 ]);
342 }
343 actions
344 }
345 Target::Notebook(library) => {
346 let listed = (self.notebooks.iter()).position(|open| Arc::ptr_eq(open, library));
347 let last = self.notebooks.len().saturating_sub(1);
158 vec![348 vec![
159 item("Delete"),349 item(Action::Rename, "Rename…", false, false),
160 Item {350 item(
161 separated: true,351 Action::Sync,
162 ..item("Copy Link to Page")352 "Sync This Notebook Now",
163 },353 library.background.is_none(),
164 Item {354 false,
165 separated: true,355 ),
166 ..item("New Page")356 // The app's iCloud Drive folder lists every notebook in it.
167 },357 item(
168 // OneNote offers this beside New Page, which is a plain + here.358 Action::Close,
169 item("New Subpage"),359 "Close This Notebook",
170 Item {360 crate::manage::in_icloud_folder(&library.location),
171 separated: true,361 false,
172 disabled: level > above || level >= 3,362 ),
173 ..item("Make Subpage")363 item(Action::CopyLink, "Copy Link to Notebook", false, true),
174 },364 item(Action::NewSection, "New Section", false, true),
175 Item {365 item(Action::NewGroup, "New Section Group", false, false),
176 disabled: level <= 1,366 item(
177 ..item("Promote Subpage")367 Action::NewNotebook,
178 },368 commands::command(commands::Id::NewNotebook).title,
179 Item {369 false,
180 separated: true,370 false,
181 disabled: !versions,371 ),
182 ..item(if shown {372 item(
183 "Hide Page Versions"373 Action::Raise(true),
184 } else {374 "Move Up",
185 "Show Page Versions"375 listed.is_none_or(|at| at == 0),
186 })376 true,
187 },377 ),
378 item(
379 Action::Raise(false),
380 "Move Down",
381 listed.is_none_or(|at| at == last),
382 false,
383 ),
384 item(
385 Action::Reveal,
386 commands::command(commands::Id::ShowNotebook).title,
387 library.folder().is_none(),
388 true,
389 ),
390 item(Action::Theme, "Theme…", false, false),
391 item(
392 Action::Properties,
393 "Properties…",
394 library.catalog().is_none(),
395 false,
396 ),
188 ]397 ]
189 }398 }
190 Target::Section { .. } | Target::Group { .. } => vec![399 Target::Closed(_) | Target::Server(_) => {
191 item("Rename"),400 vec![item(Action::Delete, "Remove from Recent", false, false)]
192 item("Delete"),401 }
193 Item {402 Target::Command(_) => Vec::new(),
194 separated: true,403 }
195 ..item("New Section")404 }
196 },405
197 item("New Section Group"),406 /// Builds menu `id` of `actions` on `target` at `anchor`, under a filter field showing
198 ],407 /// `filter` when given, with Move's places in its submenu: the action chosen.
199 Target::Notebook(_) => vec![408 pub(crate) fn action_menu(
200 item("New Section"),409 &mut self,
201 item("New Section Group"),410 id: Id,
202 Item {411 anchor: Anchor,
203 separated: true,412 filter: Option<&str>,
204 ..item("Close This Notebook")413 target: &Target,
205 },414 actions: &[(Action, Item)],
206 ],415 ) -> Option<Action> {
207 };416 let items: Vec<Item> = actions.iter().map(|(_, item)| *item).collect();
208 let Some(chosen) = ui::popup::menu(&mut self.ui, id(), Anchor::Point(*point), &items, None)417 let chosen = ui::popup::menu(&mut self.ui, id, anchor, &items, filter);
209 else {418 let places = id.child("move");
210 return;419 let colors = id.child("colors");
420 ui::popup::submenus(&mut self.ui, id, &items, |index| match actions[index].0 {
421 Action::Move => Some(places),
422 Action::Colors => Some(colors),
423 _ => None,
424 });
425 let moved = if self.ui.popup_open(places) {
426 let destinations = self.destinations(target);
427 let items: Vec<Item> = (destinations.iter())
428 .map(|place| Item {
429 text: &place.name,
430 icon: Some(place.icon),
431 tint: place.tint,
432 ..Item::default()
433 })
434 .collect();
435 ui::popup::menu(&mut self.ui, places, anchor, &items, None)
436 .map(|index| Action::MoveTo(destinations[index].path.clone()))
437 } else {
438 None
211 };439 };
212 let Some((target, _)) = self.menu.take() else {440 let colored = match target {
213 return;441 Target::Section { library, path } if self.ui.popup_open(colors) => {
442 let current = (library.tabs(&folder(path)).into_iter())
443 .find(|tab| tab.path == *path)
444 .and_then(|tab| tab.color);
445 let theme = &self.ui.theme;
446 let mut items: Vec<Item> = (SECTION_COLORS.iter())
447 .map(|&(color, text)| Item {
448 text,
449 icon: Some(crate::art::SECTION),
450 tint: Some(theme.section(crate::section_color(Some(color))).accent),
451 checked: Some(current == Some(color)),
452 ..Item::default()
453 })
454 .collect();
455 items.push(Item {
456 text: "None",
457 checked: Some(current.is_none()),
458 separated: true,
459 ..Item::default()
460 });
461 ui::popup::menu(&mut self.ui, colors, anchor, &items, None)
462 .map(|index| Action::Color(SECTION_COLORS.get(index).map(|(color, _)| *color)))
463 }
464 _ => None,
214 };465 };
215 let command = match (target, items[chosen].text) {466 chosen
216 (Target::Page(space), "Delete") => Some(Command::DeletePages(vec![space])),467 .map(|index| actions[index].0.clone())
217 (Target::Page(space), "Copy Link to Page") => {468 .or(moved)
218 let copied = self469 .or(colored)
219 .page_link(space, None)470 }
220 .and_then(|link| self.clipboard.set_text(link));471
221 if let Err(error) = copied {472 /// Where Move takes `target`: the other sections of a page's folder, or the folders a
222 eprintln!("Copying the link failed: {error}");473 /// section or group may move into.
223 }474 fn destinations(&self, target: &Target) -> Vec<Destination> {
475 let theme = &self.ui.theme;
476 match target {
477 Target::Page { library, path, .. } => (library.tabs(&folder(path)).into_iter())
478 .filter(|tab| tab.path != *path)
479 .map(|tab| Destination {
480 tint: Some(theme.section(crate::section_color(tab.color)).accent),
481 name: tab.name,
482 path: tab.path,
483 icon: crate::art::SECTION,
484 })
485 .collect(),
486 Target::Section { library, path } | Target::Group { library, path } => {
487 let within = format!("{path}/");
488 let home = folder(path);
489 (folders(library).into_iter())
490 .filter(|place| {
491 place.path != home
492 && place.path != *path
493 && !place.path.starts_with(&within)
494 })
495 .map(|place| Destination {
496 name: match place.path.rsplit_once('/') {
497 _ if place.path.is_empty() => library.name.clone(),
498 Some((_, name)) => name.to_owned(),
499 None => place.path.clone(),
500 },
501 path: place.path.clone(),
502 icon: if place.path.is_empty() {
503 crate::art::NOTEBOOK
504 } else {
505 crate::art::SECTION_GROUP
506 },
507 tint: None,
508 })
509 .collect()
510 }
511 _ => Vec::new(),
512 }
513 }
514
515 /// Does `action` to `target`. An action on a page of a section not open opens it there
516 /// first, then acts.
517 pub(crate) fn act_on(&mut self, target: Target, action: Action) {
518 let command = match (target, action) {
519 (Target::Command(id), _) => {
520 self.choose(Choice::Command(id));
521 None
522 }
523 (
524 Target::Page {
525 library,
526 path,
527 space,
528 },
529 action,
530 ) if action == Action::Open || !self.open(&library, &path) => {
531 self.go(library, path, space);
532 self.after_open = (action != Action::Open).then_some((space, action));
533 None
534 }
535 (Target::Page { space, .. }, action) => self.page_command(space, action),
536 (Target::Section { library, path }, Action::Open) => {
537 Some(Command::OpenSection(library, path))
538 }
539 (Target::Notebook(library), Action::Open) => {
540 self.open_notebook(library.location.clone(), None);
224 None541 None
225 }542 }
226 (Target::Page(_), "New Page") => Some(Command::NewPage { under: None }),
227 (Target::Page(space), "New Subpage") => Some(Command::NewPage { under: Some(space) }),
228 (Target::Page(page), "Show Page Versions") => Some(Command::History { page, show: true }),
229 (Target::Page(page), "Hide Page Versions") => Some(Command::History { page, show: false }),
230 (Target::Page(space), text) => self.session.as_ref().and_then(|session| {
231 let level = session
232 .pages
233 .iter()
234 .find(|(listed, ..)| *listed == space)?
235 .2;
236 let level = if text == "Make Subpage" {
237 level + 1
238 } else {
239 level - 1
240 };
241 Some(Command::Pages(vec![PageEdit::set_level(space, level).ok()?]))
242 }),
243 (543 (
244 Target::Section { library, path } | Target::Group { library, path },544 Target::Section { library, path } | Target::Group { library, path },
245 "Rename",545 Action::Rename,
246 ) => {546 ) => {
247 self.rename(crate::rename::Target::Entry {547 self.rename(crate::rename::Target::Entry {
248 library,548 library,
...@@ -251,37 +551,162 @@ impl State {...@@ -251,37 +551,162 @@ impl State {
251 });551 });
252 None552 None
253 }553 }
254 (Target::Section { library, path }, "Delete") => platform::confirm(554 (Target::Section { library, path }, Action::Delete) => platform::confirm(
255 "Are you sure you want to move this section to this notebook's Recycle Bin?",555 "Are you sure you want to move this section to this notebook's Recycle Bin?",
256 &path,556 &path,
257 "Cancel",557 "Cancel",
258 "Delete",558 "Delete",
259 )559 )
260 .then(|| Command::Structure(library, Structure::Delete { path })),560 .then(|| Command::Structure(library, Structure::Delete { path })),
261 (Target::Group { library, path }, "Delete") => platform::confirm(561 (Target::Group { library, path }, Action::Delete) => platform::confirm(
262 "Are you sure you want to move the sections in this section group to this notebook's Recycle Bin?",562 "Are you sure you want to move the sections in this section group to this notebook's Recycle Bin?",
263 path.rsplit('/').next().unwrap_or_default(),563 path.rsplit('/').next().unwrap_or_default(),
264 "Cancel",564 "Cancel",
265 "Delete",565 "Delete",
266 )566 )
267 .then(|| Command::Structure(library, Structure::Delete { path })),567 .then(|| Command::Structure(library, Structure::Delete { path })),
268 (Target::Section { library, path }, text) => {568 (
269 let folder = folder(&path);569 Target::Section { library, path } | Target::Group { library, path },
270 Some(Command::Structure(library, new(text, folder)))570 Action::MoveTo(folder),
571 ) => Some(Command::Structure(library, Structure::Move { path, folder })),
572 (Target::Section { library, path }, Action::Theme) => {
573 if let Some(identity) = library.section_identity(&path) {
574 let scope = notebook::sidecar::themes::Scope::section(identity);
575 self.show_themes(crate::themes::Scope::Section, library, scope);
576 }
577 None
578 }
579 (Target::Notebook(library), Action::Theme) => {
580 let scope = notebook::sidecar::themes::Scope::Notebook;
581 self.show_themes(crate::themes::Scope::Notebook, library, scope);
582 None
583 }
584 (Target::Section { library, path }, Action::Color(color)) => Some(Command::Structure(
585 library,
586 Structure::Color { path, color },
587 )),
588 (Target::Section { library, path }, Action::Reveal) => {
589 if let Some(file) = section_file(&library, &path) {
590 platform::show_file(&file);
591 }
592 None
593 }
594 (Target::Section { library, path }, action) => {
595 new(&action, folder(&path)).map(|structure| Command::Structure(library, structure))
596 }
597 (Target::Group { library, path }, action) => {
598 new(&action, path).map(|structure| Command::Structure(library, structure))
599 }
600 (Target::Notebook(library), Action::Reveal) => {
601 platform::reveal(&library.location);
602 None
603 }
604 (Target::Notebook(library), Action::Close) => Some(Command::CloseNotebook(library)),
605 (Target::Notebook(library), Action::Rename | Action::Properties) => {
606 self.open_properties(library);
607 None
608 }
609 (Target::Notebook(library), Action::Sync) => {
610 if let Some(session) = &self.session
611 && Arc::ptr_eq(&session.library, &library)
612 {
613 session.section.wake();
614 }
615 library.background.iter().for_each(|background| background.wake());
616 None
617 }
618 (Target::Notebook(library), Action::CopyLink) => {
619 // OneNote 2010's Copy Link to Notebook: the folder's path, spaces escaped.
620 let link = format!("onenote:///{}", library.location.replace(' ', "%20"));
621 if let Err(error) = self.clipboard.set_text(link) {
622 eprintln!("Copying the link failed: {error}");
623 }
624 None
625 }
626 (Target::Notebook(_), Action::NewNotebook) => {
627 self.choose(Choice::Command(commands::Id::NewNotebook));
628 None
271 }629 }
272 (Target::Group { library, path }, text) => {630 (Target::Notebook(library), Action::Raise(up)) => {
273 Some(Command::Structure(library, new(text, path)))631 let at = (self.notebooks.iter()).position(|open| Arc::ptr_eq(open, &library));
632 let to = at.and_then(|at| if up { at.checked_sub(1) } else { Some(at + 1) });
633 if let (Some(at), Some(to)) = (at, to.filter(|to| *to < self.notebooks.len())) {
634 self.notebooks.swap(at, to);
635 self.save_settings();
636 }
637 None
274 }638 }
275 (Target::Notebook(library), "Close This Notebook") => {639 (Target::Notebook(library), action) => {
276 Some(Command::CloseNotebook(library))640 new(&action, String::new()).map(|structure| Command::Structure(library, structure))
277 }641 }
278 (Target::Notebook(library), text) => {642 (Target::Closed(location), Action::Delete) => {
279 Some(Command::Structure(library, new(text, String::new())))643 self.trail.recent.retain(|place| place.notebook != location);
644 self.save_settings();
645 None
646 }
647 (Target::Closed(location), _) => {
648 self.open_notebook(location, None);
649 None
650 }
651 (Target::Server(address), action) => {
652 if let Some(index) = self.servers.iter().position(|saved| *saved == address) {
653 self.saved_server((index, action == Action::Delete));
654 }
655 None
280 }656 }
281 };657 };
282 self.commands.extend(command);658 self.commands.extend(command);
283 }659 }
284660
661 /// What `action` on page `space` of the open section does.
662 fn page_command(&mut self, space: ExGuid, action: Action) -> Option<Command> {
663 match action {
664 Action::Rename => {
665 self.rename(crate::rename::Target::Page(space));
666 None
667 }
668 Action::Delete => Some(Command::DeletePages(vec![space])),
669 Action::MoveTo(path) => Some(Command::MovePage { space, path }),
670 Action::CopyLink => {
671 let copied = self
672 .page_link(space, None)
673 .and_then(|link| self.clipboard.set_text(link));
674 if let Err(error) = copied {
675 eprintln!("Copying the link failed: {error}");
676 }
677 None
678 }
679 Action::NewPage => Some(Command::NewPage { under: None }),
680 Action::NewSubpage => Some(Command::NewPage { under: Some(space) }),
681 Action::Theme => {
682 let session = self.session.as_ref()?;
683 let identity = session.section.page(space).ok()?.identity?;
684 let library = Arc::clone(&session.library);
685 let scope = notebook::sidecar::themes::Scope::page(identity);
686 self.show_themes(crate::themes::Scope::Page, library, scope);
687 None
688 }
689 Action::Versions(show) => Some(Command::History { page: space, show }),
690 Action::MakeSubpage | Action::PromoteSubpage => {
691 let session = self.session.as_ref()?;
692 let level = session
693 .pages
694 .iter()
695 .find(|(listed, ..)| *listed == space)?
696 .2;
697 let level = if action == Action::MakeSubpage {
698 level + 1
699 } else {
700 level - 1
701 };
702 Some(Command::Pages(vec![
703 PageEdit::set_level(space, level).ok()?,
704 ]))
705 }
706 _ => None,
707 }
708 }
709
285 /// The section tab, of those built as `row`, under the pointer while a page is dragged710 /// The section tab, of those built as `row`, under the pointer while a page is dragged
286 /// there, which a drop moves the page into.711 /// there, which a drop moves the page into.
287 pub(crate) fn page_drop(&self, row: Id) -> Option<usize> {712 pub(crate) fn page_drop(&self, row: Id) -> Option<usize> {
...@@ -472,11 +897,90 @@ impl State {...@@ -472,11 +897,90 @@ impl State {
472 }897 }
473}898}
474899
475/// The new section or group a menu's `text` asks for in `folder`.900/// The file of the section at catalog `path` on this computer; none on a server reached
476fn new(text: &str, folder: String) -> Structure {901/// without a mount.
477 if text == "New Section Group" {902fn section_file(library: &Library, path: &str) -> Option<std::path::PathBuf> {
478 Structure::NewGroup { folder }903 Some(library.folder()?.join(path))
479 } else {904}
480 Structure::NewSection { folder }905
906/// The new section or group `action` asks for in `folder`.
907fn new(action: &Action, folder: String) -> Option<Structure> {
908 match action {
909 Action::NewSection => Some(Structure::NewSection { folder }),
910 Action::NewGroup => Some(Structure::NewGroup { folder }),
911 _ => None,
912 }
913}
914
915/// `library`'s folders of sections, the notebook's first, then its section groups breadth
916/// first, but for the recycle bin; none for a section opened on its own.
917pub(crate) fn folders(library: &Library) -> Vec<&notebook::discover::Folder> {
918 let mut folders: Vec<_> = library.catalog().into_iter().collect();
919 let mut at = 0;
920 while let Some(&folder) = folders.get(at) {
921 folders.extend(
922 (folder.groups.iter()).filter(|group| !crate::library::recycle_bin(&group.path)),
923 );
924 at += 1;
925 }
926 folders
927}
928
929#[cfg(test)]
930mod tests {
931 use super::SECTION_COLORS;
932 use notebook::{discover::SectionState, session::Notebook};
933
934 /// Section Color and Notebook Properties' colour as the app stores them: each of OneNote's
935 /// colours and None on a section of its own, and Teal on the notebook.
936 /// `SNOWBOUND_SECTION_COLOR_EXPORT` names a new directory receiving the notebook for a
937 /// cold reopen in OneNote 2010 (`corpus/section-color`).
938 #[test]
939 fn sections_and_the_notebook_take_onenotes_colours() {
940 let temporary =
941 std::env::temp_dir().join(format!("snowbound-colors-{}", std::process::id()));
942 let _ = std::fs::remove_dir_all(&temporary);
943 let root = temporary.join("Colors");
944 let cache = temporary.join("cache");
945 std::fs::create_dir_all(&temporary).unwrap();
946 let page = || onestore::PageCreation::new(None, Some(""), "Author").unwrap();
947 let mut notebook = Notebook::create(&root, &cache, Notebook::NEW_COLOR, &page()).unwrap();
948 let named: Vec<(Option<u32>, &str)> = (SECTION_COLORS.iter())
949 .map(|&(color, name)| (Some(color), name))
950 .chain([(None, "None")])
951 .collect();
952 for (color, name) in &named {
953 let path = notebook.create_section("", name, &page()).unwrap();
954 notebook.set_section_color(&path, *color).unwrap();
955 }
956 notebook.set_color(0xcabc4d).unwrap();
957
958 let reopened = Notebook::open(&root, &cache).unwrap();
959 let catalog = reopened.catalog();
960 assert_eq!(catalog.toc.as_ref().unwrap().color, Some(0xcabc4d));
961 let colors: Vec<(String, Option<u32>)> = (catalog.sections.iter())
962 .map(|section| match &section.state {
963 SectionState::Readable { color, .. } => (section.path.clone(), *color),
964 _ => panic!("{}", section.path),
965 })
966 .collect();
967 // The section a new notebook starts with keeps the colour it was made in.
968 let expected: Vec<(String, Option<u32>)> = [(Some(0xe4a88a), "New Section 1")]
969 .iter()
970 .chain(&named)
971 .map(|(color, name)| (format!("{name}.one"), *color))
972 .collect();
973 assert_eq!(colors, expected);
974 if let Some(directory) = std::env::var_os("SNOWBOUND_SECTION_COLOR_EXPORT") {
975 let directory = std::path::Path::new(&directory);
976 std::fs::create_dir_all(directory).unwrap();
977 for entry in std::fs::read_dir(&root).unwrap() {
978 let entry = entry.unwrap();
979 if entry.file_type().unwrap().is_file() {
980 std::fs::copy(entry.path(), directory.join(entry.file_name())).unwrap();
981 }
982 }
983 }
984 std::fs::remove_dir_all(&temporary).unwrap();
481 }985 }
482}986}
crates/snowbound/src/navigation.rs+113-23
...@@ -1,33 +1,42 @@...@@ -1,33 +1,42 @@
1//! Back and Forward through the pages visited, across sections and notebooks, as OneNote1//! Back and Forward through the pages visited, across sections and notebooks, as OneNote
2//! 2010's Quick Access Toolbar offers them.2//! 2010's Quick Access Toolbar offers them, and the pages shown lately, which the palette
3//! lists first.
34
4use super::*;5use super::*;
5use std::time::Duration;6use canvas::search::Index;
7use std::{collections::HashSet, time::Duration};
6use winit::event::TouchPhase;8use winit::event::TouchPhase;
79
8/// A page shown: its notebook's location, its section's catalog path and the page.10/// A page shown: its notebook's location, its section's catalog path and the page.
9#[derive(Clone, Debug, PartialEq)]11#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
10pub struct Place {12pub struct Place {
11 pub notebook: String,13 pub notebook: String,
12 pub section: String,14 pub section: String,
13 pub page: ExGuid,15 pub page: ExGuid,
14}16}
1517
16/// Pages visited before and after the one shown.18/// Pages visited before and after the one shown, and those shown lately.
17#[derive(Default)]19#[derive(Default)]
18pub struct Trail {20pub struct Trail {
19 back: Vec<Place>,21 back: Vec<Place>,
20 here: Option<Place>,22 here: Option<Place>,
21 forward: Vec<Place>,23 forward: Vec<Place>,
24 /// Each page shown lately once, latest first, kept between launches.
25 pub recent: Vec<Place>,
22}26}
2327
24/// Places Back keeps, at most.28/// Places Back keeps, at most.
25const KEPT: usize = 100;29const KEPT: usize = 100;
30/// Pages `Trail::recent` keeps, at most.
31const RECENT: usize = 8;
2632
27impl Trail {33impl Trail {
28 /// Notes `place` shown. Arriving anywhere but where Back or Forward went drops the pages34 /// Notes `place` shown. Arriving anywhere but where Back or Forward went drops the pages
29 /// ahead, as a browser does.35 /// ahead, as a browser does.
30 pub fn visit(&mut self, place: Place) {36 pub fn visit(&mut self, place: Place) {
37 self.recent.retain(|recent| *recent != place);
38 self.recent.insert(0, place.clone());
39 self.recent.truncate(RECENT);
31 if self.here.as_ref() == Some(&place) {40 if self.here.as_ref() == Some(&place) {
32 return;41 return;
33 }42 }
...@@ -59,17 +68,58 @@ impl Trail {...@@ -59,17 +68,58 @@ impl Trail {
59 pub fn open(&self) -> [bool; 2] {68 pub fn open(&self) -> [bool; 2] {
60 [!self.back.is_empty(), !self.forward.is_empty()]69 [!self.back.is_empty(), !self.forward.is_empty()]
61 }70 }
71
72 /// Drops the recent pages whose section `listed` refuses, and those whose section `index`
73 /// has read without them. Returns whether any went.
74 pub fn prune(&mut self, index: &Index, listed: impl Fn(&Place) -> bool) -> bool {
75 let read: HashSet<&str> = (index.entries().iter())
76 .map(|entry| entry.section.as_str())
77 .collect();
78 let before = self.recent.len();
79 self.recent.retain(|place| {
80 let key = crate::library::key(&place.notebook, &place.section);
81 listed(place)
82 && (!read.contains(key.as_str())
83 || (index.entries().iter())
84 .any(|entry| entry.space == place.page && entry.section == key))
85 });
86 self.recent.len() != before
87 }
62}88}
6389
64impl State {90impl State {
65 /// Notes the page just shown for Back and Forward.91 /// Notes the page just shown for Back and Forward, and among the recent pages.
66 pub(crate) fn visited(&mut self) {92 pub(crate) fn visited(&mut self) {
67 if let Some(session) = &self.session {93 let Some(session) = &self.session else {
68 self.trail.visit(Place {94 return;
69 notebook: session.library.location.clone(),95 };
70 section: session.tabs[session.tab].path.clone(),96 let place = Place {
71 page: session.space,97 notebook: session.library.location.clone(),
72 });98 section: session.tabs[session.tab].path.clone(),
99 page: session.space,
100 };
101 let moved = self.trail.recent.first() != Some(&place);
102 self.trail.visit(place);
103 if moved {
104 self.save_settings();
105 }
106 }
107
108 /// Whether `library`'s section at `path` is the one open.
109 pub(crate) fn open(&self, library: &Library, path: &str) -> bool {
110 self.session.as_ref().is_some_and(|session| {
111 session.library.location == library.location && session.tabs[session.tab].path == path
112 })
113 }
114
115 /// Shows page `space` of `library`'s section at `path`, opening the section on it where
116 /// it is not the one open.
117 pub(crate) fn go(&mut self, library: Arc<Library>, path: String, space: ExGuid) {
118 if self.open(&library, &path) {
119 self.commands.push(Command::OpenPage(space));
120 } else {
121 self.last_pages.insert(library.key(&path), space);
122 self.commands.push(Command::OpenSection(library, path));
73 }123 }
74 }124 }
75125
...@@ -87,13 +137,6 @@ impl State {...@@ -87,13 +137,6 @@ impl State {
87 let Some(place) = self.trail.step(forward, exists) else {137 let Some(place) = self.trail.step(forward, exists) else {
88 return;138 return;
89 };139 };
90 if let Some(session) = &self.session
91 && session.library.location == place.notebook
92 && session.tabs[session.tab].path == place.section
93 {
94 self.commands.push(Command::OpenPage(place.page));
95 return;
96 }
97 let Some(library) = self140 let Some(library) = self
98 .notebooks141 .notebooks
99 .iter()142 .iter()
...@@ -101,11 +144,7 @@ impl State {...@@ -101,11 +144,7 @@ impl State {
101 else {144 else {
102 return;145 return;
103 };146 };
104 let library = Arc::clone(library);147 self.go(Arc::clone(library), place.section, place.page);
105 self.last_pages
106 .insert(library.key(&place.section), place.page);
107 self.commands
108 .push(Command::OpenSection(library, place.section));
109 }148 }
110}149}
111150
...@@ -228,6 +267,57 @@ mod tests {...@@ -228,6 +267,57 @@ mod tests {
228 assert_eq!(trail.open(), [true, false]);267 assert_eq!(trail.open(), [true, false]);
229 }268 }
230269
270 #[test]
271 fn recent_pages_are_kept_once_latest_first_and_bounded() {
272 let mut trail = Trail::default();
273 for page in [1, 2, 1] {
274 trail.visit(place("A.one", page));
275 }
276 assert_eq!(trail.recent, [place("A.one", 1), place("A.one", 2)]);
277 for page in 0..20 {
278 trail.visit(place("B.one", page));
279 }
280 assert_eq!(trail.recent.len(), RECENT);
281 assert_eq!(trail.recent[0], place("B.one", 19));
282 }
283
284 #[test]
285 fn recent_pages_go_with_their_section_or_once_the_index_lacks_them() {
286 let page = |title: &str| onestore::page::Page {
287 title: title.into(),
288 identity: None,
289 created: None,
290 margin_origin: [0.0; 2],
291 color: None,
292 rule_lines: None,
293 objects: Vec::new(),
294 definitions: Default::default(),
295 };
296 let mut index = Index::default();
297 let section = crate::library::key("/notebooks/Personal", "A.one");
298 let kept = place("A.one", 1);
299 index.set(canvas::search::Entry::new(
300 &section,
301 kept.page,
302 &page("Kept"),
303 0,
304 ));
305 let mut trail = Trail::default();
306 // Visited oldest first: the index has read A without page 2, not yet B, and C is gone.
307 for gone in [
308 place("C.one", 1),
309 place("B.one", 1),
310 place("A.one", 2),
311 kept.clone(),
312 ] {
313 trail.visit(gone);
314 }
315 let listed = |place: &Place| place.section != "C.one";
316 assert!(trail.prune(&index, listed));
317 assert_eq!(trail.recent, [kept, place("B.one", 1)]);
318 assert!(!trail.prune(&index, listed), "nothing more goes");
319 }
320
231 #[test]321 #[test]
232 fn back_keeps_its_last_hundred_places() {322 fn back_keeps_its_last_hundred_places() {
233 let mut trail = Trail::default();323 let mut trail = Trail::default();
crates/snowbound/src/options.rs+674-206
...@@ -1,14 +1,14 @@...@@ -1,14 +1,14 @@
1//! OneNote's Options dialog: pages of settings listed on the left as OneNote 2010 lists1//! OneNote's Options dialog as one scrolling list of sections (`resources/settings.md`):
2//! them, grouped under the same headings, each kept when OK is chosen.2//! indexed on the left, filtered by the search field above, each choice kept when OK is
3//! chosen.
34
4use crate::{State, platform, settings::ColorScheme, update};5use crate::{State, platform, settings::ColorScheme, update};
5use accesskit::Role;6use accesskit::Role;
6use ui::{Anchor, Axis, Flags, Id, Spec, Theme, Ui, children, fill, popup::Item, px};7use canvas::editor::DefaultFont;
8use ui::{Anchor, Axis, Extent, Flags, Id, Size, Spec, Theme, Ui, fill, popup::Item, px};
7use winit::keyboard::NamedKey;9use winit::keyboard::NamedKey;
810
9const WIDTH: f32 = 640.0;11const WIDTH: f32 = 680.0;
10/// The pages' area, the same for every page so the dialog keeps its size.
11const HEIGHT: f32 = 320.0;
12const NAV: f32 = 150.0;12const NAV: f32 = 150.0;
13/// The column the fields' labels share.13/// The column the fields' labels share.
14const LABEL: f32 = 104.0;14const LABEL: f32 = 104.0;
...@@ -17,38 +17,214 @@ const SCHEMES: [(ColorScheme, &str); 3] = [...@@ -17,38 +17,214 @@ const SCHEMES: [(ColorScheme, &str); 3] = [
17 (ColorScheme::Light, "Light"),17 (ColorScheme::Light, "Light"),
18 (ColorScheme::Dark, "Dark"),18 (ColorScheme::Dark, "Dark"),
19];19];
20/// The sizes Default font offers, OneNote 2010's list in points.
21const SIZES: [f32; 19] = [
22 8.0, 9.0, 9.5, 10.0, 10.5, 11.0, 11.5, 12.0, 14.0, 16.0, 18.0, 20.0, 22.0, 24.0, 26.0, 28.0,
23 36.0, 48.0, 72.0,
24];
25
26/// An entry of the index and its part of the list.
27struct Section {
28 name: &'static str,
29 groups: &'static [Group],
30}
31
32/// Rows under a banded heading, as OneNote groups an Options page; an empty heading has no
33/// band.
34struct Group {
35 heading: &'static str,
36 rows: &'static [Row],
37}
2038
21#[derive(Clone, Copy, PartialEq)]39struct Row {
22enum Page {40 label: &'static str,
23 General,41 /// Other words search finds the row by.
24 Display,42 keywords: &'static str,
25 SaveBackup,43 control: Control,
26 Advanced,
27}44}
2845
29impl Page {46enum Control {
30 const ALL: [(Page, &str); 4] = [47 /// A check box before the label, bound to a choice.
31 (Page::General, "General"),48 Check(fn(&mut Options) -> &mut bool),
32 (Page::Display, "Display"),49 /// The label in the labels' column, then what the function builds.
33 (Page::SaveBackup, "Save & Backup"),50 Field(fn(&mut State, &mut Options)),
34 (Page::Advanced, "Advanced"),51 /// What `build` makes across the row, unlabelled, from the search's words; `finds` tells
35 ];52 /// whether they find anything in it. Words found in the row's own texts give it none.
53 Block {
54 build: fn(&mut State, &mut Options, &[String]),
55 finds: fn(&[String]) -> bool,
56 },
36}57}
3758
38/// The page shown and what the fields hold until OK keeps them.59/// Every section, in the list's order.
60const SECTIONS: &[Section] = &[
61 Section {
62 name: "General",
63 groups: &[
64 Group {
65 heading: "User Interface Options",
66 rows: &[
67 Row {
68 label: "Appearance:",
69 keywords: "color colour scheme dark light mode system theme",
70 control: Control::Field(appearance),
71 },
72 Row {
73 label: "Pages match UI theme",
74 keywords: "dark white paper background",
75 control: Control::Check(|options| &mut options.pages_match),
76 },
77 ],
78 },
79 Group {
80 heading: "Personalize",
81 rows: &[Row {
82 label: "User name:",
83 keywords: "author name",
84 control: Control::Field(user_name_field),
85 }],
86 },
87 ],
88 },
89 Section {
90 name: "Editing",
91 groups: &[
92 Group {
93 heading: "Default font",
94 rows: &[
95 Row {
96 label: "Font:",
97 keywords: "typeface text new",
98 control: Control::Field(font_face),
99 },
100 Row {
101 label: "Size:",
102 keywords: "font points text new",
103 control: Control::Field(font_size),
104 },
105 Row {
106 label: "Font color:",
107 keywords: "colour text new automatic",
108 control: Control::Field(font_color),
109 },
110 ],
111 },
112 Group {
113 heading: "Proofing",
114 rows: &[Row {
115 label: "Hide spelling errors",
116 keywords: "spell check misspelled words",
117 control: Control::Check(|options| &mut options.hide_spelling),
118 }],
119 },
120 Group {
121 heading: "Pen",
122 rows: &[Row {
123 label: "Use pen pressure sensitivity",
124 keywords: "tablet stylus ink stroke width drawing",
125 control: Control::Check(|options| &mut options.pen_pressure),
126 }],
127 },
128 ],
129 },
130 Section {
131 name: "Display",
132 groups: &[Group {
133 heading: "",
134 rows: &[
135 Row {
136 label: "Page tabs appear on the left",
137 keywords: "pages list side layout",
138 control: Control::Check(|options| &mut options.page_tabs_left),
139 },
140 Row {
141 label: "Navigation bar appears on the left",
142 keywords: "notebooks sidebar side right layout",
143 control: Control::Check(|options| &mut options.navigation_bar_left),
144 },
145 ],
146 }],
147 },
148 Section {
149 name: "Sync & Storage",
150 groups: &[Group {
151 heading: "Cache file location",
152 rows: &[Row {
153 label: "Path:",
154 keywords: "folder replica data disk",
155 control: Control::Field(cache),
156 }],
157 }],
158 },
159 Section {
160 name: "Updates",
161 groups: &[Group {
162 heading: "",
163 rows: &[
164 Row {
165 label: "Version:",
166 keywords: "about build release",
167 control: Control::Field(version),
168 },
169 Row {
170 label: "Check for updates automatically",
171 keywords: "update download",
172 control: Control::Check(|options| &mut options.automatic_updates),
173 },
174 #[cfg(target_os = "linux")]
175 Row {
176 label: "Installed:",
177 keywords: "install uninstall app menu remove",
178 control: Control::Field(installed),
179 },
180 ],
181 }],
182 },
183 Section {
184 name: "Keyboard",
185 groups: &[Group {
186 heading: "",
187 rows: &[Row {
188 label: "Shortcuts",
189 keywords: "keys chords hotkeys bindings commands menus",
190 control: Control::Block {
191 build: |state, options, query| options.keyboard.build(&mut state.ui, query),
192 finds: crate::keys::searched,
193 },
194 }],
195 }],
196 },
197];
198
199/// What the fields hold until OK keeps them, and where the list is.
39pub struct Options {200pub struct Options {
40 page: Page,201 query: String,
202 /// The section last picked in the index, marked until the list is scrolled.
203 picked: Option<&'static str>,
41 user_name: String,204 user_name: String,
42 color_scheme: ColorScheme,205 color_scheme: ColorScheme,
43 light_pages: bool,206 pages_match: bool,
207 hide_spelling: bool,
44 automatic_updates: bool,208 automatic_updates: bool,
45 pen_pressure: bool,209 pen_pressure: bool,
210 default_font: DefaultFont,
211 page_tabs_left: bool,
212 navigation_bar_left: bool,
213 pub(crate) keyboard: crate::keys::Keyboard,
46}214}
47215
48fn id() -> Id {216fn id() -> Id {
49 Id::ROOT.child("options")217 Id::ROOT.child("options")
50}218}
51219
220fn search() -> Id {
221 id().child("search")
222}
223
224fn list() -> Id {
225 id().child("list")
226}
227
52fn user_name() -> Id {228fn user_name() -> Id {
53 id().child("user-name")229 id().child("user-name")
54}230}
...@@ -57,45 +233,107 @@ fn schemes() -> Id {...@@ -57,45 +233,107 @@ fn schemes() -> Id {
57 id().child("color-schemes")233 id().child("color-schemes")
58}234}
59235
236fn fonts() -> Id {
237 id().child("fonts")
238}
239
240fn sizes() -> Id {
241 id().child("sizes")
242}
243
244fn font_colors() -> Id {
245 id().child("font-colors")
246}
247
248/// Whether `word`, lowercase, starts a word of `text`: "pen" finds "Pen" and "pen-like",
249/// not "Open".
250fn starts_word(text: &str, word: &str) -> bool {
251 let text = text.to_lowercase();
252 text.match_indices(word).any(|(at, _)| {
253 text[..at]
254 .chars()
255 .next_back()
256 .is_none_or(|before| !before.is_alphanumeric())
257 })
258}
259
260/// Whether every word of `query`, lowercase, starts a word in one of `texts`.
261pub(crate) fn found(query: &[String], texts: &[&str]) -> bool {
262 query
263 .iter()
264 .all(|word| texts.iter().any(|text| starts_word(text, word)))
265}
266
267/// Whether any word of `query` starts a word of `text`, which search then highlights.
268fn lit(query: &[String], text: &str) -> bool {
269 query.iter().any(|word| starts_word(text, word))
270}
271
272/// Whether every word of the query is in `row`'s section name, group heading, label or
273/// keywords.
274fn titled(query: &[String], section: &Section, group: &Group, row: &Row) -> bool {
275 found(
276 query,
277 &[section.name, group.heading, row.label, row.keywords],
278 )
279}
280
281/// Whether search shows `row`: its own texts, or a block's contents, hold the query.
282fn shown(query: &[String], section: &Section, group: &Group, row: &Row) -> bool {
283 titled(query, section, group, row)
284 || matches!(row.control, Control::Block { finds, .. } if finds(query))
285}
286
60impl State {287impl State {
61 pub(crate) fn open_options(&mut self) {288 pub(crate) fn open_options(&mut self) {
62 self.options = Some(Options {289 self.options = Some(Options {
63 page: Page::General,290 query: String::new(),
291 picked: None,
64 user_name: self.author.clone(),292 user_name: self.author.clone(),
65 color_scheme: self.color_scheme,293 color_scheme: self.color_scheme,
66 light_pages: self.light_pages,294 pages_match: !self.light_pages,
295 hide_spelling: self.hide_spelling,
67 automatic_updates: self.updates.automatic(),296 automatic_updates: self.updates.automatic(),
68 pen_pressure: self.pen_pressure,297 pen_pressure: self.pen_pressure,
298 default_font: self.view.editor.default_font.clone(),
299 page_tabs_left: self.page_tabs_left,
300 navigation_bar_left: !self.navigation_bar_right,
301 keyboard: crate::keys::Keyboard::new(),
69 });302 });
70 self.ui.open_popup(id());303 self.ui.open_popup(id());
71 self.ui.set_focus(Some(user_name()));304 self.ui.set_focus(Some(search()));
72 }305 }
73306
74 /// Builds the Options dialog while it is open. OK, or Enter in a field, keeps its choices307 /// Builds the Options dialog while it is open. OK, or Enter in the user name, keeps its
75 /// with a non-empty user name; Cancel, Escape or a press outside leave them.308 /// choices with a non-empty user name; Cancel, Escape or a press outside leave them.
76 pub(crate) fn options_dialog(&mut self) {309 pub(crate) fn options_dialog(&mut self) {
77 let Some(options) = &mut self.options else {310 let Some(mut options) = self.options.take() else {
78 return;311 return;
79 };312 };
80 let ui = &mut self.ui;313 if !self.ui.popup_open(id()) {
81 if !ui.popup_open(id()) {
82 self.options = None;
83 return;314 return;
84 }315 }
85 let theme = ui.theme.clone();316 let theme = self.ui.theme.clone();
86 let row = theme.font_size * 2.0;317 let row = row_height(&theme);
318 let ui = &mut self.ui;
87 let entered = ui::popup::navigation(ui, &[user_name()], &[NamedKey::Enter])319 let entered = ui::popup::navigation(ui, &[user_name()], &[NamedKey::Enter])
88 .contains(&NamedKey::Enter);320 .contains(&NamedKey::Enter);
321 // As tall as its contents up to the window, which the list then scrolls within.
322 let loose = Extent {
323 size: Size::Children,
324 strictness: 0.0,
325 };
89 ui.open_as(326 ui.open_as(
90 id(),327 id(),
91 Spec {328 Spec {
92 axis: Axis::Y,329 axis: Axis::Y,
93 size: [px(WIDTH), children()],330 size: [px(WIDTH), loose],
94 fill: Some(theme.popup),331 fill: Some(theme.popup),
95 border: Some(theme.chip),332 border: Some(theme.chip),
96 shadow: Some(theme.shadow),333 shadow: Some(theme.shadow),
97 radius: 8.0,334 radius: 8.0,
98 pad: [6.0, 6.0],335 pad: [6.0, 6.0],
336 gap: 6.0,
99 anchor: Some(Anchor::Dialog),337 anchor: Some(Anchor::Dialog),
100 role: Some(Role::Dialog),338 role: Some(Role::Dialog),
101 ..Spec::default()339 ..Spec::default()
...@@ -104,15 +342,59 @@ impl State {...@@ -104,15 +342,59 @@ impl State {
104 if let Some(node) = ui.access(id()) {342 if let Some(node) = ui.access(id()) {
105 node.set_label("Options");343 node.set_label("Options");
106 }344 }
345 let before = options.query.clone();
346 ui::text_field(
347 ui,
348 search(),
349 &mut options.query,
350 "Search options",
351 Spec {
352 size: [fill(), px(row)],
353 fill: Some(theme.base),
354 border: Some(if ui.focused() == Some(search()) {
355 theme.accent
356 } else {
357 theme.chip
358 }),
359 radius: 4.0,
360 pad: [8.0, 0.0],
361 role: Some(Role::SearchInput),
362 ..Spec::default()
363 },
364 );
365 if let Some(node) = ui.access(search()) {
366 node.set_label("Search options");
367 }
368 let query: Vec<String> = options
369 .query
370 .split_whitespace()
371 .map(str::to_lowercase)
372 .collect();
373 if options.query != before {
374 options.picked = None;
375 ui.scroll_to(list(), list().child("top"));
376 }
377 let sections: Vec<&Section> = SECTIONS
378 .iter()
379 .filter(|section| {
380 section.groups.iter().any(|group| {
381 group
382 .rows
383 .iter()
384 .any(|row| shown(&query, section, group, row))
385 })
386 })
387 .collect();
107 ui.open(388 ui.open(
108 "body",389 "body",
109 Spec {390 Spec {
110 size: [fill(), px(HEIGHT)],391 size: [fill(), loose],
392 gap: 6.0,
111 ..Spec::default()393 ..Spec::default()
112 },394 },
113 );395 );
114 let pages = ui.open(396 let index = ui.open(
115 "pages",397 "index",
116 Spec {398 Spec {
117 axis: Axis::Y,399 axis: Axis::Y,
118 size: [px(NAV), fill()],400 size: [px(NAV), fill()],
...@@ -124,177 +406,175 @@ impl State {...@@ -124,177 +406,175 @@ impl State {
124 ..Spec::default()406 ..Spec::default()
125 },407 },
126 );408 );
127 if let Some(node) = ui.access(pages) {409 if let Some(node) = ui.access(index) {
128 node.set_orientation(accesskit::Orientation::Vertical);410 node.set_orientation(accesskit::Orientation::Vertical);
129 }411 }
130 for (page, name) in Page::ALL {412 // The section in the top quarter of the list, or the last once the list is scrolled to
131 let shown = page == options.page;413 // its end.
132 let spec = Spec {414 let current = options.picked.or_else(|| {
133 flags: Flags::CLICKABLE,415 let [_, top, _, bottom] = ui.rect(list())?;
134 size: [fill(), px(row)],416 let starts = |id| ui.rect(id).map(|rect: [f32; 4]| rect[1]);
135 text: Some(name),417 let end = starts(list().child("top")).is_some_and(|start| start < top - 1.0)
136 bold: shown,418 && starts(list().child("end")).is_some_and(|end| end <= bottom + 1.0);
137 fill: shown.then(|| theme.hover()),419 sections
138 hover_fill: Some(theme.hover()),420 .iter()
139 radius: 4.0,421 .rfind(|section| {
140 pad: [8.0, 0.0],422 end || starts(section_id(section))
141 role: Some(Role::Tab),423 .is_some_and(|start| start <= top + (bottom - top) / 4.0)
142 ..Spec::default()424 })
143 };425 .or(sections.first())
144 let tab = ui.open(name, spec);426 .map(|section| section.name)
427 });
428 for section in &sections {
429 let marked = current == Some(section.name);
430 let tab = ui.open(
431 section.name,
432 Spec {
433 flags: Flags::CLICKABLE,
434 size: [fill(), px(row)],
435 text: Some(section.name),
436 bold: marked,
437 fill: marked.then(|| theme.hover()),
438 hover_fill: Some(theme.hover()),
439 radius: 4.0,
440 pad: [8.0, 0.0],
441 role: Some(Role::Tab),
442 ..Spec::default()
443 },
444 );
145 if let Some(node) = ui.access(tab) {445 if let Some(node) = ui.access(tab) {
146 node.set_selected(shown);446 node.set_selected(marked);
147 }447 }
148 ui.close();448 ui.close();
149 if ui.signal(tab).clicked {449 if ui.signal(tab).clicked {
150 options.page = page;450 options.picked = Some(section.name);
451 ui.scroll_to(list(), section_id(section));
151 }452 }
152 }453 }
153 ui.close();454 ui.close();
154 ui.open(455 ui.open_as(
155 "page",456 list(),
156 Spec {457 Spec {
458 flags: Flags::SCROLL | Flags::CLIP,
157 axis: Axis::Y,459 axis: Axis::Y,
158 size: [fill(), fill()],460 size: [fill(), loose],
159 pad: [16.0, 10.0],461 pad: [10.0, 0.0],
160 gap: 6.0,
161 ..Spec::default()462 ..Spec::default()
162 },463 },
163 );464 );
164 match options.page {465 if !ui.signal(list()).events.is_empty() {
165 Page::General => {466 options.picked = None;
166 heading(ui, &theme, "User Interface Options");467 }
167 field(ui, "Appearance:", |ui| {468 ui.leaf(
168 let combo = ui.id("combo");469 "top",
169 let current = SCHEMES470 Spec {
170 .iter()471 size: [fill(), px(0.0)],
171 .find(|(scheme, _)| *scheme == options.color_scheme)472 ..Spec::default()
172 .map_or("", |(_, name)| name);473 },
173 ui::shell::combo(ui, "combo", "Appearance", current, 140.0, schemes(), true);474 );
174 let items = SCHEMES.map(|(scheme, name)| Item {475 if sections.is_empty() {
175 text: name,476 ui.leaf(
176 checked: Some(scheme == options.color_scheme),477 "none",
177 current: scheme == options.color_scheme,478 Spec {
178 ..Item::default()479 size: [fill(), px(row * 2.0)],
179 });480 text: Some("No options match"),
180 let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default());481 color: Some(theme.text_dim),
181 if let Some(index) = ui::popup::menu(ui, schemes(), anchor, &items, None) {482 center: true,
182 options.color_scheme = SCHEMES[index].0;483 ..Spec::default()
183 }484 },
184 });485 );
185 heading(ui, &theme, "Personalize");486 }
186 field(ui, "User name:", |ui| {487 for section in &sections {
187 ui::text_field(488 self.ui.open_as(
188 ui,489 section_id(section),
189 user_name(),490 Spec {
190 &mut options.user_name,491 axis: Axis::Y,
191 "",492 size: [fill(), ui::children()],
192 Spec {493 pad: [0.0, 4.0],
193 size: [px(260.0), px(row)],494 gap: 4.0,
194 fill: Some(theme.base),495 ..Spec::default()
195 border: Some(theme.accent),496 },
196 radius: 4.0,497 );
197 pad: [6.0, 0.0],498 title(
198 ..Spec::default()499 &mut self.ui,
199 },500 &theme,
200 );501 section.name,
201 if let Some(node) = ui.access(user_name()) {502 lit(&query, section.name),
202 node.set_label("User name");503 );
203 }504 for group in section.groups {
204 });505 let rows: Vec<&Row> = group
205 heading(ui, &theme, "Updates");506 .rows
206 ui.leaf(507 .iter()
207 "version",508 .filter(|row| shown(&query, section, group, row))
208 Spec {509 .collect();
209 size: [fill(), px(row)],510 if rows.is_empty() {
210 text: Some(&update::describe_running()),511 continue;
211 pad: [8.0, 0.0],
212 ..Spec::default()
213 },
214 );
215 if ui::check_box(
216 ui,
217 "automatic-updates",
218 "Check for updates automatically",
219 options.automatic_updates,
220 )
221 .clicked
222 {
223 options.automatic_updates = !options.automatic_updates;
224 }
225 #[cfg(target_os = "linux")]
226 if crate::desktop::uninstallable() {
227 field(ui, "Installed:", |ui| {
228 let binary = crate::desktop::binary().unwrap_or_default();
229 ui.leaf(
230 "path",
231 Spec {
232 flags: Flags::CLIP,
233 size: [fill(), px(row)],
234 text: Some(&binary.to_string_lossy()),
235 color: Some(theme.text_dim),
236 ..Spec::default()
237 },
238 );
239 if ui::button(ui, "uninstall", "Uninstall…").clicked {
240 crate::desktop::uninstall();
241 }
242 });
243 }
244 }
245 Page::Display => {
246 heading(ui, &theme, "Display");
247 let matching = !options.light_pages;
248 if ui::check_box(ui, "pages-match-theme", "Pages match UI theme", matching).clicked
249 {
250 options.light_pages = matching;
251 }512 }
252 }513 if !group.heading.is_empty() {
253 Page::Advanced => {514 heading(
254 heading(ui, &theme, "Pen");515 &mut self.ui,
255 if ui::check_box(516 &theme,
256 ui,517 group.heading,
257 "pen-pressure",518 lit(&query, group.heading),
258 "Use pen pressure sensitivity",519 );
259 options.pen_pressure,
260 )
261 .clicked
262 {
263 options.pen_pressure = !options.pen_pressure;
264 }520 }
265 }521 for row in rows {
266 Page::SaveBackup => {522 let highlight = !matches!(row.control, Control::Block { .. })
267 heading(ui, &theme, "Cache file location");523 && (lit(&query, row.label) || lit(&query, row.keywords));
268 let cache = &self.cache;524 self.ui.open(
269 field(ui, "Path:", |ui| {525 row.label,
270 ui.leaf(
271 "path",
272 Spec {526 Spec {
273 flags: Flags::CLIP,527 size: [fill(), ui::children()],
274 size: [fill(), px(row)],528 fill: highlight.then(|| ui::mix(theme.popup, theme.accent, 0.18)),
275 text: Some(&cache.to_string_lossy()),529 radius: 4.0,
276 color: Some(theme.text_dim),530 pad: [8.0, 0.0],
531 gap: 8.0,
277 ..Spec::default()532 ..Spec::default()
278 },533 },
279 );534 );
280 let label = if cfg!(target_os = "macos") {535 match row.control {
281 "Show in Finder"536 Control::Check(choice) => {
282 } else {537 let checked = choice(&mut options);
283 "Open Folder"538 if ui::check_box(&mut self.ui, "check", row.label, *checked).clicked {
284 };539 *checked = !*checked;
285 if ui::button(ui, "reveal", label).clicked {540 }
286 platform::reveal(cache);541 }
542 Control::Field(build) => {
543 self.ui.leaf(
544 "label",
545 Spec {
546 size: [px(LABEL), px(row_height(&theme))],
547 text: Some(row.label),
548 ..Spec::default()
549 },
550 );
551 build(self, &mut options);
552 }
553 Control::Block { build, .. } => {
554 let titled = titled(&query, section, group, row);
555 build(self, &mut options, if titled { &[] } else { &query });
556 }
287 }557 }
288 });558 self.ui.close();
559 }
289 }560 }
561 self.ui.close();
290 }562 }
563 let ui = &mut self.ui;
564 ui.leaf(
565 "end",
566 Spec {
567 size: [fill(), px(6.0)],
568 ..Spec::default()
569 },
570 );
291 ui.close();571 ui.close();
292 ui.close();572 ui.close();
293 ui.open(573 ui.open(
294 "buttons",574 "buttons",
295 Spec {575 Spec {
296 size: [fill(), children()],576 size: [fill(), ui::children()],
297 pad: [10.0, 8.0],577 pad: [10.0, 4.0],
298 gap: 8.0,578 gap: 8.0,
299 ..Spec::default()579 ..Spec::default()
300 },580 },
...@@ -314,56 +594,244 @@ impl State {...@@ -314,56 +594,244 @@ impl State {
314 if ok && !name.is_empty() {594 if ok && !name.is_empty() {
315 self.author = name.to_owned();595 self.author = name.to_owned();
316 self.color_scheme = options.color_scheme;596 self.color_scheme = options.color_scheme;
317 self.light_pages = options.light_pages;597 self.light_pages = !options.pages_match;
598 self.hide_spelling = options.hide_spelling;
318 self.pen_pressure = options.pen_pressure;599 self.pen_pressure = options.pen_pressure;
600 self.view.editor.default_font = options.default_font;
601 self.page_tabs_left = options.page_tabs_left;
602 self.navigation_bar_right = !options.navigation_bar_left;
319 self.updates.set_automatic(options.automatic_updates);603 self.updates.set_automatic(options.automatic_updates);
604 self.show_spelling();
320 self.follow_color_scheme();605 self.follow_color_scheme();
606 self.install_keymap(options.keyboard.keymap);
321 self.save_settings();607 self.save_settings();
322 } else if !cancel {608 } else if !cancel {
609 self.options = Some(options);
323 return;610 return;
324 }611 }
325 self.ui.close_popup(id());612 self.ui.close_popup(id());
326 self.options = None;
327 }613 }
328}614}
329615
330/// A group's heading on a band, as OneNote heads the groups of its Options pages.616fn section_id(section: &Section) -> Id {
331fn heading(ui: &mut Ui, theme: &Theme, text: &str) {617 list().child(section.name)
618}
619
620fn row_height(theme: &Theme) -> f32 {
621 theme.font_size * 2.0
622}
623
624/// A section's name over its groups, tinted where search found it.
625fn title(ui: &mut Ui, theme: &Theme, text: &str, lit: bool) {
332 ui.leaf(626 ui.leaf(
333 text,627 "title",
334 Spec {628 Spec {
335 size: [fill(), px(theme.font_size * 1.8)],629 size: [fill(), px(theme.font_size * 2.2)],
336 text: Some(text),630 text: Some(text),
631 font_size: Some(theme.font_size * 1.25),
337 bold: true,632 bold: true,
338 fill: Some(ui::mix(theme.panel, theme.chip, 0.4)),633 color: lit.then_some(theme.accent),
339 radius: 4.0,
340 pad: [8.0, 0.0],
341 role: Some(Role::Heading),634 role: Some(Role::Heading),
342 ..Spec::default()635 ..Spec::default()
343 },636 },
344 );637 );
345}638}
346639
347/// A row of `label` in the labels' column and the controls `build` adds after it.640/// A group's heading on a band, as OneNote heads the groups of its Options pages.
348fn field(ui: &mut Ui, label: &str, build: impl FnOnce(&mut Ui)) {641fn heading(ui: &mut Ui, theme: &Theme, text: &str, lit: bool) {
349 let row = ui.theme.font_size * 2.0;642 let band = ui::mix(theme.panel, theme.chip, 0.4);
350 ui.open(
351 label,
352 Spec {
353 size: [fill(), px(row)],
354 pad: [8.0, 0.0],
355 gap: 8.0,
356 ..Spec::default()
357 },
358 );
359 ui.leaf(643 ui.leaf(
360 "label",644 text,
361 Spec {645 Spec {
362 size: [px(LABEL), px(row)],646 size: [fill(), px(theme.font_size * 1.8)],
363 text: Some(label),647 text: Some(text),
648 bold: true,
649 fill: Some(if lit {
650 ui::mix(band, theme.accent, 0.3)
651 } else {
652 band
653 }),
654 radius: 4.0,
655 pad: [8.0, 0.0],
656 role: Some(Role::Heading),
364 ..Spec::default()657 ..Spec::default()
365 },658 },
366 );659 );
367 build(ui);660}
368 ui.close();661
662fn appearance(state: &mut State, options: &mut Options) {
663 let ui = &mut state.ui;
664 let combo = ui.id("combo");
665 let current = SCHEMES
666 .iter()
667 .find(|(scheme, _)| *scheme == options.color_scheme)
668 .map_or("", |(_, name)| name);
669 ui::shell::combo(ui, "combo", "Appearance", current, 140.0, schemes(), true);
670 let items = SCHEMES.map(|(scheme, name)| Item {
671 text: name,
672 checked: Some(scheme == options.color_scheme),
673 current: scheme == options.color_scheme,
674 ..Item::default()
675 });
676 let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default());
677 if let Some(index) = ui::popup::menu(ui, schemes(), anchor, &items, None) {
678 options.color_scheme = SCHEMES[index].0;
679 }
680}
681
682fn user_name_field(state: &mut State, options: &mut Options) {
683 let ui = &mut state.ui;
684 let theme = &ui.theme;
685 let width = Extent {
686 size: Size::Pixels(260.0),
687 strictness: 0.0,
688 };
689 let spec = Spec {
690 size: [width, px(row_height(theme))],
691 fill: Some(theme.base),
692 border: Some(theme.accent),
693 radius: 4.0,
694 pad: [6.0, 0.0],
695 ..Spec::default()
696 };
697 ui::text_field(ui, user_name(), &mut options.user_name, "", spec);
698 if let Some(node) = ui.access(user_name()) {
699 node.set_label("User name");
700 }
701}
702
703fn font_face(state: &mut State, options: &mut Options) {
704 let State { ui, fonts, .. } = state;
705 let font = &mut options.default_font;
706 let combo = ui.id("combo");
707 ui::shell::combo(ui, "combo", "Font", &font.face, 200.0, self::fonts(), true);
708 let items: Vec<_> = fonts
709 .iter()
710 .map(|name| Item {
711 text: name,
712 font: Some(name),
713 current: *name == font.face,
714 ..Item::default()
715 })
716 .collect();
717 let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default());
718 if let Some(index) = ui::popup::menu(ui, self::fonts(), anchor, &items, Some("Font")) {
719 font.face = fonts[index].clone();
720 }
721}
722
723fn font_size(state: &mut State, options: &mut Options) {
724 let ui = &mut state.ui;
725 let font = &mut options.default_font;
726 let combo = ui.id("combo");
727 let size = format!("{}", font.size);
728 ui::shell::combo(ui, "combo", "Size", &size, 60.0, sizes(), true);
729 let labels = SIZES.map(|size| format!("{size}"));
730 let items: Vec<_> = labels
731 .iter()
732 .map(|label| Item {
733 text: label,
734 checked: Some(*label == size),
735 current: *label == size,
736 ..Item::default()
737 })
738 .collect();
739 let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default());
740 if let Some(index) = ui::popup::menu(ui, sizes(), anchor, &items, None) {
741 font.size = SIZES[index];
742 }
743}
744
745fn font_color(state: &mut State, options: &mut Options) {
746 let ui = &mut state.ui;
747 let font = &mut options.default_font;
748 let combo = ui.id("combo");
749 let shown = crate::FONT_COLORS
750 .iter()
751 .find(|(color, _)| Some(*color) == font.color)
752 .map_or("Automatic", |(_, name)| name);
753 ui::shell::combo(ui, "combo", "Font color", shown, 200.0, font_colors(), true);
754 let swatches: Vec<_> = crate::FONT_COLORS
755 .iter()
756 .map(|&(color, name)| (canvas::gpu::colorref(color), name))
757 .collect();
758 let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default());
759 if let Some(chosen) = ui::popup::colors(ui, font_colors(), anchor, "Automatic", &swatches, 10) {
760 font.color = chosen.and_then(|chosen| {
761 let at = swatches.iter().position(|(swatch, _)| *swatch == chosen)?;
762 Some(crate::FONT_COLORS[at].0)
763 });
764 }
765}
766
767/// A path shown dimmed and cut to the row.
768fn path(ui: &mut Ui, path: &std::path::Path) {
769 let spec = Spec {
770 flags: Flags::CLIP,
771 size: [fill(), px(row_height(&ui.theme))],
772 text: Some(&path.to_string_lossy()),
773 color: Some(ui.theme.text_dim),
774 ..Spec::default()
775 };
776 ui.leaf("path", spec);
777}
778
779fn cache(state: &mut State, _: &mut Options) {
780 path(&mut state.ui, &state.cache);
781 let label = if cfg!(target_os = "macos") {
782 "Show in Finder"
783 } else {
784 "Open Folder"
785 };
786 if ui::button(&mut state.ui, "reveal", label).clicked {
787 platform::reveal(&state.cache);
788 }
789}
790
791fn version(state: &mut State, _: &mut Options) {
792 let ui = &mut state.ui;
793 let spec = Spec {
794 size: [fill(), px(row_height(&ui.theme))],
795 text: Some(&update::describe_running()),
796 ..Spec::default()
797 };
798 ui.leaf("version", spec);
799}
800
801/// Where Install put Snowbound and Uninstall, or Install where it isn't in the app menu.
802#[cfg(target_os = "linux")]
803fn installed(state: &mut State, _: &mut Options) {
804 let ui = &mut state.ui;
805 if crate::desktop::uninstallable() {
806 path(ui, &crate::desktop::binary().unwrap_or_default());
807 if ui::button(ui, "uninstall", "Uninstall…").clicked {
808 crate::desktop::uninstall();
809 }
810 } else if crate::desktop::installable() {
811 path(ui, std::path::Path::new("Not in the app menu"));
812 if ui::button(ui, "install", "Install").clicked {
813 crate::desktop::install();
814 }
815 } else {
816 path(ui, std::path::Path::new("By your system's package manager"));
817 }
818}
819
820#[cfg(test)]
821mod tests {
822 use super::found;
823
824 #[test]
825 fn search_words_match_the_starts_of_words() {
826 let query = |text: &str| vec![text.to_owned()];
827 assert!(found(&query("pen"), &["Use pen pressure sensitivity"]));
828 assert!(found(&query("pen"), &["Pen"]));
829 assert!(!found(&query("pen"), &["Open Notebook"]));
830 assert!(found(&query("⌘b"), &["⌘B"]));
831 assert!(found(&query("storage"), &["Sync & Storage"]));
832 assert!(
833 !found(&["pen".into(), "zzz".into()], &["Pen"]),
834 "every word"
835 );
836 }
369}837}
crates/snowbound/src/palette.rs+249-73
...@@ -1,14 +1,18 @@...@@ -1,14 +1,18 @@
1//! The palette: the open notebooks, their sections and every page the search index holds to1//! The palette: the pages and sections shown lately, then the open notebooks, their sections
2//! go to, or after a leading `>` the command table's commands, narrowed as typed.2//! and every page the search index holds to go to, or after a leading `>` the command
3//! table's commands, narrowed as typed. ⌘K or a right-click lists what can be done to a row.
34
4use crate::{5use crate::{
5 Command, Library, State, art,6 State, art,
6 commands::{self, Choice},7 commands::{self, Choice},
7 library,8 library,
9 menus::{self, Action, Target},
8};10};
9use onestore::ExGuid;
10use std::sync::Arc;11use std::sync::Arc;
11use ui::{Id, popup::Item};12use ui::{
13 Anchor, Id,
14 popup::{Item, Pick},
15};
1216
13/// What the palette's query starts with to list commands.17/// What the palette's query starts with to list commands.
14pub(crate) const COMMANDS: &str = ">";18pub(crate) const COMMANDS: &str = ">";
...@@ -17,20 +21,16 @@ pub(crate) fn id() -> Id {...@@ -17,20 +21,16 @@ pub(crate) fn id() -> Id {
17 Id::ROOT.child("palette")21 Id::ROOT.child("palette")
18}22}
1923
20enum Target {
21 Command(commands::Id),
22 Notebook(String),
23 Section(Arc<Library>, String),
24 Page(String, ExGuid),
25}
26
27/// A row: its text, the dim text after it, its icon and what it runs; a heading runs nothing.24/// A row: its text, the dim text after it, its icon and what it runs; a heading runs nothing.
25#[derive(Default)]
28struct Row {26struct Row {
29 text: String,27 text: String,
30 after: String,28 after: String,
31 icon: Option<&'static [&'static str]>,29 icon: Option<&'static [&'static str]>,
32 tint: Option<[f32; 4]>,30 tint: Option<[f32; 4]>,
33 disabled: bool,31 disabled: bool,
32 /// Repeats a row listed further on, as the recent ones do.
33 repeated: bool,
34 target: Option<Target>,34 target: Option<Target>,
35}35}
3636
...@@ -38,11 +38,7 @@ impl Row {...@@ -38,11 +38,7 @@ impl Row {
38 fn heading(text: &str) -> Self {38 fn heading(text: &str) -> Self {
39 Self {39 Self {
40 text: text.to_owned(),40 text: text.to_owned(),
41 after: String::new(),41 ..Self::default()
42 icon: None,
43 tint: None,
44 disabled: false,
45 target: None,
46 }42 }
47 }43 }
4844
...@@ -55,13 +51,15 @@ impl Row {...@@ -55,13 +51,15 @@ impl Row {
55 disabled: self.disabled,51 disabled: self.disabled,
56 heading: self.target.is_none(),52 heading: self.target.is_none(),
57 separated: self.target.is_none(),53 separated: self.target.is_none(),
54 repeated: self.repeated,
58 ..Item::default()55 ..Item::default()
59 }56 }
60 }57 }
61}58}
6259
63impl State {60impl State {
64 /// Builds the palette while it is open, and runs what is chosen from it.61 /// Builds the palette while it is open, with the actions on a row when asked for, and
62 /// does what is chosen from either.
65 pub(crate) fn palette(&mut self) {63 pub(crate) fn palette(&mut self) {
66 if !self.ui.popup_open(id()) {64 if !self.ui.popup_open(id()) {
67 return;65 return;
...@@ -74,9 +72,9 @@ impl State {...@@ -74,9 +72,9 @@ impl State {
74 text: command.title.to_owned(),72 text: command.title.to_owned(),
75 after: commands::shortcut(command.id),73 after: commands::shortcut(command.id),
76 icon: crate::artwork(command.id),74 icon: crate::artwork(command.id),
77 tint: None,
78 disabled: !self.status(&Choice::Command(command.id), &format).enabled,75 disabled: !self.status(&Choice::Command(command.id), &format).enabled,
79 target: Some(Target::Command(command.id)),76 target: Some(Target::Command(command.id)),
77 ..Row::default()
80 })78 })
81 .collect();79 .collect();
82 commands.extend(self.tags.iter().enumerate().map(|(place, tag)| {80 commands.extend(self.tags.iter().enumerate().map(|(place, tag)| {
...@@ -88,41 +86,30 @@ impl State {...@@ -88,41 +86,30 @@ impl State {
88 tint: Some([1.0; 4]),86 tint: Some([1.0; 4]),
89 disabled: !self.status(&Choice::Command(id), &format).enabled,87 disabled: !self.status(&Choice::Command(id), &format).enabled,
90 target: Some(Target::Command(id)),88 target: Some(Target::Command(id)),
89 ..Row::default()
91 }90 }
92 }));91 }));
93 let mut places = vec![Row::heading("Notebooks")];92 let mut places = self.recent();
93 places.push(Row::heading("Notebooks"));
94 places.extend(94 places.extend(
95 (self.notebooks.iter())95 (self.notebooks.iter())
96 .filter(|library| library.catalog().is_some())96 .filter(|library| library.catalog().is_some())
97 .map(|library| Row {97 .map(|library| Row {
98 text: library.name.clone(),98 text: library.name.clone(),
99 after: String::new(),
100 icon: Some(art::NOTEBOOK),99 icon: Some(art::NOTEBOOK),
101 tint: None,100 target: Some(Target::Notebook(Arc::clone(library))),
102 disabled: false,101 ..Row::default()
103 target: Some(Target::Notebook(library.location.clone())),
104 }),102 }),
105 );103 );
106 places.push(Row::heading("Sections"));104 places.push(Row::heading("Sections"));
107 let theme = &self.ui.theme;105 let theme = &self.ui.theme;
108 for library in &self.notebooks {106 for library in &self.notebooks {
109 // A section opened on its own has no catalog, and lists itself at "".107 // A section opened on its own has no catalog, and lists itself at "".
110 let mut folders: Vec<_> = library.catalog().into_iter().collect();108 let folders = menus::folders(library);
111 let mut at = 0;
112 while let Some(&folder) = folders.get(at) {
113 folders.extend(
114 folder
115 .groups
116 .iter()
117 .filter(|group| !library::recycle_bin(&group.path)),
118 );
119 at += 1;
120 }
121 let paths = folders.iter().map(|folder| folder.path.as_str());
122 let paths: Vec<&str> = if folders.is_empty() {109 let paths: Vec<&str> = if folders.is_empty() {
123 vec![""]110 vec![""]
124 } else {111 } else {
125 paths.collect()112 folders.iter().map(|folder| folder.path.as_str()).collect()
126 };113 };
127 for path in paths {114 for path in paths {
128 places.extend(library.tabs(path).into_iter().map(|tab| Row {115 places.extend(library.tabs(path).into_iter().map(|tab| Row {
...@@ -130,8 +117,11 @@ impl State {...@@ -130,8 +117,11 @@ impl State {
130 after: library.name.clone(),117 after: library.name.clone(),
131 icon: Some(art::SECTION),118 icon: Some(art::SECTION),
132 tint: Some(theme.section(crate::section_color(tab.color)).accent),119 tint: Some(theme.section(crate::section_color(tab.color)).accent),
133 disabled: false,120 target: Some(Target::Section {
134 target: Some(Target::Section(Arc::clone(library), tab.path)),121 library: Arc::clone(library),
122 path: tab.path,
123 }),
124 ..Row::default()
135 }));125 }));
136 }126 }
137 }127 }
...@@ -141,57 +131,243 @@ impl State {...@@ -141,57 +131,243 @@ impl State {
141 .index131 .index
142 .lock()132 .lock()
143 .unwrap_or_else(|poison| poison.into_inner());133 .unwrap_or_else(|poison| poison.into_inner());
144 places.extend(index.entries().iter().map(|entry| {134 places.extend(index.entries().iter().filter_map(|entry| {
145 let (location, path) = entry.section.split_once('\n').unwrap_or_default();135 let (location, path) = entry.section.split_once('\n').unwrap_or_default();
136 let library = self
137 .notebooks
138 .iter()
139 .find(|library| library.location == location)?;
146 let mut section = library::section_name(path, &None);140 let mut section = library::section_name(path, &None);
147 if section.is_empty() {141 if section.is_empty() {
148 section = (self.notebooks.iter())142 section = library.name.clone();
149 .find(|library| library.location == location)
150 .map_or_else(String::new, |library| library.name.clone());
151 }143 }
152 Row {144 Some(Row {
153 text: if entry.title.is_empty() {145 text: title(&entry.title),
154 "Untitled page".to_owned()
155 } else {
156 entry.title.clone()
157 },
158 after: section,146 after: section,
159 icon: Some(art::PAGE),147 icon: Some(art::PAGE),
160 tint: None,148 target: Some(Target::Page {
161 disabled: false,149 library: Arc::clone(library),
162 target: Some(Target::Page(entry.section.clone(), entry.space)),150 path: path.to_owned(),
163 }151 space: entry.space,
152 }),
153 ..Row::default()
154 })
164 }));155 }));
165 drop(index);156 drop(index);
166 let items = [&commands, &places].map(|rows| rows.iter().map(Row::item).collect::<Vec<_>>());157 let mut items =
167 let chosen = ui::popup::palette(158 [&commands, &places].map(|rows| rows.iter().map(Row::item).collect::<Vec<_>>());
159 // The latest page or section starts highlighted, so Enter goes back to it.
160 if let Some(latest) = items[1].get_mut(1).filter(|item| item.repeated) {
161 latest.current = true;
162 }
163 let picked = ui::popup::palette(
168 &mut self.ui,164 &mut self.ui,
169 id(),165 id(),
170 &[(COMMANDS, &items[0]), ("", &items[1])],166 &[(COMMANDS, &items[0]), ("", &items[1])],
171 "Search pages, sections and notebooks (type > for commands)",167 "Search pages, sections and notebooks (type > for commands)",
172 );168 );
173 let mut rows = [commands, places];169 let mut rows = [commands, places];
174 match chosen.and_then(|(mode, index)| rows[mode].swap_remove(index).target) {170 match picked {
175 Some(Target::Command(command)) => self.choose(Choice::Command(command)),171 Some(Pick::Run(mode, index)) => {
176 Some(Target::Notebook(location)) => self.open_notebook(location, None),172 if let Some(target) = rows[mode].swap_remove(index).target {
177 Some(Target::Section(library, path)) => {173 let action = match target {
178 self.commands.push(Command::OpenSection(library, path));174 Target::Command(_) => Action::Run,
179 }175 _ => Action::Open,
180 Some(Target::Page(section, space)) => {176 };
181 let open = (self.session.as_ref())177 self.act_on(target, action);
182 .map(|session| session.library.key(&session.tabs[session.tab].path));
183 let (location, path) = section.split_once('\n').unwrap_or_default();
184 if open.as_ref() == Some(&section) {
185 self.commands.push(Command::OpenPage(space));
186 } else if let Some(library) =
187 (self.notebooks.iter()).find(|library| library.location == location)
188 {
189 self.commands
190 .push(Command::OpenSection(Arc::clone(library), path.to_owned()));
191 self.last_pages.insert(section, space);
192 }178 }
193 }179 }
180 Some(Pick::Actions(mode, index)) => {
181 self.actions = rows[mode].swap_remove(index).target;
182 }
194 None => {}183 None => {}
195 }184 }
185 let panel = ui::popup::actions(id());
186 let Some(target) = self.actions.clone().filter(|_| self.ui.popup_open(panel)) else {
187 self.actions = None;
188 return;
189 };
190 // Run or Open leads, as Enter on the row does.
191 let (keys, first) = match target {
192 Target::Command(id) => (
193 commands::shortcut(id),
194 (
195 Action::Run,
196 Item {
197 text: "Run",
198 disabled: !self.status(&Choice::Command(id), &format).enabled,
199 ..Item::default()
200 },
201 ),
202 ),
203 _ => (
204 String::new(),
205 (
206 Action::Open,
207 Item {
208 text: "Open",
209 ..Item::default()
210 },
211 ),
212 ),
213 };
214 let first = (
215 first.0,
216 Item {
217 shortcut: &keys,
218 ..first.1
219 },
220 );
221 let mut actions = vec![first];
222 actions.extend(self.actions(&target).into_iter().enumerate().map(
223 |(at, (action, item))| {
224 let separated = item.separated || at == 0;
225 (action, Item { separated, ..item })
226 },
227 ));
228 let anchor = Anchor::Right(self.ui.rect(id()).unwrap_or_default());
229 if let Some(action) =
230 self.action_menu(panel, anchor, Some("Search actions"), &target, &actions)
231 {
232 self.actions = None;
233 self.act_on(target, action);
234 }
235 }
236
237 /// Under a heading, the pages shown lately but the one shown, their sections but the one
238 /// open, the notebooks of those since closed, and the servers saved to reconnect to;
239 /// latest first, and none before any. Pages and sections since gone are forgotten.
240 fn recent(&mut self) -> Vec<Row> {
241 let index = self
242 .search
243 .index
244 .lock()
245 .unwrap_or_else(|poison| poison.into_inner());
246 let notebooks = &self.notebooks;
247 let library = |location: &str| {
248 (notebooks.iter())
249 .find(|library| library.location == location)
250 .map(Arc::clone)
251 };
252 // A closed notebook keeps its places to open it again, an unreadable one until it reads.
253 let pruned = self.trail.prune(&index, |place| {
254 library(&place.notebook)
255 .is_none_or(|library| library.notebook.is_err() || library.contains(&place.section))
256 });
257 let shown = self.session.as_ref().map(|session| {
258 (
259 session.library.location.as_str(),
260 session.tabs[session.tab].path.as_str(),
261 session.space,
262 )
263 });
264 let theme = &self.ui.theme;
265 let [mut pages, mut sections, mut closed] = [(); 3].map(|()| Vec::<Row>::new());
266 let listed = |rows: &[Row], target: &Target| {
267 rows.iter().any(|row| match (&row.target, target) {
268 (
269 Some(Target::Section { library, path }),
270 Target::Section {
271 library: other,
272 path: at,
273 },
274 ) => library.location == other.location && path == at,
275 (Some(Target::Closed(location)), Target::Closed(other)) => location == other,
276 _ => false,
277 })
278 };
279 for place in &self.trail.recent {
280 let Some(library) = library(&place.notebook) else {
281 let target = Target::Closed(place.notebook.clone());
282 if !listed(&closed, &target) {
283 closed.push(Row {
284 text: (place.notebook.rsplit(['/', '\\']))
285 .find(|name| !name.is_empty())
286 .unwrap_or(&place.notebook)
287 .to_owned(),
288 after: "Closed".to_owned(),
289 icon: Some(art::NOTEBOOK),
290 target: Some(target),
291 ..Row::default()
292 });
293 }
294 continue;
295 };
296 if library.notebook.is_err() {
297 continue;
298 }
299 let here = (place.notebook.as_str(), place.section.as_str());
300 let section = Target::Section {
301 library: Arc::clone(&library),
302 path: place.section.clone(),
303 };
304 if shown.is_none_or(|(location, path, _)| (location, path) != here)
305 && !listed(&sections, &section)
306 && let Some(tab) = (library.tabs(&menus::folder(&place.section)).into_iter())
307 .find(|tab| tab.path == place.section)
308 {
309 sections.push(Row {
310 text: tab.name,
311 after: library.name.clone(),
312 icon: Some(art::SECTION),
313 tint: Some(theme.section(crate::section_color(tab.color)).accent),
314 repeated: true,
315 target: Some(section),
316 ..Row::default()
317 });
318 }
319 let key = library.key(&place.section);
320 // A page the index hasn't read yet waits for its title.
321 let Some(entry) = (index.entries().iter())
322 .find(|entry| entry.space == place.page && entry.section == key)
323 .filter(|_| shown != Some((here.0, here.1, place.page)))
324 else {
325 continue;
326 };
327 pages.push(Row {
328 text: title(&entry.title),
329 after: library::section_name(&place.section, &None),
330 icon: Some(art::PAGE),
331 repeated: true,
332 target: Some(Target::Page {
333 library,
334 path: place.section.clone(),
335 space: place.page,
336 }),
337 ..Row::default()
338 });
339 }
340 drop(index);
341 if pruned {
342 self.save_settings();
343 }
344 let servers = self.servers.iter().map(|address| Row {
345 text: address.strip_prefix("smb://").unwrap_or(address).to_owned(),
346 after: "Server".to_owned(),
347 icon: Some(art::SERVER),
348 target: Some(Target::Server(address.clone())),
349 ..Row::default()
350 });
351 let rows: Vec<Row> = pages
352 .into_iter()
353 .chain(sections)
354 .chain(closed)
355 .chain(servers)
356 .collect();
357 if rows.is_empty() {
358 return rows;
359 }
360 std::iter::once(Row::heading("Recent"))
361 .chain(rows)
362 .collect()
363 }
364}
365
366/// A page's title as the palette lists it.
367fn title(title: &str) -> String {
368 if title.is_empty() {
369 "Untitled page".to_owned()
370 } else {
371 title.to_owned()
196 }372 }
197}373}
crates/snowbound/src/paste.rs+572-42
...@@ -1,13 +1,21 @@...@@ -1,13 +1,21 @@
1//! Pictures and files from the clipboard, and pictures inserted, as OneNote 2010 places them.1//! Pictures and files from the clipboard, and pictures inserted, as OneNote 2010 places them.
22
3use crate::{State, platform};3use crate::{State, platform};
4use std::{error::Error, path::PathBuf};4use canvas::editor::Piece;
5use std::{
6 error::Error,
7 path::{Path, PathBuf},
8 time::Duration,
9};
510
6/// What Paste takes from the clipboard. Files come first, as Finder also offers their names11/// What Paste takes from the clipboard. Files come first, as Finder also offers their names
7/// as text and their icons as pictures; text before a picture, as Office and browsers offer12/// as text and their icons as pictures; then a page holding pictures, as its text alone
8/// a picture of copied text beside it.13/// would lose them; text before a picture, as Office and browsers offer a picture of copied
14/// text beside it.
9pub(crate) enum Pasted {15pub(crate) enum Pasted {
10 Files(Vec<PathBuf>),16 Files(Vec<PathBuf>),
17 /// HTML holding a picture.
18 Page(String),
11 Text(String),19 Text(String),
12 Picture(Vec<u8>),20 Picture(Vec<u8>),
13}21}
...@@ -17,13 +25,39 @@ impl State {...@@ -17,13 +25,39 @@ impl State {
17 match self.clipboard.pasted() {25 match self.clipboard.pasted() {
18 Some(Pasted::Files(paths)) => {26 Some(Pasted::Files(paths)) => {
19 for path in paths {27 for path in paths {
20 // OneNote 2010 pastes a picture file as the picture (lab, 2026-09-30).28 self.place_file(&path, None)?;
21 match std::fs::read(&path) {29 }
22 Ok(bytes) if draw::RasterImage::measure(&bytes).is_ok() => {30 }
23 self.insert_picture(bytes)?31 Some(Pasted::Page(html)) => {
24 }32 // Web pictures come later, so the window never waits on the network.
25 _ => self.attach(&path, None)?,33 let mut fetched = Vec::new();
34 let pieces = html_pieces(&html, |source, css| {
35 if source.starts_with("https://") || source.starts_with("http://") {
36 fetched.push((source.to_owned(), css));
37 return Some(Piece::Awaited);
26 }38 }
39 let bytes = load(source)?;
40 let size = sized(&bytes, css)?;
41 Some(Piece::Picture(bytes, size))
42 });
43 let language = canvas::language::lcid(&platform::input_language());
44 let (places, response) = self.view.paste_pieces(pieces, language)?;
45 self.respond(response);
46 for (place, (source, css)) in places.into_iter().zip(fetched) {
47 let proxy = self.proxy.clone();
48 std::thread::spawn(move || {
49 let Some(bytes) = fetch(&source) else {
50 return;
51 };
52 let Some(size) = sized(&bytes, css) else {
53 return;
54 };
55 let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| {
56 let response = state.view.insert_awaited(place, bytes, size)?;
57 state.respond(response);
58 Ok(())
59 })));
60 });
27 }61 }
28 }62 }
29 Some(Pasted::Text(text)) => {63 Some(Pasted::Text(text)) => {
...@@ -31,15 +65,34 @@ impl State {...@@ -31,15 +65,34 @@ impl State {
31 let response = self.view.paste(&text, language)?;65 let response = self.view.paste(&text, language)?;
32 self.respond(response);66 self.respond(response);
33 }67 }
34 Some(Pasted::Picture(bytes)) => self.insert_picture(bytes)?,68 Some(Pasted::Picture(bytes)) => self.insert_picture(bytes, None)?,
35 None => {}69 None => {}
36 }70 }
37 Ok(())71 Ok(())
38 }72 }
3973
40 /// Puts an encoded picture at the caret at the size its resolution gives it, as OneNote74 /// A pasted file, or one dropped at `at`, a window point: a picture file as its picture,
41 /// 2010 inserts and pastes one.75 /// as OneNote 2010 pastes one (lab, 2026-09-30), and any other file attached.
42 pub(crate) fn insert_picture(&mut self, bytes: Vec<u8>) -> Result<(), Box<dyn Error>> {76 pub(crate) fn place_file(
77 &mut self,
78 path: &Path,
79 at: Option<[f32; 2]>,
80 ) -> Result<(), Box<dyn Error>> {
81 match std::fs::read(path) {
82 Ok(bytes) if draw::RasterImage::measure(&bytes).is_ok() => {
83 self.insert_picture(bytes, at)
84 }
85 _ => self.attach(path, at),
86 }
87 }
88
89 /// Puts an encoded picture at the caret, or where it is dropped at `at`, a window point,
90 /// at the size its resolution gives it, as OneNote 2010 inserts and pastes one.
91 pub(crate) fn insert_picture(
92 &mut self,
93 bytes: Vec<u8>,
94 at: Option<[f32; 2]>,
95 ) -> Result<(), Box<dyn Error>> {
43 let Ok(pixels) = draw::RasterImage::measure(&bytes) else {96 let Ok(pixels) = draw::RasterImage::measure(&bytes) else {
44 platform::alert(97 platform::alert(
45 "Couldn't insert the picture",98 "Couldn't insert the picture",
...@@ -48,12 +101,289 @@ impl State {...@@ -48,12 +101,289 @@ impl State {
48 return Ok(());101 return Ok(());
49 };102 };
50 let size = picture_size(&bytes, pixels);103 let size = picture_size(&bytes, pixels);
51 let response = self.view.insert_picture(bytes, size)?;104 let response = match at {
105 Some(point) => self
106 .view
107 .drop_picture(self.page_point(point), bytes, size)?,
108 None => self.view.insert_picture(bytes, size)?,
109 };
52 self.respond(response);110 self.respond(response);
53 Ok(())111 Ok(())
54 }112 }
55}113}
56114
115/// A web page fragment's text, a line to each block, and its pictures in order, as OneNote
116/// 2010 pastes them (lab, 2026-09-30). `load` makes a picture's piece from its source and
117/// its `width` and `height` in CSS pixels; one it can't is left out.
118fn html_pieces(
119 html: &str,
120 mut load: impl FnMut(&str, [Option<f32>; 2]) -> Option<Piece>,
121) -> Vec<Piece> {
122 let html = html
123 .split_once("<!--StartFragment-->")
124 .map_or(html, |(_, fragment)| fragment);
125 let html = html
126 .split_once("<!--EndFragment-->")
127 .map_or(html, |(fragment, _)| fragment);
128 let mut pieces = Vec::new();
129 let mut text = String::new();
130 let flush = |text: &mut String, pieces: &mut Vec<Piece>| {
131 let trimmed = text.trim_matches(|c: char| c.is_whitespace());
132 if !trimmed.is_empty() {
133 pieces.push(Piece::Text(trimmed.to_owned()));
134 }
135 text.clear();
136 };
137 let line = |text: &mut String| {
138 text.truncate(text.trim_end_matches(' ').len());
139 if !text.is_empty() && !text.ends_with('\n') {
140 text.push('\n');
141 }
142 };
143 // Elements whose content is not text: script, style, head and title.
144 let mut hidden: Option<String> = None;
145 let mut rest = html;
146 while !rest.is_empty() {
147 if let Some(after) = rest.strip_prefix("<!--") {
148 rest = after.split_once("-->").map_or("", |(_, after)| after);
149 continue;
150 }
151 let Some(after) = rest.strip_prefix('<') else {
152 let end = rest.find('<').unwrap_or(rest.len());
153 if hidden.is_none() {
154 for c in decode_entities(&rest[..end]).chars() {
155 if c.is_whitespace() && c != '\u{a0}' {
156 if !text.is_empty() && !text.ends_with([' ', '\n']) {
157 text.push(' ');
158 }
159 } else {
160 text.push(if c == '\u{a0}' { ' ' } else { c });
161 }
162 }
163 }
164 rest = &rest[end..];
165 continue;
166 };
167 let end = tag_end(after);
168 let tag = &after[..end];
169 rest = after.get(end + 1..).unwrap_or("");
170 let (closing, tag) = match tag.strip_prefix('/') {
171 Some(tag) => (true, tag),
172 None => (false, tag),
173 };
174 let name = tag
175 .split(|c: char| c.is_whitespace() || c == '/')
176 .next()
177 .unwrap_or_default()
178 .to_ascii_lowercase();
179 if let Some(until) = &hidden {
180 if closing && name == *until {
181 hidden = None;
182 }
183 continue;
184 }
185 match name.as_str() {
186 "script" | "style" | "head" | "title" if !closing => hidden = Some(name),
187 "br" => {
188 text.truncate(text.trim_end_matches(' ').len());
189 text.push('\n');
190 }
191 "p" | "div" | "li" | "tr" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "blockquote"
192 | "pre" | "table" | "ul" | "ol" | "dt" | "dd" | "section" | "article" | "header"
193 | "footer" | "figure" | "figcaption" => line(&mut text),
194 "img" if !closing => {
195 let css = |name| {
196 attribute(tag, name)
197 .and_then(|value| value.trim_end_matches("px").parse::<f32>().ok())
198 .filter(|pixels| *pixels > 0.0)
199 };
200 let Some(picture) = attribute(tag, "src").and_then(|source| {
201 load(&decode_entities(source), [css("width"), css("height")])
202 }) else {
203 continue;
204 };
205 flush(&mut text, &mut pieces);
206 pieces.push(picture);
207 }
208 _ => {}
209 }
210 }
211 flush(&mut text, &mut pieces);
212 pieces
213}
214
215/// Where a tag's text ends, before its `>`, outside quoted attribute values.
216fn tag_end(tag: &str) -> usize {
217 let mut quote = None;
218 for (at, c) in tag.char_indices() {
219 match (quote, c) {
220 (None, '"' | '\'') => quote = Some(c),
221 (Some(open), _) if c == open => quote = None,
222 (None, '>') => return at,
223 _ => {}
224 }
225 }
226 tag.len()
227}
228
229/// The value of attribute `name` in `tag`, entities still encoded.
230fn attribute<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
231 let mut rest = tag;
232 while let Some(at) = rest.find('=') {
233 let key = rest[..at].trim_end().rsplit(char::is_whitespace).next()?;
234 let value = rest[at + 1..].trim_start();
235 let (value, after) = match value.chars().next()? {
236 quote @ ('"' | '\'') => value[1..].split_once(quote)?,
237 _ => value.split_at(value.find(char::is_whitespace).unwrap_or(value.len())),
238 };
239 if key.eq_ignore_ascii_case(name) {
240 return Some(value);
241 }
242 rest = after;
243 }
244 None
245}
246
247fn decode_entities(text: &str) -> String {
248 let mut decoded = String::with_capacity(text.len());
249 let mut rest = text;
250 while let Some(at) = rest.find('&') {
251 decoded.push_str(&rest[..at]);
252 rest = &rest[at..];
253 let entity = rest[1..]
254 .find(';')
255 .filter(|end| *end <= 10)
256 .map(|end| &rest[1..end + 1]);
257 let c = entity.and_then(|entity| match entity {
258 "amp" => Some('&'),
259 "lt" => Some('<'),
260 "gt" => Some('>'),
261 "quot" => Some('"'),
262 "apos" => Some('\''),
263 "nbsp" => Some('\u{a0}'),
264 _ => {
265 let number = entity.strip_prefix('#')?;
266 let code = match number.strip_prefix(['x', 'X']) {
267 Some(hex) => u32::from_str_radix(hex, 16).ok()?,
268 None => number.parse().ok()?,
269 };
270 char::from_u32(code)
271 }
272 });
273 match (c, entity) {
274 (Some(c), Some(entity)) => {
275 decoded.push(c);
276 rest = &rest[entity.len() + 2..];
277 }
278 _ => {
279 decoded.push('&');
280 rest = &rest[1..];
281 }
282 }
283 }
284 decoded.push_str(rest);
285 decoded
286}
287
288/// A picture's size in points from its `width` and `height` in CSS pixels, either giving
289/// the other by its proportions, or else from its resolution; none where it doesn't decode.
290fn sized(bytes: &[u8], css: [Option<f32>; 2]) -> Option<[f32; 2]> {
291 let natural = picture_size(bytes, draw::RasterImage::measure(bytes).ok()?);
292 Some(match css {
293 [Some(width), Some(height)] => [width * 0.75, height * 0.75],
294 [Some(width), None] => [width * 0.75, natural[1] * width * 0.75 / natural[0]],
295 [None, Some(height)] => [natural[0] * height * 0.75 / natural[1], height * 0.75],
296 [None, None] => natural,
297 })
298}
299
300/// A picture's bytes from a `data:` address or a local file; none from a relative address,
301/// which has no page to resolve against.
302fn load(source: &str) -> Option<Vec<u8>> {
303 if let Some(data) = source.strip_prefix("data:") {
304 let (kind, payload) = data.split_once(',')?;
305 return if kind.ends_with(";base64") {
306 base64(payload)
307 } else {
308 Some(percent_decode(payload))
309 };
310 }
311 if let Some(path) = source.strip_prefix("file://") {
312 let path = String::from_utf8(percent_decode(path)).ok()?;
313 // file:///C:/… on Windows.
314 let path = match path.strip_prefix('/') {
315 Some(drive) if cfg!(windows) => drive.to_owned(),
316 _ => path,
317 };
318 return std::fs::read(path).ok();
319 }
320 None
321}
322
323/// A web picture's bytes, on a thread of its own.
324fn fetch(source: &str) -> Option<Vec<u8>> {
325 let agent: ureq::Agent = ureq::Agent::config_builder()
326 .timeout_global(Some(Duration::from_secs(10)))
327 .build()
328 .into();
329 agent
330 .get(source)
331 .call()
332 .ok()?
333 .body_mut()
334 .with_config()
335 .limit(64 << 20)
336 .read_to_vec()
337 .ok()
338}
339
340fn percent_decode(text: &str) -> Vec<u8> {
341 let bytes = text.as_bytes();
342 let mut decoded = Vec::with_capacity(bytes.len());
343 let mut at = 0;
344 while at < bytes.len() {
345 let hex = bytes
346 .get(at + 1..at + 3)
347 .and_then(|hex| u8::from_str_radix(std::str::from_utf8(hex).ok()?, 16).ok());
348 match (bytes[at], hex) {
349 (b'%', Some(byte)) => {
350 decoded.push(byte);
351 at += 3;
352 }
353 (byte, _) => {
354 decoded.push(byte);
355 at += 1;
356 }
357 }
358 }
359 decoded
360}
361
362fn base64(text: &str) -> Option<Vec<u8>> {
363 let mut decoded = Vec::with_capacity(text.len() * 3 / 4);
364 let (mut bits, mut count) = (0_u32, 0);
365 for c in text
366 .bytes()
367 .filter(|c| !c.is_ascii_whitespace() && *c != b'=')
368 {
369 let value = match c {
370 b'A'..=b'Z' => c - b'A',
371 b'a'..=b'z' => c - b'a' + 26,
372 b'0'..=b'9' => c - b'0' + 52,
373 b'+' | b'-' => 62,
374 b'/' | b'_' => 63,
375 _ => return None,
376 };
377 bits = bits << 6 | u32::from(value);
378 count += 6;
379 if count >= 8 {
380 count -= 8;
381 decoded.push((bits >> count) as u8);
382 }
383 }
384 Some(decoded)
385}
386
57/// A picture's size in points: its pixels at the resolution it declares, or at 96 dpi387/// A picture's size in points: its pixels at the resolution it declares, or at 96 dpi
58/// without one, as OneNote 2010 sizes inserted and pasted pictures and pasted bitmaps388/// without one, as OneNote 2010 sizes inserted and pasted pictures and pasted bitmaps
59/// (lab, 2026-09-30).389/// (lab, 2026-09-30).
...@@ -62,39 +392,122 @@ fn picture_size(encoded: &[u8], pixels: [u32; 2]) -> [f32; 2] {...@@ -62,39 +392,122 @@ fn picture_size(encoded: &[u8], pixels: [u32; 2]) -> [f32; 2] {
62 [0, 1].map(|axis| pixels[axis] as f32 * 72.0 / dpi[axis])392 [0, 1].map(|axis| pixels[axis] as f32 * 72.0 / dpi[axis])
63}393}
64394
65/// Dots per inch from a PNG's `pHYs` or a JPEG's JFIF density, where they give one.395/// Dots per inch from a PNG's `pHYs`, or a JPEG's JFIF density or else its Exif
396/// resolution, where they give one.
66fn resolution(encoded: &[u8]) -> Option<[f32; 2]> {397fn resolution(encoded: &[u8]) -> Option<[f32; 2]> {
67 let dpi = if let Some(mut chunks) = encoded.strip_prefix(b"\x89PNG\r\n\x1a\n") {398 let dpi = match encoded.strip_prefix(b"\x89PNG\r\n\x1a\n") {
68 loop {399 Some(chunks) => png_resolution(chunks)?,
69 let length = u32::from_be_bytes(chunks.get(..4)?.try_into().ok()?) as usize;400 None => jpeg_resolution(encoded.strip_prefix(b"\xff\xd8")?)?,
70 let kind = chunks.get(4..8)?;401 };
71 let data = chunks.get(8..8 + length)?;402 dpi.iter().all(|dpi| *dpi > 0.0).then_some(dpi)
72 if kind == b"IDAT" {403}
73 return None;404
74 }405fn png_resolution(mut chunks: &[u8]) -> Option<[f32; 2]> {
75 // Unit 1 is the metre.406 loop {
76 if kind == b"pHYs" && data.len() == 9 && data[8] == 1 {407 let length = u32::from_be_bytes(chunks.get(..4)?.try_into().ok()?) as usize;
77 let per_metre =408 let kind = chunks.get(4..8)?;
78 |at: usize| u32::from_be_bytes(data[at..at + 4].try_into().unwrap());409 let data = chunks.get(8..8 + length)?;
79 break [per_metre(0), per_metre(4)].map(|dots| dots as f32 * 0.0254);410 if kind == b"IDAT" {
80 }
81 chunks = chunks.get(12 + length..)?;
82 }
83 } else {
84 // Length, identifier, version, units, then the densities.
85 let app0 = encoded.strip_prefix(b"\xff\xd8\xff\xe0")?.get(..14)?;
86 if &app0[2..7] != b"JFIF\0" {
87 return None;411 return None;
88 }412 }
89 let density = |at: usize| u16::from_be_bytes([app0[at], app0[at + 1]]) as f32;413 // Unit 1 is the metre.
90 let dots = [density(10), density(12)];414 if kind == b"pHYs" && data.len() == 9 && data[8] == 1 {
91 match app0[9] {415 let per_metre = |at: usize| u32::from_be_bytes(data[at..at + 4].try_into().unwrap());
92 1 => dots,416 return Some([per_metre(0), per_metre(4)].map(|dots| dots as f32 * 0.0254));
93 2 => dots.map(|per_cm| per_cm * 2.54),417 }
94 _ => return None,418 chunks = chunks.get(12 + length..)?;
419 }
420}
421
422/// `segments` follow the start-of-image marker.
423fn jpeg_resolution(mut segments: &[u8]) -> Option<[f32; 2]> {
424 let mut exif = None;
425 while let [0xff, marker, high, low, rest @ ..] = segments {
426 let length = usize::from(u16::from_be_bytes([*high, *low])).checked_sub(2)?;
427 let data = rest.get(..length)?;
428 match (*marker, data) {
429 (
430 0xe0,
431 [
432 b'J',
433 b'F',
434 b'I',
435 b'F',
436 0,
437 _,
438 _,
439 units @ (1 | 2),
440 x0,
441 x1,
442 y0,
443 y1,
444 ..,
445 ],
446 ) => {
447 let dots = [[*x0, *x1], [*y0, *y1]].map(|dots| f32::from(u16::from_be_bytes(dots)));
448 if dots.iter().all(|dots| *dots > 0.0) {
449 return Some(if *units == 1 {
450 dots
451 } else {
452 dots.map(|per_cm| per_cm * 2.54)
453 });
454 }
455 }
456 (0xe1, [b'E', b'x', b'i', b'f', 0, 0, tiff @ ..]) => exif = exif_resolution(tiff),
457 // The scan follows.
458 (0xda, _) => break,
459 _ => {}
95 }460 }
461 segments = &rest[length..];
462 }
463 exif
464}
465
466/// XResolution and YResolution from an Exif block's first directory, in their unit.
467fn exif_resolution(tiff: &[u8]) -> Option<[f32; 2]> {
468 let big = match tiff.get(..2)? {
469 b"MM" => true,
470 b"II" => false,
471 _ => return None,
96 };472 };
97 dpi.iter().all(|dpi| *dpi > 0.0).then_some(dpi)473 let u16_at = |at: usize| -> Option<u16> {
474 let bytes = tiff.get(at..at + 2)?.try_into().ok()?;
475 Some(if big {
476 u16::from_be_bytes(bytes)
477 } else {
478 u16::from_le_bytes(bytes)
479 })
480 };
481 let u32_at = |at: usize| -> Option<u32> {
482 let bytes = tiff.get(at..at + 4)?.try_into().ok()?;
483 Some(if big {
484 u32::from_be_bytes(bytes)
485 } else {
486 u32::from_le_bytes(bytes)
487 })
488 };
489 let directory = u32_at(4)? as usize;
490 let (mut x, mut y, mut unit) = (None, None, 2);
491 for entry in 0..usize::from(u16_at(directory)?) {
492 let at = directory + 2 + entry * 12;
493 let rational = || {
494 let offset = u32_at(at + 8)? as usize;
495 let [numerator, denominator] = [u32_at(offset)?, u32_at(offset + 4)?];
496 (denominator > 0).then(|| numerator as f32 / denominator as f32)
497 };
498 match u16_at(at)? {
499 0x011a => x = rational(),
500 0x011b => y = rational(),
501 0x0128 => unit = u16_at(at + 8)?,
502 _ => {}
503 }
504 }
505 let dots = [x?, y?];
506 match unit {
507 2 => Some(dots),
508 3 => Some(dots.map(|per_cm| per_cm * 2.54)),
509 _ => None,
510 }
98}511}
99512
100/// A bitmap from the clipboard as the PNG a page stores, without a resolution, so it takes513/// A bitmap from the clipboard as the PNG a page stores, without a resolution, so it takes
...@@ -148,6 +561,120 @@ mod tests {...@@ -148,6 +561,120 @@ mod tests {
148 bytes561 bytes
149 }562 }
150563
564 /// A JPEG header whose JFIF segment gives no density and whose Exif block gives `dpi`
565 /// in `unit`, little-endian.
566 fn exif(dpi: u32, unit: u16) -> Vec<u8> {
567 let mut tiff = b"II*\0\x08\0\0\0".to_vec();
568 tiff.extend(3u16.to_le_bytes());
569 let values = 8 + 2 + 3 * 12 + 4;
570 for (tag, kind, value) in [
571 (0x011a_u16, 5_u16, values),
572 (0x011b, 5, values + 8),
573 (0x0128, 3, u32::from(unit)),
574 ] {
575 tiff.extend(tag.to_le_bytes());
576 tiff.extend(kind.to_le_bytes());
577 tiff.extend(1u32.to_le_bytes());
578 tiff.extend(value.to_le_bytes());
579 }
580 tiff.extend(0u32.to_le_bytes());
581 for _ in 0..2 {
582 tiff.extend(dpi.to_le_bytes());
583 tiff.extend(1u32.to_le_bytes());
584 }
585 let mut bytes = jfif(0, 1);
586 bytes.extend([0xff, 0xe1]);
587 bytes.extend(u16::try_from(2 + 6 + tiff.len()).unwrap().to_be_bytes());
588 bytes.extend(b"Exif\0\0");
589 bytes.extend(tiff);
590 bytes.extend([0xff, 0xda, 0, 2]);
591 bytes
592 }
593
594 fn shown(pieces: &[Piece]) -> Vec<String> {
595 pieces
596 .iter()
597 .map(|piece| match piece {
598 Piece::Text(text) => text.clone(),
599 Piece::Picture(bytes, size) => format!("[{} {size:?}]", bytes.len()),
600 Piece::Awaited => "[awaited]".to_owned(),
601 })
602 .collect()
603 }
604
605 /// As OneNote 2010 pasted `<p>before <img> after</p>` (lab, 2026-09-30): the text on
606 /// either side of the picture, the picture at the size its attributes give.
607 #[test]
608 fn a_pasted_page_gives_its_text_and_pictures_in_order() {
609 let html = "Version:0.9\r\n<html><head><style>p{}</style><title>T</title></head>\
610 <body><!--StartFragment--><p>before <img alt=\"a > b\" \
611 src=\"file:///C:/work/pic.png\" width=\"200\" height=\"100px\"> after</p>\
612 <p>Fish &amp; chips&nbsp;&#x2014;&#8212;</p><ul><li>one<li>two<br>three</ul>\
613 <img src=\"missing.png\"><IMG SRC='https://example.invalid/a.png' width=40>\
614 <!--EndFragment--></body></html>";
615 let mut asked = Vec::new();
616 let pieces = html_pieces(html, |source, css| {
617 asked.push((source.to_owned(), css));
618 match source {
619 "missing.png" => None,
620 "https://example.invalid/a.png" => Some(Piece::Awaited),
621 _ => Some(Piece::Picture(vec![0; 3], [150.0, 75.0])),
622 }
623 });
624 assert_eq!(
625 asked,
626 [
627 (
628 "file:///C:/work/pic.png".to_owned(),
629 [Some(200.0), Some(100.0)]
630 ),
631 ("missing.png".to_owned(), [None, None]),
632 (
633 "https://example.invalid/a.png".to_owned(),
634 [Some(40.0), None]
635 ),
636 ]
637 );
638 assert_eq!(
639 shown(&pieces),
640 [
641 "before",
642 "[3 [150.0, 75.0]]",
643 "after\nFish & chips \u{2014}\u{2014}\none\ntwo\nthree",
644 "[awaited]",
645 ]
646 );
647 }
648
649 /// Attributes size a picture, the missing one by its proportions.
650 #[test]
651 fn page_pictures_take_the_size_their_attributes_give() {
652 let picture = png_at(None);
653 assert_eq!(sized(&picture, [None, None]), Some([150.0, 75.0]));
654 assert_eq!(sized(&picture, [Some(100.0), None]), Some([75.0, 37.5]));
655 assert_eq!(sized(&picture, [None, Some(50.0)]), Some([75.0, 37.5]));
656 assert_eq!(sized(&picture, [Some(8.0), Some(4.0)]), Some([6.0, 3.0]));
657 assert_eq!(sized(b"not a picture", [None, None]), None);
658 }
659
660 #[test]
661 fn picture_sources_decode() {
662 assert_eq!(
663 load("data:image/png;base64,iVBORw0KGgo=").unwrap(),
664 b"\x89PNG\r\n\x1a\n"
665 );
666 assert_eq!(load("data:text/plain,a%20b").unwrap(), b"a b");
667 assert_eq!(load("relative/pic.png"), None);
668 let directory =
669 std::env::temp_dir().join(format!("snowbound-paste-{}", std::process::id()));
670 std::fs::create_dir_all(&directory).unwrap();
671 let file = directory.join("a picture.png");
672 std::fs::write(&file, b"bytes").unwrap();
673 let address = format!("file://{}", file.to_str().unwrap().replace(' ', "%20"));
674 assert_eq!(load(&address).unwrap(), b"bytes");
675 std::fs::remove_dir_all(&directory).unwrap();
676 }
677
151 fn close(size: [f32; 2], expected: [f32; 2]) -> bool {678 fn close(size: [f32; 2], expected: [f32; 2]) -> bool {
152 (0..2).all(|axis| (size[axis] - expected[axis]).abs() < 0.01)679 (0..2).all(|axis| (size[axis] - expected[axis]).abs() < 0.01)
153 }680 }
...@@ -166,6 +693,9 @@ mod tests {...@@ -166,6 +693,9 @@ mod tests {
166 assert!(close(size(&jfif(0, 1)), [150.0, 75.0]));693 assert!(close(size(&jfif(0, 1)), [150.0, 75.0]));
167 assert!(close(size(&jfif(1, 0)), [150.0, 75.0]));694 assert!(close(size(&jfif(1, 0)), [150.0, 75.0]));
168 assert!(close(size(b"GIF89a"), [150.0, 75.0]));695 assert!(close(size(b"GIF89a"), [150.0, 75.0]));
696 assert!(close(size(&exif(72, 2)), [200.0, 100.0]));
697 assert!(close(size(&exif(20, 3)), [283.4646, 141.7323]));
698 assert!(close(size(&exif(72, 1)), [150.0, 75.0]));
169 }699 }
170700
171 #[test]701 #[test]
crates/snowbound/src/prefetch.rs created+376
...@@ -0,0 +1,376 @@
1//! Sections and pages made ready before they are shown, and pages kept once left, so
2//! switching to one shows it at once. A section kept open is its notebook's to hand out
3//! (`Library::open`); pages are kept as scenes, whose pictures are what takes long to
4//! draw, and every page shown is read and laid out afresh around them.
5
6use super::*;
7use std::collections::VecDeque;
8
9/// Most recently used first, at most `most` of them.
10pub struct Recent<K, V> {
11 entries: VecDeque<(K, V)>,
12 most: usize,
13}
14
15impl<K: PartialEq, V> Recent<K, V> {
16 pub const fn new(most: usize) -> Self {
17 Self {
18 entries: VecDeque::new(),
19 most,
20 }
21 }
22
23 /// Keeps `value` as the most recent, returning what `key` held before and what no
24 /// longer fits.
25 pub fn put(&mut self, key: K, value: V) -> Vec<V> {
26 let mut gone: Vec<V> = self.take(&key).into_iter().collect();
27 self.entries.push_front((key, value));
28 gone.extend(self.trim(u64::MAX, |_| 0));
29 gone
30 }
31
32 pub fn take(&mut self, key: &K) -> Option<V> {
33 let at = self.entries.iter().position(|(held, _)| held == key)?;
34 self.entries.remove(at).map(|(_, value)| value)
35 }
36
37 pub fn contains(&self, key: &K) -> bool {
38 self.entries.iter().any(|(held, _)| held == key)
39 }
40
41 pub fn values_mut(&mut self) -> impl Iterator<Item = &mut V> {
42 self.entries.iter_mut().map(|(_, value)| value)
43 }
44
45 /// Lets go of the least recent past `most`, and past `budget` as `weigh` counts them.
46 pub fn trim(&mut self, budget: u64, weigh: impl Fn(&V) -> u64) -> Vec<V> {
47 let mut kept = 0u64;
48 let fits = self
49 .entries
50 .iter()
51 .take(self.most)
52 .take_while(|(_, value)| {
53 kept = kept.saturating_add(weigh(value));
54 kept <= budget
55 })
56 .count();
57 self.entries.drain(fits..).map(|(_, value)| value).collect()
58 }
59}
60
61/// Pages kept: a section's `Library::key` and the page's space.
62type Key = (String, ExGuid);
63
64/// How many pages are kept laid out, and the decoded pictures they may hold between them,
65/// besides the page shown.
66const SCENES: usize = 32;
67const SCENE_BYTES: u64 = 128 << 20;
68
69/// A page kept for showing again: its scene, at the offset `PageView::open` takes, and, while
70/// its pictures are still drawing, the editor that places them.
71pub struct Kept {
72 scene: (PageScene, [f32; 2]),
73 editor: Option<CanvasEditor>,
74}
75
76pub type Scenes = Arc<Mutex<Recent<Key, Kept>>>;
77
78/// Work done ahead on its own thread, one piece at a time.
79enum Job {
80 /// Reads a page of the open section.
81 Page(
82 Key,
83 Box<dyn FnOnce() -> Result<Page, Box<dyn Error>> + Send>,
84 ),
85 /// Opens a section for its notebook to keep, then reads the page it would show.
86 Section {
87 library: Arc<Library>,
88 path: String,
89 last: Option<ExGuid>,
90 notify: Box<dyn Fn() + Send>,
91 },
92}
93
94pub struct Prefetch {
95 pub scenes: Scenes,
96 jobs: mpsc::Sender<Job>,
97 /// What was asked for lately, by section key and page (none for a section), so a hover
98 /// held over many frames asks once.
99 asked: Recent<(String, Option<ExGuid>), ()>,
100}
101
102impl Prefetch {
103 pub fn new(layouts: Arc<Mutex<TextEngine>>, redraw: std::task::Waker) -> Self {
104 let scenes = Scenes::new(Mutex::new(Recent::new(SCENES)));
105 let (jobs, queue) = mpsc::channel();
106 let kept = Arc::clone(&scenes);
107 std::thread::Builder::new()
108 .name("prefetch".into())
109 .spawn(move || {
110 for job in queue {
111 // What fails ahead fails again, and says so, once it is asked for.
112 if prepare(job, &layouts, &kept).is_ok() {
113 redraw.wake_by_ref();
114 }
115 }
116 })
117 .expect("The prefetch thread starts");
118 Self {
119 scenes,
120 jobs,
121 asked: Recent::new(32),
122 }
123 }
124
125 /// The scene kept for page `space` of the section `section` names, if any.
126 pub fn take(scenes: &Scenes, section: &str, space: ExGuid) -> Option<PageScene> {
127 let kept = scenes.lock().ok()?.take(&(section.to_owned(), space))?;
128 Some(kept.scene.0)
129 }
130
131 /// Keeps the scene of a page left, its pictures drawn.
132 pub fn keep(&mut self, section: String, space: ExGuid, scene: (PageScene, [f32; 2])) {
133 self.asked.take(&(section.clone(), Some(space)));
134 if let Ok(mut scenes) = self.scenes.lock() {
135 scenes.put(
136 (section, space),
137 Kept {
138 scene,
139 editor: None,
140 },
141 );
142 scenes.trim(SCENE_BYTES, weigh);
143 }
144 }
145
146 /// Whether `target` is new since it was last asked for, noting it.
147 fn ask(&mut self, target: (String, Option<ExGuid>)) -> bool {
148 let new = !self.asked.contains(&target);
149 self.asked.put(target, ());
150 new
151 }
152
153 /// Draws the pictures kept pages show first, as `view` would open them, letting go of
154 /// each editor once they are drawn.
155 pub fn draw(&self, view: &PageView, paper: canvas::gpu::Paper, waker: &std::task::Waker) {
156 let Ok(mut scenes) = self.scenes.lock() else {
157 return;
158 };
159 for kept in scenes.values_mut() {
160 if let Some(editor) = &kept.editor
161 && view.prepare(
162 &mut kept.scene,
163 editor,
164 paper.colored(editor.page_color()),
165 waker,
166 )
167 {
168 kept.editor = None;
169 }
170 }
171 scenes.trim(SCENE_BYTES, weigh);
172 }
173}
174
175fn weigh(kept: &Kept) -> u64 {
176 kept.scene.0.raster_bytes()
177}
178
179fn prepare(job: Job, layouts: &Mutex<TextEngine>, scenes: &Scenes) -> Result<(), Box<dyn Error>> {
180 let (key, page) = match job {
181 Job::Page(key, read) => {
182 if scenes.lock().map_err(|_| "Prefetch failed")?.contains(&key) {
183 return Ok(());
184 }
185 let page = read()?;
186 (key, page)
187 }
188 Job::Section {
189 library,
190 path,
191 last,
192 notify,
193 } => {
194 let replica = library.prefetch(&path, notify)?;
195 let pages = replica.pages()?;
196 let space = last
197 .filter(|space| pages.iter().any(|(listed, ..)| listed == space))
198 .or(pages.first().map(|(space, ..)| *space))
199 .ok_or("The section has no pages")?;
200 let key = (library.key(&path), space);
201 if scenes.lock().map_err(|_| "Prefetch failed")?.contains(&key) {
202 return Ok(());
203 }
204 (key, replica.page(space)?)
205 }
206 };
207 let (scene, editor) = {
208 let mut engine = layouts.lock().map_err(|_| "Page layout failed")?;
209 PageScene::from_page(page, &mut engine)?
210 };
211 let mut scenes = scenes.lock().map_err(|_| "Prefetch failed")?;
212 scenes.put(
213 key,
214 Kept {
215 scene: (scene, [0.0; 2]),
216 editor: Some(editor),
217 },
218 );
219 scenes.trim(SCENE_BYTES, weigh);
220 Ok(())
221}
222
223impl Session {
224 /// The section's `Library::key`.
225 pub fn key(&self) -> String {
226 self.library.key(&self.tabs[self.tab].path)
227 }
228}
229
230/// How long a page may take to open before its area shows that it is opening, so a page
231/// ready in a frame or two never flashes the placeholder.
232const PATIENCE: std::time::Duration = std::time::Duration::from_millis(80);
233
234/// A page's outline while it opens, in points from the page area's corner: its title, its
235/// date and some lines of text, breathing in `paper`'s ink.
236pub fn skeleton(paper: canvas::gpu::Paper, since: Instant) -> Vec<draw::Primitive<'static>> {
237 let breath = 0.5 - 0.5 * (since.elapsed().as_secs_f32() * std::f32::consts::TAU / 1.6).cos();
238 let [red, green, blue, _] = paper.ink;
239 let color = [red, green, blue, 0.06 + 0.06 * breath];
240 let bar = |x: f32, y: f32, width: f32, height: f32| draw::Primitive::RoundedRect {
241 rect: [x, y, x + width, y + height],
242 radius: [height / 3.0; 2],
243 stroke: None,
244 color,
245 };
246 let mut bars = vec![bar(48.0, 40.0, 280.0, 24.0), bar(48.0, 74.0, 150.0, 10.0)];
247 let lines = [420.0, 380.0, 440.0, 300.0, 0.0, 400.0, 360.0, 220.0];
248 for (line, width) in lines.into_iter().enumerate() {
249 if width > 0.0 {
250 bars.push(bar(64.0, 120.0 + 24.0 * line as f32, width, 11.0));
251 }
252 }
253 bars
254}
255
256impl State {
257 /// The section tab shown open: one being opened, from the moment it is asked for.
258 pub fn open_tab(&self) -> Option<usize> {
259 let session = self.session.as_ref()?;
260 let opening = self.switching.and_then(|(tab, _)| tab);
261 Some(
262 opening
263 .filter(|tab| *tab < session.tabs.len())
264 .unwrap_or(session.tab),
265 )
266 }
267
268 /// Since when the page area shows that a page is opening, once it has taken a while.
269 pub fn loading(&self) -> Option<Instant> {
270 let (_, since) = self.switching?;
271 (since.elapsed() >= PATIENCE).then_some(since)
272 }
273
274 /// Leaves the page shown as it is, dropping whatever was opening in its place.
275 pub fn stop_loading(&mut self) {
276 self.loading += 1;
277 self.opening = None;
278 self.switching = None;
279 }
280
281 /// Readies the pages beside the open one and the sections beside the open section.
282 pub fn prefetch_around(&mut self) {
283 let Some(session) = &self.session else {
284 return;
285 };
286 let at = session
287 .pages
288 .iter()
289 .position(|(space, ..)| *space == session.space);
290 let pages: Vec<ExGuid> = at
291 .into_iter()
292 .flat_map(|at| [at + 1, at.wrapping_sub(1)])
293 .filter_map(|at| session.pages.get(at).map(|(space, ..)| *space))
294 .collect();
295 let tabs: Vec<usize> = [session.tab + 1, session.tab.wrapping_sub(1)]
296 .into_iter()
297 .filter(|tab| *tab < session.tabs.len())
298 .collect();
299 for space in pages {
300 self.prefetch_page(space);
301 }
302 for tab in tabs {
303 self.prefetch_section(tab);
304 }
305 }
306
307 /// Readies page `space` of the open section.
308 pub fn prefetch_page(&mut self, space: ExGuid) {
309 let Some(session) = &self.session else {
310 return;
311 };
312 let key = session.key();
313 if space == session.space || !self.prefetch.ask((key.clone(), Some(space))) {
314 return;
315 }
316 let read = session.reader(space);
317 let _ = self
318 .prefetch
319 .jobs
320 .send(Job::Page((key, space), Box::new(read)));
321 }
322
323 /// Readies section tab `tab` of the open section's folder.
324 pub fn prefetch_section(&mut self, tab: usize) {
325 let Some(session) = &self.session else {
326 return;
327 };
328 let path = session.tabs[tab].path.clone();
329 let key = session.library.key(&path);
330 if tab == session.tab || !self.prefetch.ask((key.clone(), None)) {
331 return;
332 }
333 let _ = self.prefetch.jobs.send(Job::Section {
334 library: Arc::clone(&session.library),
335 path,
336 last: self.last_pages.get(&key).copied(),
337 notify: Box::new(notify(self.proxy.clone())),
338 });
339 }
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345
346 #[test]
347 fn the_least_recent_go_first_past_the_count() {
348 let mut recent = Recent::new(2);
349 assert!(recent.put("a", 1).is_empty());
350 assert!(recent.put("b", 2).is_empty());
351 // Putting again makes it the most recent and gives back what it held.
352 assert_eq!(recent.put("a", 3), vec![1]);
353 assert_eq!(recent.put("c", 4), vec![2]);
354 assert!(!recent.contains(&"b"));
355 assert_eq!(recent.take(&"a"), Some(3));
356 assert_eq!(recent.take(&"a"), None);
357 assert_eq!(
358 recent.values_mut().map(|value| *value).collect::<Vec<_>>(),
359 [4]
360 );
361 }
362
363 #[test]
364 fn the_least_recent_go_first_past_the_budget() {
365 let mut recent = Recent::new(8);
366 for (key, bytes) in [("a", 40), ("b", 30), ("c", 20), ("d", 10)] {
367 recent.put(key, bytes);
368 }
369 // Most recent first: d, c, b fit in 60; a does not.
370 assert_eq!(recent.trim(60, |bytes| *bytes), vec![40]);
371 assert_eq!(recent.trim(30, |bytes| *bytes), vec![30]);
372 assert!(recent.contains(&"c") && recent.contains(&"d"));
373 // One entry over the budget on its own goes too.
374 assert_eq!(recent.trim(5, |bytes| *bytes), vec![10, 20]);
375 }
376}
crates/snowbound/src/print.rs+389-18
...@@ -1,31 +1,319 @@...@@ -1,31 +1,319 @@
1//! File, Print… and Export as PDF…: the open page, or the whole section, laid on paper as1//! File, Print… and Export as PDF…: OneNote 2010's Print Preview and Settings, less the
2//! OneNote 2010 prints it (`canvas::print`), then handed to the system's printing or saved.2//! preview, then the chosen pages laid on paper as OneNote prints them (`canvas::print`),
3//! handed to the system's printing or saved.
34
4use crate::{State, UserEvent, platform, printer};5use crate::{State, UserEvent, library::Library, platform, printer};
5use onestore::page::Page;6use accesskit::Role;
7use canvas::print::Footer;
8use notebook::discover::Folder;
9use onestore::{ExGuid, page::Page};
6use std::{error::Error, path::PathBuf, sync::Arc};10use std::{error::Error, path::PathBuf, sync::Arc};
11use ui::{Anchor, Axis, Id, Spec, Ui, children, fill, popup::Item, px};
12use winit::keyboard::NamedKey;
713
8#[derive(Clone, Copy, PartialEq, Eq)]14#[derive(Clone, Copy, PartialEq, Eq)]
9pub enum Scope {15pub enum Scope {
10 Page,16 Page,
17 /// The page with the subpages of its group.
18 PageGroup,
11 Section,19 Section,
20 /// Every section of the notebook, which only Export as PDF offers, as OneNote's Save As.
21 Notebook,
22}
23
24const SCOPES: [(Scope, &str); 4] = [
25 (Scope::Page, "Current Page"),
26 (Scope::PageGroup, "Page Group"),
27 (Scope::Section, "Current Section"),
28 (Scope::Notebook, "Current Notebook"),
29];
30
31/// Print Preview's paper sizes, upright, in points.
32const PAPERS: [(&str, [f32; 2]); 8] = [
33 ("Letter", canvas::print::LETTER),
34 ("Tabloid", [792.0, 1224.0]),
35 ("Legal", [612.0, 1008.0]),
36 ("Executive", [522.0, 756.0]),
37 ("A3", [841.89, 1190.55]),
38 ("A4", canvas::print::A4),
39 ("B4 (JIS)", [728.5, 1031.81]),
40 ("B5 (JIS)", [515.91, 728.5]),
41];
42
43const FOOTERS: [Footer; 4] = [
44 Footer::SectionAndPage,
45 Footer::Page,
46 Footer::Section,
47 Footer::None,
48];
49
50/// macOS's print panel chooses the paper, and the sheets are fitted to it.
51const PRINT_PAPER: bool = !cfg!(target_os = "macos");
52
53/// What to print and how, as Print Preview and Settings offers it.
54#[derive(Clone, Copy)]
55pub struct Setup {
56 scope: Scope,
57 /// Upright width and height.
58 paper: [f32; 2],
59 landscape: bool,
60 fit_width: bool,
61 footer: Footer,
62}
63
64/// The choices last made, and the dialog's while it is open.
65#[derive(Default)]
66pub struct Printing {
67 last: Option<Setup>,
68 dialog: Option<Dialog>,
69}
70
71struct Dialog {
72 export: bool,
73 setup: Setup,
74}
75
76impl Printing {
77 pub fn open(&self) -> bool {
78 self.dialog.is_some()
79 }
80}
81
82fn id() -> Id {
83 Id::ROOT.child("print")
84}
85
86/// A combo's value and the menu listing `names`, choosing among them.
87fn choose(ui: &mut Ui, name: &str, names: &[&str], current: usize) -> Option<usize> {
88 let menu = id().child(name);
89 let combo = ui.id(name);
90 let shown = names.get(current).copied().unwrap_or("Other");
91 ui::shell::combo(ui, name, name, shown, 180.0, menu, true);
92 let items: Vec<_> = names
93 .iter()
94 .enumerate()
95 .map(|(at, text)| Item {
96 text,
97 checked: Some(at == current),
98 current: at == current,
99 ..Item::default()
100 })
101 .collect();
102 let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default());
103 ui::popup::menu(ui, menu, anchor, &items, None)
104}
105
106/// A row of `label` and the controls `build` adds after it.
107fn field(ui: &mut Ui, label: &str, build: impl FnOnce(&mut Ui)) {
108 let row = ui.theme.font_size * 2.0;
109 ui.open(
110 label,
111 Spec {
112 size: [fill(), px(row)],
113 gap: 8.0,
114 ..Spec::default()
115 },
116 );
117 ui.leaf(
118 "label",
119 Spec {
120 size: [px(110.0), px(row)],
121 text: Some(label),
122 ..Spec::default()
123 },
124 );
125 build(ui);
126 ui.close();
12}127}
13128
14impl State {129impl State {
15 /// Prints `scope` through the system's print dialog, or with `export` saves it as a PDF130 /// Opens Print Preview and Settings for Print…, or with `export` for Export as PDF….
16 /// where the user chooses. Pages are laid out on a thread of their own.131 pub(crate) fn open_print(&mut self, export: bool) {
17 pub(crate) fn print(&mut self, scope: Scope, export: bool) -> Result<(), Box<dyn Error>> {132 let mut setup = self.printing.last.unwrap_or_else(|| Setup {
133 scope: Scope::Page,
134 paper: printer::paper(),
135 landscape: false,
136 fit_width: true,
137 footer: Footer::SectionAndPage,
138 });
139 if !export && setup.scope == Scope::Notebook {
140 setup.scope = Scope::Page;
141 }
142 if !export && !PRINT_PAPER {
143 setup.paper = printer::paper();
144 }
145 self.printing.dialog = Some(Dialog { export, setup });
146 self.ui.open_popup(id());
147 }
148
149 /// Builds the dialog while it is open; Print or Export goes on with its choices, which
150 /// the next opening starts from.
151 pub(crate) fn print_dialog(&mut self) {
152 let Some(dialog) = &mut self.printing.dialog else {
153 return;
154 };
155 let ui = &mut self.ui;
156 if !ui.popup_open(id()) {
157 self.printing.dialog = None;
158 return;
159 }
160 let section = self.session.as_ref().map_or(String::new(), |session| {
161 session.tabs[session.tab].name.clone()
162 });
163 let theme = ui.theme.clone();
164 let entered = ui::popup::navigation(ui, &[], &[NamedKey::Enter]).contains(&NamedKey::Enter);
165 let title = if dialog.export {
166 "Export as PDF"
167 } else {
168 "Print"
169 };
170 ui.open_as(
171 id(),
172 Spec {
173 axis: Axis::Y,
174 size: [px(340.0), children()],
175 fill: Some(theme.popup),
176 border: Some(theme.chip),
177 shadow: Some(theme.shadow),
178 radius: 8.0,
179 pad: [16.0, 12.0],
180 gap: 6.0,
181 anchor: Some(Anchor::Dialog),
182 role: Some(Role::Dialog),
183 ..Spec::default()
184 },
185 );
186 if let Some(node) = ui.access(id()) {
187 node.set_label(title);
188 }
189 ui.leaf(
190 "title",
191 Spec {
192 size: [fill(), px(theme.font_size * 2.0)],
193 text: Some(title),
194 bold: true,
195 role: Some(Role::Heading),
196 ..Spec::default()
197 },
198 );
199 let setup = &mut dialog.setup;
200 let scopes = if dialog.export { 4 } else { 3 };
201 let names: Vec<&str> = SCOPES[..scopes].iter().map(|(_, name)| *name).collect();
202 field(ui, "Print range:", |ui| {
203 let current = SCOPES.iter().position(|(scope, _)| *scope == setup.scope);
204 if let Some(at) = choose(ui, "Print range", &names, current.unwrap_or(0)) {
205 setup.scope = SCOPES[at].0;
206 }
207 });
208 if dialog.export || PRINT_PAPER {
209 let names = PAPERS.map(|(name, _)| name);
210 field(ui, "Paper size:", |ui| {
211 let current = PAPERS.iter().position(|(_, size)| {
212 size.iter()
213 .zip(setup.paper)
214 .all(|(a, b)| (a - b).abs() < 1.0)
215 });
216 if let Some(at) = choose(ui, "Paper size", &names, current.unwrap_or(usize::MAX)) {
217 setup.paper = PAPERS[at].1;
218 }
219 });
220 }
221 field(ui, "Orientation:", |ui| {
222 let names = ["Portrait", "Landscape"];
223 if let Some(at) = choose(ui, "Orientation", &names, usize::from(setup.landscape)) {
224 setup.landscape = at == 1;
225 }
226 });
227 if ui::check_box(
228 ui,
229 "fit-width",
230 "Scale content to paper width",
231 setup.fit_width,
232 )
233 .clicked
234 {
235 setup.fit_width = !setup.fit_width;
236 }
237 let footers = [
238 format!("{section} Page (number)"),
239 "Page (number)".to_owned(),
240 section.clone(),
241 "(none)".to_owned(),
242 ];
243 let names: Vec<&str> = footers.iter().map(String::as_str).collect();
244 field(ui, "Footer:", |ui| {
245 let current = FOOTERS.iter().position(|footer| *footer == setup.footer);
246 if let Some(at) = choose(ui, "Footer", &names, current.unwrap_or(0)) {
247 setup.footer = FOOTERS[at];
248 }
249 });
250 ui.open(
251 "buttons",
252 Spec {
253 size: [fill(), children()],
254 pad: [0.0, 8.0],
255 gap: 8.0,
256 ..Spec::default()
257 },
258 );
259 ui.leaf(
260 "space",
261 Spec {
262 size: [fill(), px(1.0)],
263 ..Spec::default()
264 },
265 );
266 let cancel = ui::button(ui, "cancel", "Cancel").clicked;
267 let action = if dialog.export {
268 "Export…"
269 } else {
270 "Print…"
271 };
272 let go = ui::button(ui, "go", action).clicked || entered;
273 ui.close();
274 ui.close();
275 if !go && !cancel {
276 return;
277 }
278 let Dialog { export, setup } = self.printing.dialog.take().expect("The dialog is open");
279 self.ui.close_popup(id());
280 if go {
281 self.printing.last = Some(setup);
282 if let Err(error) = self.print(setup, export) {
283 platform::alert("Couldn't print", &error.to_string());
284 }
285 }
286 }
287
288 /// Prints `setup`'s pages through the system's print dialog, or with `export` saves
289 /// them as a PDF where the user chooses. Pages are read and laid out on a thread of
290 /// their own.
291 fn print(&mut self, setup: Setup, export: bool) -> Result<(), Box<dyn Error>> {
18 let session = self.session.as_ref().ok_or("No section is open")?;292 let session = self.session.as_ref().ok_or("No section is open")?;
19 let section = session.tabs[session.tab].name.clone();293 let tab = &session.tabs[session.tab];
20 // The open page as the editor holds it; the section's others as stored.294 // The open page as the editor holds it; the others as stored.
21 let open = (session.space, self.view.editor.page()?);295 let open = (session.space, self.view.editor.page()?);
22 let spaces: Vec<_> = match scope {296 let at = session
23 Scope::Page => vec![session.space],297 .pages
24 Scope::Section => session.pages.iter().map(|(space, ..)| *space).collect(),298 .iter()
299 .position(|(space, ..)| *space == session.space);
300 let spaces: Vec<ExGuid> = match (setup.scope, at) {
301 (Scope::PageGroup, Some(at)) => {
302 let levels: Vec<u32> = session.pages.iter().map(|(.., level)| *level).collect();
303 session.pages[group(&levels, at)]
304 .iter()
305 .map(|(space, ..)| *space)
306 .collect()
307 }
308 (Scope::Section | Scope::Notebook, _) => {
309 session.pages.iter().map(|(space, ..)| *space).collect()
310 }
311 _ => vec![session.space],
25 };312 };
26 let title = match scope {313 let title = match setup.scope {
27 Scope::Page => open.1.title.clone(),314 Scope::Page | Scope::PageGroup => open.1.title.clone(),
28 Scope::Section => section.clone(),315 Scope::Section => tab.name.clone(),
316 Scope::Notebook => session.library.name.clone(),
29 };317 };
30 let destination = if export {318 let destination = if export {
31 let name = format!("{}.pdf", file_name(&title));319 let name = format!("{}.pdf", file_name(&title));
...@@ -39,22 +327,62 @@ impl State {...@@ -39,22 +327,62 @@ impl State {
39 } else {327 } else {
40 None328 None
41 };329 };
330 // Other sections are opened on the thread, in the notebook's order.
331 let others = match setup.scope {
332 Scope::Notebook => notebook_tabs(&session.library),
333 _ => Vec::new(),
334 };
335 let (open_tab, library) = (
336 (tab.path.clone(), tab.name.clone()),
337 Arc::clone(&session.library),
338 );
42 let replica = Arc::clone(session.section.replica());339 let replica = Arc::clone(session.section.replica());
43 let layouts = Arc::clone(&self.layouts);340 let layouts = Arc::clone(&self.layouts);
44 let proxy = self.proxy.clone();341 let proxy = self.proxy.clone();
45 let paper = printer::paper();342 let paper = if setup.landscape {
343 [setup.paper[1], setup.paper[0]]
344 } else {
345 setup.paper
346 };
347 let layout = canvas::print::Setup {
348 paper,
349 fit_width: setup.fit_width,
350 footer: setup.footer,
351 };
46 std::thread::spawn(move || {352 std::thread::spawn(move || {
47 let made = (|| -> Result<Vec<u8>, Box<dyn Error>> {353 let made = (|| -> Result<Vec<u8>, Box<dyn Error>> {
48 let mut open = Some(open);354 let mut open = Some(open);
49 let pages = spaces355 let this = spaces
50 .into_iter()356 .into_iter()
51 .map(|space| match open.take_if(|(shown, _)| *shown == space) {357 .map(|space| match open.take_if(|(shown, _)| *shown == space) {
52 Some((_, page)) => Ok(page),358 Some((_, page)) => Ok(page),
53 None => replica.page(space).map_err(Into::into),359 None => replica.page(space).map_err(Into::into),
54 })360 })
55 .collect::<Result<Vec<Page>, Box<dyn Error>>>()?;361 .collect::<Result<Vec<Page>, Box<dyn Error>>>()?;
362 let mut this = Some((open_tab.1.clone(), this));
363 let sections = if others.is_empty() {
364 this.into_iter().collect()
365 } else {
366 others
367 .into_iter()
368 .map(|(path, name)| {
369 if path == open_tab.0
370 && let Some(open) = this.take()
371 {
372 return Ok(open);
373 }
374 let section = library.open(&path, || {})?;
375 let pages = section
376 .pages()?
377 .into_iter()
378 .map(|(space, ..)| section.page(space))
379 .collect::<Result<Vec<_>, _>>()?;
380 Ok((name, pages))
381 })
382 .collect::<Result<Vec<_>, Box<dyn Error>>>()?
383 };
56 let mut engine = layouts.lock().map_err(|_| "Page layout failed")?;384 let mut engine = layouts.lock().map_err(|_| "Page layout failed")?;
57 Ok(canvas::print::pdf(pages, &mut engine, paper, &section)?)385 Ok(canvas::print::pdf(sections, &mut engine, &layout)?)
58 })();386 })();
59 let done = made.and_then(|pdf| match &destination {387 let done = made.and_then(|pdf| match &destination {
60 Some(path) => std::fs::write(path, pdf).map(|()| None).map_err(Into::into),388 Some(path) => std::fs::write(path, pdf).map(|()| None).map_err(Into::into),
...@@ -79,6 +407,40 @@ impl State {...@@ -79,6 +407,40 @@ impl State {
79 }407 }
80}408}
81409
410/// The pages of the group the page at `at` is in, by the pages' outline `levels`: the top
411/// page before it, or itself, and the subpages after that.
412fn group(levels: &[u32], at: usize) -> std::ops::Range<usize> {
413 let top = |level: &u32| *level <= 1;
414 let start = levels[..=at].iter().rposition(top).unwrap_or(at);
415 let end = levels[start + 1..]
416 .iter()
417 .position(top)
418 .map_or(levels.len(), |end| start + 1 + end);
419 start..end
420}
421
422/// Every readable section of `library`, catalog path and name, a folder's sections before
423/// its groups', as OneNote orders them.
424fn notebook_tabs(library: &Library) -> Vec<(String, String)> {
425 fn walk(library: &Library, folder: &Folder, tabs: &mut Vec<(String, String)>) {
426 tabs.extend(
427 library
428 .tabs(&folder.path)
429 .into_iter()
430 .map(|tab| (tab.path, tab.name)),
431 );
432 for group in &folder.groups {
433 walk(library, group, tabs);
434 }
435 }
436 let mut tabs = Vec::new();
437 match library.catalog() {
438 Some(catalog) => walk(library, catalog, &mut tabs),
439 None => tabs.extend(library.tabs("").into_iter().map(|tab| (tab.path, tab.name))),
440 }
441 tabs
442}
443
82/// `title` as a file name: without the characters Windows and macOS refuse in one.444/// `title` as a file name: without the characters Windows and macOS refuse in one.
83fn file_name(title: &str) -> String {445fn file_name(title: &str) -> String {
84 let name: String = title446 let name: String = title
...@@ -115,4 +477,13 @@ mod tests {...@@ -115,4 +477,13 @@ mod tests {
115 assert_eq!(file_name("Notes: 3/4"), "Notes- 3-4");477 assert_eq!(file_name("Notes: 3/4"), "Notes- 3-4");
116 assert_eq!(file_name(" "), "Untitled");478 assert_eq!(file_name(" "), "Untitled");
117 }479 }
480
481 #[test]
482 fn a_page_group_is_its_top_page_and_subpages() {
483 let levels = [1, 2, 3, 1, 1, 2];
484 assert_eq!(group(&levels, 2), 0..3);
485 assert_eq!(group(&levels, 0), 0..3);
486 assert_eq!(group(&levels, 3), 3..4);
487 assert_eq!(group(&levels, 5), 4..6);
488 }
118}489}
crates/snowbound/src/properties.rs created+196
...@@ -0,0 +1,196 @@
1//! Notebook Properties, as OneNote 2010's: the notebook's display name on this computer, its
2//! colour, and where it lives.
3
4use crate::{Command, Library, State, art, commands, manage::Structure, menus::SECTION_COLORS};
5use std::sync::Arc;
6use ui::{Anchor, Axis, Id, Spec, children, fill, popup::Item, px};
7use winit::keyboard::NamedKey;
8
9const WIDTH: f32 = 380.0;
10const TITLE: &str = "Notebook Properties";
11
12/// The dialog's fields until OK keeps them.
13pub struct Properties {
14 library: Arc<Library>,
15 name: String,
16 /// A colour picked, COLORREF.
17 color: Option<u32>,
18}
19
20fn id() -> Id {
21 Id::ROOT.child("notebook-properties")
22}
23
24fn name_field() -> Id {
25 id().child("name")
26}
27
28fn colors() -> Id {
29 id().child("colors")
30}
31
32impl State {
33 /// Opens Notebook Properties on `library`, its display name selected, as OneNote's Rename…
34 /// and Properties… both open it.
35 pub(crate) fn open_properties(&mut self, library: Arc<Library>) {
36 self.properties = Some(Properties {
37 name: library.name.clone(),
38 library,
39 color: None,
40 });
41 self.ui.open_popup(id());
42 self.ui.focus_all(name_field());
43 }
44
45 /// Builds Notebook Properties while it is open. OK, or Enter in the name, keeps a
46 /// non-empty name and a colour picked.
47 pub(crate) fn properties_dialog(&mut self) {
48 let Some(dialog) = &mut self.properties else {
49 return;
50 };
51 let ui = &mut self.ui;
52 if !ui.popup_open(id()) {
53 self.properties = None;
54 return;
55 }
56 let theme = ui.theme.clone();
57 let row = theme.font_size * 2.0;
58 let entered = ui::popup::navigation(ui, &[name_field()], &[NamedKey::Enter])
59 .contains(&NamedKey::Enter);
60 ui.open_as(
61 id(),
62 Spec {
63 axis: Axis::Y,
64 size: [px(WIDTH), children()],
65 fill: Some(theme.popup),
66 border: Some(theme.chip),
67 shadow: Some(theme.shadow),
68 radius: 8.0,
69 pad: [16.0, 12.0],
70 gap: 4.0,
71 anchor: Some(Anchor::Dialog),
72 role: Some(accesskit::Role::Dialog),
73 ..Spec::default()
74 },
75 );
76 if let Some(node) = ui.access(id()) {
77 node.set_label(TITLE);
78 }
79 ui.leaf(
80 "title",
81 Spec {
82 size: [fill(), px(row)],
83 text: Some(TITLE),
84 bold: true,
85 role: Some(accesskit::Role::Heading),
86 ..Spec::default()
87 },
88 );
89 let label = |ui: &mut ui::Ui, text: &str| {
90 ui.leaf(
91 text,
92 Spec {
93 size: [fill(), px(row * 0.8)],
94 text: Some(text),
95 ..Spec::default()
96 },
97 );
98 };
99 let dim = |ui: &mut ui::Ui, key: &str, text: &str| {
100 ui.leaf(
101 key,
102 Spec {
103 flags: ui::Flags::CLIP,
104 size: [fill(), px(row * 0.8)],
105 text: Some(text),
106 color: Some(theme.text_dim),
107 ..Spec::default()
108 },
109 );
110 };
111 label(ui, "Display name:");
112 ui::text_field(
113 ui,
114 name_field(),
115 &mut dialog.name,
116 "",
117 Spec {
118 size: [fill(), px(row)],
119 fill: Some(theme.base),
120 border: Some(theme.accent),
121 radius: 4.0,
122 pad: [6.0, 0.0],
123 ..Spec::default()
124 },
125 );
126 if let Some(node) = ui.access(name_field()) {
127 node.set_label("Display name");
128 }
129 dim(ui, "hint", "Doesn’t change the notebook’s folder name");
130 label(ui, "Color:");
131 let shown = dialog.color.or(dialog.library.color());
132 let named = SECTION_COLORS
133 .iter()
134 .find(|(color, _)| Some(*color) == shown)
135 .map_or("", |(_, name)| name);
136 let combo = ui.id("combo");
137 ui::shell::combo(ui, "combo", "Color", named, 160.0, colors(), true);
138 let items: Vec<Item> = SECTION_COLORS
139 .iter()
140 .map(|&(color, text)| Item {
141 text,
142 icon: Some(art::SECTION),
143 tint: Some(theme.section(crate::section_color(Some(color))).accent),
144 current: Some(color) == shown,
145 ..Item::default()
146 })
147 .collect();
148 let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default());
149 if let Some(index) = ui::popup::menu(ui, colors(), anchor, &items, None) {
150 dialog.color = Some(SECTION_COLORS[index].0);
151 }
152 label(ui, "Location:");
153 dim(ui, "location", &dialog.library.location);
154 let folder = dialog.library.folder().map(std::path::Path::to_owned);
155 let reveal = commands::command(commands::Id::ShowNotebook).title;
156 if folder.is_some() && ui::button(ui, "reveal", reveal).clicked {
157 crate::platform::reveal(&dialog.library.location);
158 }
159 ui.open(
160 "buttons",
161 Spec {
162 size: [fill(), children()],
163 pad: [0.0, 8.0],
164 gap: 8.0,
165 ..Spec::default()
166 },
167 );
168 ui.leaf(
169 "space",
170 Spec {
171 size: [fill(), px(1.0)],
172 ..Spec::default()
173 },
174 );
175 let ok = ui::button(ui, "ok", "OK").clicked || entered;
176 let cancel = ui::button(ui, "cancel", "Cancel").clicked;
177 ui.close();
178 ui.close();
179 let name = dialog.name.trim();
180 if ok && !name.is_empty() {
181 if name != dialog.library.name || dialog.color.is_some() {
182 self.commands.push(Command::Structure(
183 Arc::clone(&dialog.library),
184 Structure::Properties {
185 name: name.to_owned(),
186 color: dialog.color,
187 },
188 ));
189 }
190 } else if !cancel {
191 return;
192 }
193 self.ui.close_popup(id());
194 self.properties = None;
195 }
196}
crates/snowbound/src/recording.rs+2
...@@ -204,6 +204,7 @@ impl State {...@@ -204,6 +204,7 @@ impl State {
204 kind: if video { 2 } else { 1 },204 kind: if video { 2 } else { 1 },
205 duration_ms,205 duration_ms,
206 }),206 }),
207 tags: Vec::new(),
207 };208 };
208 let response = self.view.finish_recording(file)?;209 let response = self.view.finish_recording(file)?;
209 self.respond(response);210 self.respond(response);
...@@ -983,6 +984,7 @@ pub(crate) mod tests {...@@ -983,6 +984,7 @@ pub(crate) mod tests {
983 kind,984 kind,
984 duration_ms: Some(duration),985 duration_ms: Some(duration),
985 }),986 }),
987 tags: Vec::new(),
986 };988 };
987 editor.finish_recording(&mut engine, file).unwrap();989 editor.finish_recording(&mut engine, file).unwrap();
988 store(&mut editor);990 store(&mut editor);
crates/snowbound/src/rename.rs+34-18
...@@ -6,7 +6,7 @@ use crate::{Command, Library, State, manage::Structure};...@@ -6,7 +6,7 @@ use crate::{Command, Library, State, manage::Structure};
6use onestore::{6use onestore::{
7 ExGuid,7 ExGuid,
8 op::{Edit, Op, PageOp},8 op::{Edit, Op, PageOp},
9 page::PageObject,9 page::{Page, PageObject},
10};10};
11use std::{error::Error, sync::Arc};11use std::{error::Error, sync::Arc};
12use ui::{Event, Id, Spec, Theme, Ui, fill, px};12use ui::{Event, Id, Spec, Theme, Ui, fill, px};
...@@ -170,32 +170,48 @@ impl State {...@@ -170,32 +170,48 @@ impl State {
170 fn retitle(&mut self, space: ExGuid, name: String) -> Result<(), Box<dyn Error>> {170 fn retitle(&mut self, space: ExGuid, name: String) -> Result<(), Box<dyn Error>> {
171 self.persist()?;171 self.persist()?;
172 let session = self.session.as_mut().ok_or("No section is open")?;172 let session = self.session.as_mut().ok_or("No section is open")?;
173 let page = session.section.page(space)?;173 let op = retitled(&session.section.page(space)?, space, name.clone())?;
174 let title = page174 let old = session
175 .objects175 .pages
176 .iter()176 .iter()
177 .find_map(|object| match object {177 .find(|(page, ..)| *page == space)
178 PageObject::Title(title) => title.outlines.first()?.paragraphs.first()?.text(),178 .map(|(_, title, _)| title.clone())
179 _ => None,179 .unwrap_or_default();
180 })
181 .ok_or("The page has no title to rename")?;
182 let length = title.text.utf16_offset(title.text.text().len())?;
183 session.section.apply(180 session.section.apply(
184 &self.author,181 &self.author,
185 Edit {182 Edit {
186 at: crate::filetime(),183 at: crate::filetime(),
187 ops: vec![Op::Page {184 ops: vec![op],
188 space,
189 op: PageOp::Text {
190 text: title.id,
191 range: 0..length,
192 with: name,
193 },
194 }],
195 },185 },
196 )?;186 )?;
197 session.pages = session.section.pages()?;187 session.pages = session.section.pages()?;
188 self.made(crate::undo::Change::Retitle {
189 page: space,
190 from: name,
191 to: old,
192 });
198 self.edited(vec![space]);193 self.edited(vec![space]);
199 self.refresh()194 self.refresh()
200 }195 }
201}196}
197
198/// The op titling `page`, in `space`, `name`.
199pub fn retitled(page: &Page, space: ExGuid, name: String) -> Result<Op, Box<dyn Error>> {
200 let title = page
201 .objects
202 .iter()
203 .find_map(|object| match object {
204 PageObject::Title(title) => title.outlines.first()?.paragraphs.first()?.text(),
205 _ => None,
206 })
207 .ok_or("The page has no title to rename")?;
208 let length = title.text.utf16_offset(title.text.text().len())?;
209 Ok(Op::Page {
210 space,
211 op: PageOp::Text {
212 text: title.id,
213 range: 0..length,
214 with: name,
215 },
216 })
217}
crates/snowbound/src/server.rs+123-2
...@@ -343,6 +343,76 @@ fn id() -> Id {...@@ -343,6 +343,76 @@ fn id() -> Id {
343 Id::ROOT.child("server")343 Id::ROOT.child("server")
344}344}
345345
346/// The address a sign-in to `mount` is saved by: its server and account, without share or
347/// folder, and never a password.
348fn saved_address(mount: &Mount) -> String {
349 Mount {
350 share: String::new(),
351 root: String::new(),
352 ..mount.clone()
353 }
354 .url()
355}
356
357/// The saved servers under "Recent Servers", each a button that reconnects to it and one that
358/// removes it; returns the one picked and whether to remove it.
359pub(crate) fn saved_servers(ui: &mut Ui, servers: &[String]) -> Option<(usize, bool)> {
360 if servers.is_empty() {
361 return None;
362 }
363 let theme = ui.theme.clone();
364 let row = theme.font_size * 2.0;
365 ui.leaf(
366 "recent",
367 Spec {
368 size: [fill(), px(row)],
369 text: Some("Recent Servers"),
370 color: Some(theme.text_dim),
371 role: Some(Role::Heading),
372 ..Spec::default()
373 },
374 );
375 let mut picked = None;
376 for (index, address) in servers.iter().enumerate() {
377 ui.open(
378 ("saved", address),
379 Spec {
380 size: [fill(), px(row)],
381 gap: 4.0,
382 ..Spec::default()
383 },
384 );
385 let label = address.strip_prefix("smb://").unwrap_or(address);
386 if ui
387 .leaf(
388 "connect",
389 Spec {
390 flags: Flags::CLICKABLE | Flags::CLIP,
391 size: [fill(), px(row)],
392 icon: Some(art::SERVER),
393 text: Some(label),
394 hover_fill: Some(theme.hover()),
395 radius: 4.0,
396 pad: [8.0, 0.0],
397 gap: 6.0,
398 role: Some(Role::Button),
399 ..Spec::default()
400 },
401 )
402 .clicked
403 {
404 picked = Some((index, false));
405 }
406 let remove = ui.id("remove");
407 if ui::shell::tool_button(ui, "remove", art::CLOSE, theme.text_dim, None).clicked {
408 picked = Some((index, true));
409 }
410 name(ui, remove, &format!("Remove {label}"));
411 ui.close();
412 }
413 picked
414}
415
346fn address_field() -> Id {416fn address_field() -> Id {
347 id().child("address")417 id().child("address")
348}418}
...@@ -409,6 +479,33 @@ impl State {...@@ -409,6 +479,33 @@ impl State {
409 self.ui.focus_all(focus);479 self.ui.focus_all(focus);
410 }480 }
411481
482 /// Opens the dialog at saved server `address`, signing in with any password the keychain
483 /// keeps, to browse its shares.
484 pub(crate) fn reconnect(&mut self, address: &str) {
485 self.open_server(Some(address));
486 if let Some(connect) = &mut self.server {
487 connect.reopen = false;
488 }
489 }
490
491 /// Saves `address` first among the recent servers.
492 fn remember_server(&mut self, address: String) {
493 self.servers.retain(|saved| *saved != address);
494 self.servers.insert(0, address);
495 self.save_settings();
496 }
497
498 /// Picks from, or removes from, the recent servers `saved_servers` lists.
499 pub(crate) fn saved_server(&mut self, (index, remove): (usize, bool)) {
500 if remove {
501 self.servers.remove(index);
502 self.save_settings();
503 } else {
504 let address = self.servers[index].clone();
505 self.reconnect(&address);
506 }
507 }
508
412 /// Runs `request` off the frame, which wakes when it is answered.509 /// Runs `request` off the frame, which wakes when it is answered.
413 fn ask(&mut self, request: Request) {510 fn ask(&mut self, request: Request) {
414 let Some(connect) = &self.server else {511 let Some(connect) = &self.server else {
...@@ -452,9 +549,13 @@ impl State {...@@ -452,9 +549,13 @@ impl State {
452 return;549 return;
453 }550 }
454 let replies: Vec<Reply> = connect.replies.1.try_iter().collect();551 let replies: Vec<Reply> = connect.replies.1.try_iter().collect();
552 let mut signed_in = None;
455 for reply in replies {553 for reply in replies {
456 if connect.receive(reply)554 if !connect.receive(reply) {
457 && connect.remember == Some(true)555 continue;
556 }
557 signed_in = connect.mount.as_ref().map(saved_address);
558 if connect.remember == Some(true)
458 && !connect.guest559 && !connect.guest
459 && let (Some(mount), login) = (&connect.mount, connect.login())560 && let (Some(mount), login) = (&connect.mount, connect.login())
460 && let Err(error) = platform::save_login(mount, &login)561 && let Err(error) = platform::save_login(mount, &login)
...@@ -462,6 +563,12 @@ impl State {...@@ -462,6 +563,12 @@ impl State {
462 eprintln!("Keeping the password for {}: {error}", mount.host());563 eprintln!("Keeping the password for {}: {error}", mount.host());
463 }564 }
464 }565 }
566 if let Some(address) = signed_in {
567 self.remember_server(address);
568 }
569 let Some(connect) = &mut self.server else {
570 return;
571 };
465 if connect.reopen572 if connect.reopen
466 && let Some((mount, login)) = connect.opens()573 && let Some((mount, login)) = connect.opens()
467 {574 {
...@@ -529,6 +636,7 @@ impl State {...@@ -529,6 +636,7 @@ impl State {
529 };636 };
530 let mut chosen = None;637 let mut chosen = None;
531 let mut back = false;638 let mut back = false;
639 let mut saved = None;
532 match connect.step {640 match connect.step {
533 Step::Address => {641 Step::Address => {
534 labelled(ui, "Server Address:", |ui| {642 labelled(ui, "Server Address:", |ui| {
...@@ -541,6 +649,7 @@ impl State {...@@ -541,6 +649,7 @@ impl State {
541 );649 );
542 name(ui, address_field(), "Server Address");650 name(ui, address_field(), "Server Address");
543 });651 });
652 saved = saved_servers(ui, &self.servers);
544 }653 }
545 Step::SignIn => {654 Step::SignIn => {
546 if ui::check_box(ui, "guest", "Connect as guest", connect.guest).clicked {655 if ui::check_box(ui, "guest", "Connect as guest", connect.guest).clicked {
...@@ -744,6 +853,9 @@ impl State {...@@ -744,6 +853,9 @@ impl State {
744 self.server = None;853 self.server = None;
745 return;854 return;
746 }855 }
856 if let Some(saved) = saved {
857 return self.saved_server(saved);
858 }
747 let connect = self.server.as_mut().expect("the dialog is open");859 let connect = self.server.as_mut().expect("the dialog is open");
748 let request = if let Some(index) = chosen {860 let request = if let Some(index) = chosen {
749 connect.enter(index)861 connect.enter(index)
...@@ -1032,6 +1144,15 @@ mod tests {...@@ -1032,6 +1144,15 @@ mod tests {
1032 assert_eq!(connect.listing.folders, ["Notes", "public"]);1144 assert_eq!(connect.listing.folders, ["Notes", "public"]);
1033 }1145 }
10341146
1147 #[test]
1148 fn a_saved_server_keeps_its_account_but_not_its_share_or_password() {
1149 let mount =
1150 Mount::from_address("smb://WORK;clover:secret@nas:1445/Notes/My Garden").unwrap();
1151 assert_eq!(saved_address(&mount), "smb://WORK;clover@nas:1445");
1152 let guest = Mount::from_address("nas/public").unwrap();
1153 assert_eq!(saved_address(&guest), "smb://nas");
1154 }
1155
1035 #[test]1156 #[test]
1036 fn guests_sign_in_without_an_account() {1157 fn guests_sign_in_without_an_account() {
1037 let mut connect = Connect::new("nas/Notes".into(), None);1158 let mut connect = Connect::new("nas/Notes".into(), None);
crates/snowbound/src/settings.rs+19-21
...@@ -36,7 +36,7 @@ pub struct Settings {...@@ -36,7 +36,7 @@ pub struct Settings {
36 /// Places and moves things where they are dropped, as OneNote's Snap To Grid off.36 /// Places and moves things where they are dropped, as OneNote's Snap To Grid off.
37 pub ignore_grid: bool,37 pub ignore_grid: bool,
38 /// Options' Default font: new text's font and size, and new titles' font.38 /// Options' Default font: new text's font and size, and new titles' font.
39 pub default_font: DefaultFont,39 pub default_font: canvas::editor::DefaultFont,
40 /// OneNote's "Page tabs appear on the left".40 /// OneNote's "Page tabs appear on the left".
41 pub page_tabs_left: bool,41 pub page_tabs_left: bool,
42 /// OneNote's "Navigation bar appears on the left" turned off: the notebooks on the right.42 /// OneNote's "Navigation bar appears on the left" turned off: the notebooks on the right.
...@@ -44,24 +44,10 @@ pub struct Settings {...@@ -44,24 +44,10 @@ pub struct Settings {
44 /// Servers Open Notebook from Server signed in to, latest first, as addresses without a44 /// Servers Open Notebook from Server signed in to, latest first, as addresses without a
45 /// password.45 /// password.
46 pub servers: Vec<String>,46 pub servers: Vec<String>,
47}47 /// Chords set in Options' Keyboard, by command, each list in place of its defaults.
4848 pub keys: std::collections::BTreeMap<String, Vec<String>>,
49/// The font and size OneNote's Default font gives new text.49 /// Pages shown lately, latest first, which the palette lists first.
50#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]50 pub recent: Vec<crate::navigation::Place>,
51#[serde(default)]
52pub struct DefaultFont {
53 pub face: String,
54 pub size: f32,
55}
56
57impl Default for DefaultFont {
58 /// OneNote 2010's: Calibri 11.
59 fn default() -> Self {
60 Self {
61 face: "Calibri".into(),
62 size: 11.0,
63 }
64 }
65}51}
6652
67/// What the toolbar's buttons apply from their menus' last picks.53/// What the toolbar's buttons apply from their menus' last picks.
...@@ -174,10 +160,12 @@ impl crate::State {...@@ -174,10 +160,12 @@ impl crate::State {
174 manual_updates: !self.updates.automatic(),160 manual_updates: !self.updates.automatic(),
175 ignore_pen_pressure: !self.pen_pressure,161 ignore_pen_pressure: !self.pen_pressure,
176 ignore_grid: !self.view.snap_to_grid,162 ignore_grid: !self.view.snap_to_grid,
177 default_font: self.default_font.clone(),163 default_font: self.view.editor.default_font.clone(),
178 page_tabs_left: self.page_tabs_left,164 page_tabs_left: self.page_tabs_left,
179 navigation_bar_right: self.navigation_bar_right,165 navigation_bar_right: self.navigation_bar_right,
180 servers: self.servers.clone(),166 servers: self.servers.clone(),
167 keys: crate::commands::Keymap::current().saved(),
168 recent: self.trail.recent.clone(),
181 };169 };
182 if let Err(error) = settings.save(path) {170 if let Err(error) = settings.save(path) {
183 eprintln!("Cannot save the settings in {}: {error}", path.display());171 eprintln!("Cannot save the settings in {}: {error}", path.display());
...@@ -239,13 +227,23 @@ mod tests {...@@ -239,13 +227,23 @@ mod tests {
239 manual_updates: true,227 manual_updates: true,
240 ignore_pen_pressure: true,228 ignore_pen_pressure: true,
241 ignore_grid: true,229 ignore_grid: true,
242 default_font: DefaultFont {230 default_font: canvas::editor::DefaultFont {
243 face: "Georgia".into(),231 face: "Georgia".into(),
244 size: 14.0,232 size: 14.0,
233 color: Some(0xc07000),
245 },234 },
246 page_tabs_left: true,235 page_tabs_left: true,
247 navigation_bar_right: true,236 navigation_bar_right: true,
248 servers: vec!["smb://clover@nas/Notes".into()],237 servers: vec!["smb://clover@nas/Notes".into()],
238 keys: [("Sidebar".into(), vec!["Command+Shift+L".into()])].into(),
239 recent: vec![crate::navigation::Place {
240 notebook: "/notebooks/Personal".into(),
241 section: "To Do.one".into(),
242 page: onestore::ExGuid {
243 guid: [7; 16],
244 n: 1,
245 },
246 }],
249 };247 };
250 settings.save(&path).unwrap();248 settings.save(&path).unwrap();
251 assert_eq!(Settings::load(&path), settings);249 assert_eq!(Settings::load(&path), settings);
crates/snowbound/src/sidebar.rs+101-37
...@@ -31,6 +31,7 @@ pub enum Action {...@@ -31,6 +31,7 @@ pub enum Action {
31 /// Folds or unfolds a notebook's or section group's rows, by `fold_key`.31 /// Folds or unfolds a notebook's or section group's rows, by `fold_key`.
32 Fold(String),32 Fold(String),
33 NewNotebook,33 NewNotebook,
34 NewICloudNotebook,
34 OpenNotebook,35 OpenNotebook,
35 /// Signs in again to the notebook opened from its server at this location.36 /// Signs in again to the notebook opened from its server at this location.
36 SignIn(String),37 SignIn(String),
...@@ -137,7 +138,8 @@ pub fn header() -> Id {...@@ -137,7 +138,8 @@ pub fn header() -> Id {
137}138}
138139
139/// The sidebar's header row, `header` tall and dragging the window when `drags`, then140/// The sidebar's header row, `header` tall and dragging the window when `drags`, then
140/// with `rows` the tree of `notebooks` with the open section marked.141/// with `rows` the tree of `notebooks` with the open section marked. The notebook button
142/// floats over the header's end at the window's edge, the `right` one or the left.
141fn sidebar(143fn sidebar(
142 ui: &mut Ui,144 ui: &mut Ui,
143 tree: &mut Tree,145 tree: &mut Tree,
...@@ -145,6 +147,7 @@ fn sidebar(...@@ -145,6 +147,7 @@ fn sidebar(
145 header: f32,147 header: f32,
146 drags: bool,148 drags: bool,
147 rows: bool,149 rows: bool,
150 right: bool,
148) {151) {
149 let (theme, folded) = (tree.theme, tree.folded);152 let (theme, folded) = (tree.theme, tree.folded);
150 ui.open_as(153 ui.open_as(
...@@ -162,13 +165,18 @@ fn sidebar(...@@ -162,13 +165,18 @@ fn sidebar(
162 },165 },
163 );166 );
164 // The notebook button's square, which floats over this place.167 // The notebook button's square, which floats over this place.
165 ui.leaf(168 let toggle = |ui: &mut Ui| {
166 "toggle",169 ui.leaf(
167 Spec {170 "toggle",
168 size: [px(RAIL - crate::FRAME), px(ui::shell::TOOL)],171 Spec {
169 ..Spec::default()172 size: [px(RAIL - crate::FRAME), px(ui::shell::TOOL)],
170 },173 ..Spec::default()
171 );174 },
175 );
176 };
177 if !right {
178 toggle(ui);
179 }
172 ui.leaf(180 ui.leaf(
173 "title",181 "title",
174 Spec {182 Spec {
...@@ -178,6 +186,9 @@ fn sidebar(...@@ -178,6 +186,9 @@ fn sidebar(
178 ..Spec::default()186 ..Spec::default()
179 },187 },
180 );188 );
189 if right {
190 toggle(ui);
191 }
181 ui.close();192 ui.close();
182 if !rows {193 if !rows {
183 return;194 return;
...@@ -242,12 +253,21 @@ fn sidebar(...@@ -242,12 +253,21 @@ fn sidebar(
242 },253 },
243 );254 );
244 let options = ("options", art::OPTIONS, "Options", Action::Options);255 let options = ("options", art::OPTIONS, "Options", Action::Options);
245 for (part, icon, label, chosen) in [256 let icloud = crate::icloud::folder().map(|_| {
246 ("new", art::PLUS, "New Notebook", Action::NewNotebook),257 (
247 ("open", art::NOTEBOOK, "Open Existing", Action::OpenNotebook),258 "new icloud",
248 ]259 art::ICLOUD,
249 .into_iter()260 "New iCloud Notebook",
250 .chain((!cfg!(target_os = "macos")).then_some(options))261 Action::NewICloudNotebook,
262 )
263 });
264 for (part, icon, label, chosen) in icloud
265 .into_iter()
266 .chain([
267 ("new", art::PLUS, "New Notebook", Action::NewNotebook),
268 ("open", art::NOTEBOOK, "Open Existing", Action::OpenNotebook),
269 ])
270 .chain((!cfg!(target_os = "macos")).then_some(options))
251 {271 {
252 let (row, _) = tree_row(272 let (row, _) = tree_row(
253 ui,273 ui,
...@@ -395,10 +415,10 @@ fn folder(...@@ -395,10 +415,10 @@ fn folder(
395 ui.id(("unavailable", notebook, &entry.path)),415 ui.id(("unavailable", notebook, &entry.path)),
396 Row {416 Row {
397 label: &name,417 label: &name,
398 icon: if entry.group {418 icon: match entry.reason {
399 Leading::Icon(art::SECTION_GROUP)419 _ if entry.group => Leading::Icon(art::SECTION_GROUP),
400 } else {420 Reason::Evicted => Leading::Icon(art::ICLOUD),
401 Leading::Section(section_color(None))421 _ => Leading::Section(section_color(None)),
402 },422 },
403 depth,423 depth,
404 dim: true,424 dim: true,
...@@ -413,6 +433,10 @@ fn folder(...@@ -413,6 +433,10 @@ fn folder(
413 format!("Can't read \u{201c}{name}\u{201d}"),433 format!("Can't read \u{201c}{name}\u{201d}"),
414 format!("{}\n\nIt appears here once you have access.", entry.error),434 format!("{}\n\nIt appears here once you have access.", entry.error),
415 ),435 ),
436 Reason::Evicted => Action::Unavailable(
437 format!("Downloading \u{201c}{name}\u{201d}"),
438 "It opens once iCloud Drive brings it to this computer.".into(),
439 ),
416 _ => Action::Unavailable(440 _ => Action::Unavailable(
417 format!("Can't open \u{201c}{name}\u{201d}"),441 format!("Can't open \u{201c}{name}\u{201d}"),
418 format!("{}.", entry.error),442 format!("{}.", entry.error),
...@@ -663,19 +687,27 @@ fn tree_row(ui: &mut Ui, tree: &mut Tree, id: Id, row: Row) -> (Signal, bool) {...@@ -663,19 +687,27 @@ fn tree_row(ui: &mut Ui, tree: &mut Tree, id: Id, row: Row) -> (Signal, bool) {
663}687}
664688
665impl crate::State {689impl crate::State {
666 /// The sidebar left of the section tabs, easing open and shut; returns its width.690 /// How wide the sidebar stands this frame as it eases open and shut.
667 pub(crate) fn sidebar(&mut self, theme: &Theme) -> f32 {691 pub(crate) fn sidebar_width(&mut self) -> f32 {
668 if self.temporary {692 if self.temporary {
669 return 0.0;693 return 0.0;
670 }694 }
671 let width = self.ui.animate(695 self.ui.animate(
672 self.ui.id("sidebar"),696 self.ui.id("sidebar"),
673 if self.sidebar && !self.full_page {697 if self.sidebar && !self.full_page {
674 WIDTH698 WIDTH
675 } else {699 } else {
676 0.0700 0.0
677 },701 },
678 );702 )
703 }
704
705 /// The sidebar `width` wide beside the section tabs, on the left, or the right as
706 /// OneNote's "Navigation bar appears on the left" turned off puts it.
707 pub(crate) fn sidebar(&mut self, theme: &Theme, width: f32) {
708 if self.temporary {
709 return;
710 }
679 self.ui.open(711 self.ui.open(
680 "sidebar",712 "sidebar",
681 Spec {713 Spec {
...@@ -721,6 +753,7 @@ impl crate::State {...@@ -721,6 +753,7 @@ impl crate::State {
721 crate::TAB_ROW,753 crate::TAB_ROW,
722 drags,754 drags,
723 width > 0.5,755 width > 0.5,
756 self.navigation_bar_right,
724 );757 );
725 let Tree {758 let Tree {
726 action,759 action,
...@@ -754,6 +787,9 @@ impl crate::State {...@@ -754,6 +787,9 @@ impl crate::State {
754 }787 }
755 Some(Action::Renamed(keep)) => self.finish_renaming(keep),788 Some(Action::Renamed(keep)) => self.finish_renaming(keep),
756 Some(Action::NewNotebook) => self.commands.push(crate::Command::NewNotebook),789 Some(Action::NewNotebook) => self.commands.push(crate::Command::NewNotebook),
790 Some(Action::NewICloudNotebook) => {
791 self.commands.push(crate::Command::NewICloudNotebook)
792 }
757 Some(Action::OpenNotebook) => self.commands.push(crate::Command::OpenNotebook),793 Some(Action::OpenNotebook) => self.commands.push(crate::Command::OpenNotebook),
758 Some(Action::SignIn(location)) => self794 Some(Action::SignIn(location)) => self
759 .commands795 .commands
...@@ -762,7 +798,6 @@ impl crate::State {...@@ -762,7 +798,6 @@ impl crate::State {
762 Some(Action::Unavailable(title, message)) => crate::platform::alert(&title, &message),798 Some(Action::Unavailable(title, message)) => crate::platform::alert(&title, &message),
763 Some(Action::Open { .. }) | None => {}799 Some(Action::Open { .. }) | None => {}
764 }800 }
765 width
766 }801 }
767802
768 /// The sidebar's button in its square at the window's edge, and the room the section803 /// The sidebar's button in its square at the window's edge, and the room the section
...@@ -774,11 +809,20 @@ impl crate::State {...@@ -774,11 +809,20 @@ impl crate::State {
774 if self.temporary {809 if self.temporary {
775 return;810 return;
776 }811 }
812 // On the right it stands at the body's far edge, as laid out last frame.
813 let x = match self.navigation_bar_right {
814 true => self
815 .ui
816 .rect(self.ui.current())
817 .map_or(0.0, |[left, _, right, _]| right - left - RAIL),
818 false => 0.0,
819 };
777 self.ui.open(820 self.ui.open(
778 "toggle",821 "toggle",
779 Spec {822 Spec {
780 flags: Flags::FLOAT | Flags::CLIP,823 flags: Flags::FLOAT | Flags::CLIP,
781 size: [px(RAIL), px(height)],824 size: [px(RAIL), px(height)],
825 position: [x, 0.0],
782 pad: [826 pad: [
783 (RAIL - ui::shell::TOOL) / 2.0,827 (RAIL - ui::shell::TOOL) / 2.0,
784 (RAIL - ui::shell::TOOL) / 2.0 + DROP,828 (RAIL - ui::shell::TOOL) / 2.0 + DROP,
...@@ -1058,15 +1102,25 @@ impl crate::State {...@@ -1058,15 +1102,25 @@ impl crate::State {
1058 self.load(move || {1102 self.load(move || {
1059 let library = match open {1103 let library = match open {
1060 Some(library) => library,1104 Some(library) => library,
1061 None => Arc::new(read(&location, &cache)?),1105 None => {
1106 let library = Arc::new(read(&location, &cache)?);
1107 library.purge_recycle_bin();
1108 library
1109 }
1062 };1110 };
1063 if let Err(error) = &library.notebook {1111 if let Err(error) = &library.notebook {
1064 return Err(error.clone().into());1112 return Err(error.clone().into());
1065 }1113 }
1066 let path = section1114 let Some(path) = section
1067 .filter(|path| library.contains(path))1115 .filter(|path| library.contains(path))
1068 .or_else(|| library.first_section())1116 .or_else(|| library.first_section())
1069 .ok_or("This folder holds no notebook sections.")?;1117 else {
1118 // Shown once its sections arrive.
1119 if library.downloading() {
1120 return Ok(crate::Loaded::Library(library, None));
1121 }
1122 return Err("This folder holds no notebook sections.".into());
1123 };
1070 let section = library.open(&path, notify)?;1124 let section = library.open(&path, notify)?;
1071 let (session, page) = crate::read_session(section, library, path, None)?;1125 let (session, page) = crate::read_session(section, library, path, None)?;
1072 Ok(crate::Loaded::Section(Box::new(session), page))1126 Ok(crate::Loaded::Section(Box::new(session), page))
...@@ -1086,18 +1140,20 @@ impl crate::State {...@@ -1086,18 +1140,20 @@ impl crate::State {
1086 ));1140 ));
1087 #[cfg(not(target_os = "linux"))]1141 #[cfg(not(target_os = "linux"))]
1088 let install = None;1142 let install = None;
1143 let servers = self.servers.clone();
1089 self.notice(1144 self.notice(
1090 theme,1145 theme,
1091 id,1146 id,
1092 theme.base,1147 theme.base,
1093 "No notebooks open",1148 "No notebooks open",
1149 &servers,
1094 crate::icloud::folder()1150 crate::icloud::folder()
1095 .map(|_| {1151 .map(|_| {
1096 (1152 (
1097 "icloud",1153 "icloud",
1098 art::ICLOUD,1154 art::ICLOUD,
1099 "Use iCloud Drive",1155 "New iCloud Notebook",
1100 crate::Command::UseICloud,1156 crate::Command::NewICloudNotebook,
1101 )1157 )
1102 })1158 })
1103 .into_iter()1159 .into_iter()
...@@ -1133,8 +1189,9 @@ impl crate::State {...@@ -1133,8 +1189,9 @@ impl crate::State {
1133 }1189 }
11341190
1135 /// The page's place while notebook `library` has no sections, as OneNote 2010 shows1191 /// The page's place while notebook `library` has no sections, as OneNote 2010 shows
1136 /// it, with a button to add one.1192 /// it, with a button to add one; or while none is on this computer yet.
1137 pub(crate) fn no_sections(&mut self, theme: &Theme, library: Arc<Library>) {1193 pub(crate) fn no_sections(&mut self, theme: &Theme, library: Arc<Library>) {
1194 let downloading = library.downloading();
1138 let new = crate::Command::Structure(1195 let new = crate::Command::Structure(
1139 library,1196 library,
1140 crate::manage::Structure::NewSection {1197 crate::manage::Structure::NewSection {
...@@ -1142,23 +1199,26 @@ impl crate::State {...@@ -1142,23 +1199,26 @@ impl crate::State {
1142 },1199 },
1143 );1200 );
1144 let id = self.ui.id("no sections");1201 let id = self.ui.id("no sections");
1145 self.notice(1202 let (title, buttons) = if downloading {
1146 theme,1203 ("Downloading from iCloud Drive…", Vec::new())
1147 id,1204 } else {
1148 theme.strip,1205 (
1149 "No sections in this notebook",1206 "No sections in this notebook",
1150 vec![("new", art::PLUS, "New Section", new)],1207 vec![("new", art::PLUS, "New Section", new)],
1151 );1208 )
1209 };
1210 self.notice(theme, id, theme.strip, title, &[], buttons);
1152 }1211 }
11531212
1154 /// A box `id` filled with `background`, showing `title` above `buttons` in its middle: each1213 /// A box `id` filled with `background`, showing `title` above `buttons` in its middle: each
1155 /// a part, an icon, a label and what it does.1214 /// a part, an icon, a label and what it does; then the saved `servers` to reconnect to.
1156 fn notice(1215 fn notice(
1157 &mut self,1216 &mut self,
1158 theme: &Theme,1217 theme: &Theme,
1159 id: ui::Id,1218 id: ui::Id,
1160 background: [f32; 4],1219 background: [f32; 4],
1161 title: &str,1220 title: &str,
1221 servers: &[String],
1162 buttons: Vec<(&str, &'static [&'static str], &str, crate::Command)>,1222 buttons: Vec<(&str, &'static [&'static str], &str, crate::Command)>,
1163 ) {1223 ) {
1164 const BUTTON: [f32; 2] = [240.0, 32.0];1224 const BUTTON: [f32; 2] = [240.0, 32.0];
...@@ -1217,8 +1277,12 @@ impl crate::State {...@@ -1217,8 +1277,12 @@ impl crate::State {
1217 }1277 }
1218 }1278 }
1219 self.commands.extend(chosen);1279 self.commands.extend(chosen);
1280 let saved = crate::server::saved_servers(&mut self.ui, servers);
1220 self.ui.close();1281 self.ui.close();
1221 self.ui.close();1282 self.ui.close();
1283 if let Some(saved) = saved {
1284 self.saved_server(saved);
1285 }
1222 }1286 }
1223}1287}
12241288
crates/snowbound/src/spell_windows.rs+2-1
...@@ -45,7 +45,8 @@ type Out = *mut *mut *const usize;...@@ -45,7 +45,8 @@ type Out = *mut *mut *const usize;
4545
46/// The object a method answered through `out`, where it answered one.46/// The object a method answered through `out`, where it answered one.
47fn answered(result: i32, out: *mut *const usize) -> Option<Object> {47fn answered(result: i32, out: *mut *const usize) -> Option<Object> {
48 (result >= 0 && !out.is_null()).then_some(Object(out))48 // Lazily: an `Object` made for a failed call would release a null pointer when dropped.
49 (result >= 0 && !out.is_null()).then(|| Object(out))
49}50}
5051
51/// The strings an IEnumString holds, freed as they are read.52/// The strings an IEnumString holds, freed as they are read.
crates/snowbound/src/surface_windows.rs+48-8
...@@ -73,23 +73,42 @@ impl Surface {...@@ -73,23 +73,42 @@ impl Surface {
73 unreachable!()73 unreachable!()
74 };74 };
75 let device = unsafe { GetDC(handle.hwnd.get() as HWND) };75 let device = unsafe { GetDC(handle.hwnd.get() as HWND) };
76 let alpha = if backdrop { 8 } else { 0 };76 let request = |alpha| gl::PIXELFORMATDESCRIPTOR {
77 let format = gl::PIXELFORMATDESCRIPTOR {
78 nSize: size_of::<gl::PIXELFORMATDESCRIPTOR>() as u16,77 nSize: size_of::<gl::PIXELFORMATDESCRIPTOR>() as u16,
79 nVersion: 1,78 nVersion: 1,
80 // PFD_SUPPORT_COMPOSITION: the desktop composes the window's alpha.
81 dwFlags: gl::PFD_DRAW_TO_WINDOW79 dwFlags: gl::PFD_DRAW_TO_WINDOW
82 | gl::PFD_SUPPORT_OPENGL80 | gl::PFD_SUPPORT_OPENGL
83 | gl::PFD_DOUBLEBUFFER81 | gl::PFD_DOUBLEBUFFER
84 | 0x8000,82 | gl::PFD_SUPPORT_COMPOSITION,
85 iPixelType: gl::PFD_TYPE_RGBA,83 iPixelType: gl::PFD_TYPE_RGBA,
86 cColorBits: 32,84 cColorBits: 32,
87 cAlphaBits: alpha,85 cAlphaBits: alpha,
88 iLayerType: gl::PFD_MAIN_PLANE as u8,86 iLayerType: gl::PFD_MAIN_PLANE as u8,
89 ..unsafe { std::mem::zeroed() }87 ..unsafe { std::mem::zeroed() }
90 };88 };
89 // A driver with no accelerated format that has alpha answers with Windows' own
90 // OpenGL 1.1; frames then go without the glass behind them.
91 let accelerated = |format: &gl::PIXELFORMATDESCRIPTOR| unsafe {
92 let chosen = gl::ChoosePixelFormat(device, format);
93 let mut found = std::mem::zeroed();
94 let size = size_of::<gl::PIXELFORMATDESCRIPTOR>() as u32;
95 (chosen != 0
96 && gl::DescribePixelFormat(device, chosen, size, &mut found) != 0
97 && (found.dwFlags & gl::PFD_GENERIC_FORMAT == 0
98 || found.dwFlags & gl::PFD_GENERIC_ACCELERATED != 0))
99 .then_some(chosen)
100 };
101 let mut format = request(if backdrop { 8 } else { 0 });
102 let mut translucent = backdrop;
103 let mut chosen = accelerated(&format);
104 if chosen.is_none() && backdrop {
105 eprintln!("No accelerated OpenGL format with alpha; drawing opaque");
106 format = request(0);
107 translucent = false;
108 chosen = accelerated(&format);
109 }
91 unsafe {110 unsafe {
92 let chosen = gl::ChoosePixelFormat(device, &format);111 let chosen = chosen.unwrap_or_else(|| gl::ChoosePixelFormat(device, &format));
93 if chosen == 0 || gl::SetPixelFormat(device, chosen, &format) == 0 {112 if chosen == 0 || gl::SetPixelFormat(device, chosen, &format) == 0 {
94 return Err("No OpenGL pixel format".into());113 return Err("No OpenGL pixel format".into());
95 }114 }
...@@ -105,15 +124,36 @@ impl Surface {...@@ -105,15 +124,36 @@ impl Surface {
105 );124 );
106 }125 }
107 }126 }
108 let renderer = Renderer::opengl()?;127 let string = |name| unsafe {
109 eprintln!("Canvas GPU: OpenGL");128 let text = gl::glGetString(name);
129 if text.is_null() {
130 String::new()
131 } else {
132 std::ffi::CStr::from_ptr(text.cast())
133 .to_string_lossy()
134 .into_owned()
135 }
136 };
137 let driver = format!(
138 "OpenGL {} ({}, {})",
139 string(gl::GL_VERSION),
140 string(gl::GL_RENDERER),
141 string(gl::GL_VENDOR)
142 );
143 eprintln!("Canvas GPU: {driver}");
144 let renderer = Renderer::opengl().map_err(|error| {
145 format!(
146 "Snowbound needs OpenGL 2.1, and this display driver has {driver}. Install \
147 the driver from your graphics card's maker. ({error})"
148 )
149 })?;
110 let size = window.inner_size();150 let size = window.inner_size();
111 Ok((151 Ok((
112 Self {152 Self {
113 size: [size.width, size.height],153 size: [size.width, size.height],
114 backend: Backend::Gl {154 backend: Backend::Gl {
115 device,155 device,
116 translucent: backdrop,156 translucent,
117 target: None,157 target: None,
118 },158 },
119 },159 },
crates/snowbound/src/sync.rs+1032-252
...@@ -1,14 +1,27 @@...@@ -1,14 +1,27 @@
1//! The sync status at the top right, and the popup it opens: OneNote's Shared Notebook1//! The sync status at the top right, and the popup it opens: OneNote's Shared Notebook
2//! Synchronization for the open section's notebook and each of its sections.2//! Synchronization for the open section's notebook, led by where its sync stands.
33
4use crate::{Session, State, art, filetime, library, platform, update};4use crate::{Library, Session, State, art, filetime, library, platform, update};
5use notebook::session::{SyncState, SyncStatus};5use notebook::session::{SyncState, SyncStatus};
6use std::error::Error;6use onestore::ExGuid;
7use std::{error::Error, path::Path, sync::Arc, time::Duration};
7use ui::{8use ui::{
8 Anchor, Axis, Flags, Id, Overflow, Spec, Theme, Ui, children, fill, fit, px, shell::TOOL,9 Anchor, Axis, Flags, Id, Overflow, Spec, Theme, Ui, children, fill, fit, px, shell::TOOL,
9};10};
1011
11const WIDTH: f32 = 380.0;12const WIDTH: f32 = 340.0;
13const PAD: f32 = 16.0;
14/// A sync shorter than this never shows, so a quick publish doesn't flash the bar.
15const SHOW_AFTER: Duration = Duration::from_millis(400);
16/// How long a shown sync's full bar stays before the status reads up to date.
17const LINGER: Duration = Duration::from_millis(700);
18/// One pass of the sheen along a running bar.
19const SHEEN: f32 = 1.4;
20const THIS: &str = if cfg!(target_os = "macos") {
21 "this Mac"
22} else {
23 "this computer"
24};
1225
13fn id() -> Id {26fn id() -> Id {
14 Id::ROOT.child("sync-status")27 Id::ROOT.child("sync-status")
...@@ -18,55 +31,52 @@ fn button() -> Id {...@@ -18,55 +31,52 @@ fn button() -> Id {
18 Id::ROOT.child("sync-button")31 Id::ROOT.child("sync-button")
19}32}
2033
21/// The status's label and icon, and what the reader can do about an error; `mounted` where34/// The status's headline and icon. `quiet` holds a sync back that hasn't run `SHOW_AFTER`
22/// the notebook syncs through the system's mount because Snowbound's client couldn't sign in.35/// yet: a notebook synced before still reads up to date.
23fn describe(36fn describe(
24 sync: &SyncStatus,37 sync: &SyncStatus,
25 mounted: bool,38 offline: bool,
26) -> (&'static str, &'static [&'static str], Option<&'static str>) {39 quiet: bool,
27 if library::offline() {40) -> (&'static str, &'static [&'static str]) {
28 return (41 if offline {
29 "Working offline",42 return ("Working offline", art::SYNC_OFFLINE);
30 art::SYNC_OFFLINE,
31 Some("Changes stay on this computer until you sync."),
32 );
33 }43 }
34 match sync.state() {44 match sync.state() {
35 SyncState::ReadOnly => (45 SyncState::ReadOnly => ("Can’t save changes", art::SYNC_ERROR),
36 "Unable to sync",46 SyncState::InUse => ("Waiting for another device", art::SYNC_BUSY),
37 art::SYNC_ERROR,47 SyncState::NotConnected => ("Waiting for connection", art::SYNC_OFFLINE),
38 Some(48 SyncState::Protected => ("Password-protected section", art::SYNC_ERROR),
39 "You can’t change this notebook where it’s stored. Changes stay on this computer.",49 SyncState::Unreadable => ("Can’t read a section", art::SYNC_ERROR),
40 ),50 SyncState::Failed => ("Unable to sync", art::SYNC_ERROR),
41 ),51 SyncState::Syncing if !quiet || sync.synced.is_none() => ("Syncing…", art::SYNC_BUSY),
42 SyncState::InUse => (52 SyncState::Syncing | SyncState::UpToDate => ("Up to date", art::SYNC_DONE),
43 "Section in use",
44 art::SYNC_BUSY,
45 Some("Someone else is saving this section. Sync continues when they finish."),
46 ),
47 SyncState::NotConnected => (
48 "Not connected",
49 art::SYNC_OFFLINE,
50 Some("Changes stay on this computer and sync when the notebook is back."),
51 ),
52 SyncState::Protected => (
53 "Password protected",
54 art::SYNC_ERROR,
55 Some("Snowbound can’t open password-protected sections yet."),
56 ),
57 SyncState::Unreadable => ("Can’t read this section", art::SYNC_ERROR, None),
58 SyncState::Failed => ("Unable to sync", art::SYNC_ERROR, None),
59 SyncState::Syncing => ("Syncing…", art::SYNC_BUSY, None),
60 SyncState::UpToDate if mounted => {
61 ("Using the system’s connection", art::SYNC_WARNING, None)
62 }
63 SyncState::UpToDate => ("Up to date", art::SYNC_DONE, None),
64 }53 }
65}54}
6655
67/// Every section of the open section's notebook with its status, in catalog order: the56/// A section's status beside its name.
68/// open one's from its session, the others' from the notebook's background sync.57fn brief(sync: &SyncStatus, offline: bool) -> String {
69fn sections(session: &Session) -> Vec<(String, SyncStatus)> {58 let waiting = match sync.queued {
59 0 => String::new(),
60 queued => format!(" · {} waiting", changes(queued)),
61 };
62 match sync.state() {
63 SyncState::UpToDate => "Up to date".to_owned(),
64 SyncState::Syncing if offline => format!("{} waiting", changes(sync.queued)),
65 SyncState::Syncing if sync.queued > 0 => format!("Sending {}", changes(sync.queued)),
66 SyncState::Syncing => "Syncing…".to_owned(),
67 SyncState::InUse => format!("In use elsewhere{waiting}"),
68 SyncState::NotConnected => format!("Not connected{waiting}"),
69 SyncState::ReadOnly => format!("Read-only{waiting}"),
70 SyncState::Protected => "Password protected".to_owned(),
71 SyncState::Unreadable => "Can’t read".to_owned(),
72 SyncState::Failed => format!("Unable to sync{waiting}"),
73 }
74}
75
76/// Every section of `library` with its status, in catalog order: the open one's from its
77/// session, the others' from the notebook's background sync. OneNote's sync dialog leaves
78/// out the recycle bin, unless something is wrong there.
79fn sections(library: &Library, session: &Session) -> Vec<(String, SyncStatus)> {
70 let open = &session.tabs[session.tab].path;80 let open = &session.tabs[session.tab].path;
71 let copy = |sync: &SyncStatus| SyncStatus {81 let copy = |sync: &SyncStatus| SyncStatus {
72 synced: sync.synced,82 synced: sync.synced,
...@@ -76,16 +86,23 @@ fn sections(session: &Session) -> Vec<(String, SyncStatus)> {...@@ -76,16 +86,23 @@ fn sections(session: &Session) -> Vec<(String, SyncStatus)> {
76 .map(|error| std::io::Error::new(error.kind(), error.to_string())),86 .map(|error| std::io::Error::new(error.kind(), error.to_string())),
77 queued: sync.queued,87 queued: sync.queued,
78 };88 };
79 let mut sections = session89 let mut sections = library
80 .library
81 .background90 .background
82 .as_ref()91 .as_ref()
83 .map(|background| background.status())92 .map(|background| background.status())
84 .unwrap_or_default();93 .unwrap_or_default();
85 match sections.iter_mut().find(|(path, _)| path == open) {94 if std::ptr::eq(library, &*session.library) {
86 Some((_, sync)) => *sync = copy(&session.sync),95 match sections.iter_mut().find(|(path, _)| path == open) {
87 None => sections.insert(0, (open.clone(), copy(&session.sync))),96 Some((_, sync)) => *sync = copy(&session.sync),
97 None => sections.insert(0, (open.clone(), copy(&session.sync))),
98 }
88 }99 }
100 sections.retain(|(path, sync)| {
101 sync.error.is_some()
102 || !path
103 .rsplit_once('/')
104 .is_some_and(|(folder, _)| library::recycle_bin(folder))
105 });
89 sections106 sections
90}107}
91108
...@@ -117,15 +134,39 @@ fn changes(count: u64) -> String {...@@ -117,15 +134,39 @@ fn changes(count: u64) -> String {
117 }134 }
118}135}
119136
120/// A FILETIME as OneNote's "Last sync": the time, and the date too before today.137/// A FILETIME as how long ago it was: minutes within the hour, then the time, and the date
121fn when(time: u64) -> String {138/// too before today.
139fn ago(time: u64) -> String {
140 let minutes = filetime().saturating_sub(time) / 600_000_000;
141 if minutes < 1 {
142 return "just now".to_owned();
143 }
144 if minutes < 60 {
145 return format!("{minutes} min ago");
146 }
122 let [_, clock] = platform::date_text(time);147 let [_, clock] = platform::date_text(time);
123 let day = platform::short_date(time);148 let day = platform::short_date(time);
124 if day == platform::short_date(filetime()) {149 if day == platform::short_date(filetime()) {
125 clock150 format!("at {clock}")
126 } else {151 } else {
127 format!("{day} {clock}")152 format!("{day} at {clock}")
153 }
154}
155
156/// Whether a sync shows: once it has run `SHOW_AFTER`, and for `LINGER` after it ends up
157/// to date where it `shown`, so a quick one never flashes. `id` keeps the timings.
158fn shows_sync(ui: &mut Ui, id: Id, syncing: bool, shown: bool) -> bool {
159 let running = ui.lasted(id.child("running"), syncing);
160 let ended = ui.lasted(id.child("ended"), !syncing);
161 let due = match syncing {
162 true => SHOW_AFTER.checked_sub(running),
163 false if shown => LINGER.checked_sub(ended),
164 false => None,
165 };
166 if let Some(due) = due.filter(|due| !due.is_zero()) {
167 ui.wake_after(due);
128 }168 }
169 running >= SHOW_AFTER || !syncing && shown && ended < LINGER
129}170}
130171
131const TITLE: &str = "Notebook Sync Status";172const TITLE: &str = "Notebook Sync Status";
...@@ -143,8 +184,16 @@ fn update_note(update: &update::Status) -> Option<String> {...@@ -143,8 +184,16 @@ fn update_note(update: &update::Status) -> Option<String> {
143/// The status's icon in the toolbar, which opens the popup; its tooltip names the status,184/// The status's icon in the toolbar, which opens the popup; its tooltip names the status,
144/// and a dot on it says a newer build is ready.185/// and a dot on it says a newer build is ready.
145pub(crate) fn control(ui: &mut Ui, session: &Session, update: &update::Status, theme: &Theme) {186pub(crate) fn control(ui: &mut Ui, session: &Session, update: &update::Status, theme: &Theme) {
146 let sync = overall(&sections(session));187 let sync = overall(&sections(&session.library, session));
147 let (label, icon, _) = describe(&sync, session.library.notice.is_some());188 let offline = library::offline();
189 let syncing = !offline && sync.state() == SyncState::Syncing;
190 let quiet = !shows_sync(ui, button(), syncing, false);
191 let (label, icon) = describe(&sync, offline, quiet);
192 // Through the system's mount, OneNote's locks aren't taken.
193 let icon = match session.library.notice {
194 Some(_) if icon == art::SYNC_DONE => art::SYNC_WARNING,
195 _ => icon,
196 };
148 ui.open_as(197 ui.open_as(
149 button(),198 button(),
150 Spec {199 Spec {
...@@ -197,255 +246,986 @@ pub(crate) fn control(ui: &mut Ui, session: &Session, update: &update::Status, t...@@ -197,255 +246,986 @@ pub(crate) fn control(ui: &mut Ui, session: &Session, update: &update::Status, t
197 }246 }
198}247}
199248
200impl State {249/// What the popup shows of the notebook.
201 /// Builds the sync status popup while it is open.250struct Facts<'a> {
202 pub(crate) fn sync_popup(&mut self) -> Result<(), Box<dyn Error>> {251 name: &'a str,
203 let (ui, notebooks) = (&mut self.ui, &self.notebooks);252 /// The notebook's folder or address in full.
204 let update = self.updates.status();253 location: &'a str,
205 let Some(session) = &mut self.session else {254 place: Vec<String>,
206 ui.close_popup(id());255 /// Why the notebook syncs through the system's mount of its share.
207 return Ok(());256 notice: Option<&'a str>,
208 };257 sections: Vec<(String, SyncStatus)>,
209 if !ui.popup_open(id()) {258 /// The open section's pages with conflict pages, and their titles.
210 return Ok(());259 conflicts: Vec<(ExGuid, &'a str)>,
211 }260 offline: bool,
212 // Read afresh while shown, so the last sync time follows each poll.261 update: &'a update::Status,
213 session.sync = session.section.sync_status()?;262 /// Whether the open section's file can be shown in the file manager.
214 let theme = ui.theme.clone();263 local: bool,
215 let offline = library::offline();264}
216 let sections = sections(session);265
217 let sync = overall(&sections);266/// What the reader picked in the popup.
218 let (progress, _, advice) = describe(&sync, session.library.notice.is_some());267#[derive(Default)]
219 ui.open_as(268struct Picked {
220 id(),269 offline: bool,
270 sync_now: bool,
271 show_file: bool,
272 sign_in: bool,
273 conflict: Option<ExGuid>,
274 build_folder: bool,
275 restart: bool,
276}
277
278/// Lays out the open popup's contents.
279fn build(ui: &mut Ui, facts: &Facts) -> Picked {
280 let theme = ui.theme.clone();
281 let mut picked = Picked::default();
282 let line = theme.font_size * 1.6;
283 let text = |ui: &mut Ui, part: &str, text: &str, color: [f32; 4]| {
284 ui.leaf(
285 part,
221 Spec {286 Spec {
222 axis: Axis::Y,287 size: [fill(), fit()],
223 size: [px(WIDTH), children()],288 text: Some(text),
224 fill: Some(theme.popup),289 color: Some(color),
225 border: Some(theme.chip),290 overflow: Overflow::Wrap,
226 shadow: Some(theme.shadow),291 pad: [0.0, 2.0],
227 radius: 8.0,
228 pad: [14.0, 10.0],
229 gap: 2.0,
230 anchor: Some(Anchor::Below(ui.rect(button()).unwrap_or_default())),
231 role: Some(accesskit::Role::Dialog),
232 ..Spec::default()292 ..Spec::default()
233 },293 },
234 );294 );
235 if let Some(node) = ui.access(id()) {295 };
236 node.set_label(TITLE);296 let rule = |ui: &mut Ui, part: &str| {
297 ui.leaf(
298 part,
299 Spec {
300 size: [fill(), px(1.0)],
301 fill: Some(theme.chip),
302 ..Spec::default()
303 },
304 );
305 };
306 let sync = overall(&facts.sections);
307 let total = facts.sections.len();
308 let done = facts
309 .sections
310 .iter()
311 .filter(|(_, sync)| sync.state() == SyncState::UpToDate)
312 .count();
313 let state = sync.state();
314 let syncing = !facts.offline && state == SyncState::Syncing;
315 let bar = id().child("status").child("bar");
316 let shown = ui.rect(bar).is_some() && state == SyncState::UpToDate;
317 let showing = !facts.offline && shows_sync(ui, button(), syncing, shown);
318 let (mut headline, mut icon) = describe(&sync, facts.offline, !showing);
319 let host = facts.place.first().map_or("the notebook", String::as_str);
320 let waiting = changes(sync.queued);
321 let conflict;
322 let advice = if facts.offline {
323 Some(match sync.queued {
324 0 => "Turn off Work offline to sync.".to_owned(),
325 _ => format!("{waiting} will sync when you go back online."),
326 })
327 } else {
328 match state {
329 SyncState::NotConnected => Some(match sync.queued {
330 0 => format!("Can’t reach {host}. Sync continues when it’s back."),
331 _ => format!("Can’t reach {host}. {waiting} will sync when it’s back."),
332 }),
333 SyncState::InUse => {
334 Some("Someone else is saving a section. Sync continues when they finish.".into())
335 }
336 SyncState::ReadOnly => Some(format!(
337 "This location is read-only. Your changes stay on {THIS}."
338 )),
339 SyncState::Protected => {
340 Some("Snowbound can’t open password-protected sections yet.".into())
341 }
342 SyncState::Unreadable | SyncState::Failed => {
343 Some("Snowbound keeps trying. Sync Now tries again at once.".into())
344 }
345 _ => None,
237 }346 }
238 let text = |ui: &mut Ui, part: &str, text: &str, color: [f32; 4], bold: bool| {347 };
239 ui.leaf(348 if !showing && headline == "Up to date" && !facts.conflicts.is_empty() {
240 part,349 conflict = match facts.conflicts.as_slice() {
241 Spec {350 [(_, title)] => format!("Conflict on “{title}”"),
242 size: [fill(), fit()],351 pages => format!("Conflicts on {} pages", pages.len()),
243 text: Some(text),
244 color: Some(color),
245 bold,
246 overflow: Overflow::Wrap,
247 pad: [0.0, 3.0],
248 ..Spec::default()
249 },
250 );
251 };352 };
252 let row = |ui: &mut Ui, key: &str, value: &str| {353 headline = &conflict;
253 ui.open(354 icon = art::SYNC_WARNING;
254 key,355 }
255 Spec {356
256 size: [fill(), children()],357 // The status: headline, when it last synced or what to do, and a running sync's bar.
257 gap: 8.0,358 ui.open(
258 ..Spec::default()359 "status",
259 },360 Spec {
260 );361 axis: Axis::Y,
261 ui.leaf(362 size: [fill(), children()],
262 "key",363 gap: 2.0,
263 Spec {364 ..Spec::default()
264 size: [px(92.0), px(theme.font_size * 1.6)],365 },
265 text: Some(key),366 );
266 color: Some(theme.text_dim),367 if let Some(node) = ui.access(ui.current()) {
267 ..Spec::default()368 node.set_live(accesskit::Live::Polite);
268 },369 }
269 );370 ui.leaf(
270 ui.leaf(371 "headline",
271 "value",372 Spec {
272 Spec {373 size: [fill(), px(theme.font_size * 2.0)],
273 size: [fill(), px(theme.font_size * 1.6)],374 text: Some(headline),
274 text: Some(value),375 icon: Some(icon),
275 overflow: Overflow::Ellipsis,376 font_size: Some(theme.font_size * 1.2),
276 ..Spec::default()377 bold: true,
277 },378 overflow: Overflow::Ellipsis,
278 );379 ..Spec::default()
279 ui.close();380 },
381 );
382 if showing {
383 let fraction = match total {
384 0 => 1.0,
385 _ if state == SyncState::UpToDate => 1.0,
386 _ => done as f32 / total as f32,
280 };387 };
281 text(ui, "title", TITLE, theme.text, true);388 let fraction = ui.animate(bar.child("fraction"), fraction);
282 row(ui, "Notebook", &session.library.name);389 let running = ui.lasted(bar.child("running"), true).as_secs_f32();
283 row(ui, "Location", &session.library.location);390 ui.open_as(
284 row(ui, "Connection", &session.library.transport());391 bar,
285 row(ui, "Progress", progress);392 Spec {
286 if let Some(synced) = sync.synced {393 size: [fill(), px(6.0)],
287 row(ui, "Last sync", &when(synced));394 fill: Some(theme.chip),
288 }395 radius: 3.0,
289 if sync.queued > 0 {396 role: Some(accesskit::Role::ProgressIndicator),
290 row(ui, "Not yet synced", &changes(sync.queued));397 ..Spec::default()
398 },
399 );
400 let width = ui.rect(bar).map_or(0.0, |rect| rect[2] - rect[0]);
401 let filled = width * fraction;
402 ui.mark([0.0, 0.0, filled, 6.0], theme.accent, 3.0);
403 // A sheen sweeps the filled part while the sync runs, so a long section still moves.
404 if fraction < 1.0 && filled > 0.0 {
405 let band = 36.0_f32.min(filled);
406 let at = (running % SHEEN) / SHEEN * (filled + band) - band;
407 let [r, g, b, _] = theme.popup;
408 ui.mark(
409 [at.max(0.0), 0.0, (at + band).min(filled), 6.0],
410 [r, g, b, 0.28],
411 3.0,
412 );
413 ui.wake_after(Duration::from_millis(16));
291 }414 }
292 if let Some(advice) = advice {415 if let Some(node) = ui.access(bar) {
293 text(ui, "advice", advice, theme.text, false);416 node.set_label("Sync progress");
417 node.set_min_numeric_value(0.0);
418 node.set_max_numeric_value(100.0);
419 node.set_numeric_value(f64::from((fraction * 100.0).round()));
294 }420 }
295 let mut sign_in = None;421 ui.close();
296 if let Some(notice) = &session.library.notice {422 let mut caption = match total {
297 let notice = format!("Snowbound’s SMB client couldn’t sign in: {notice}");423 0 | 1 => String::new(),
298 text(ui, "notice", &notice, theme.text_dim, false);424 _ => format!("{done} of {total} sections synced"),
299 if ui::button(ui, "sign-in", "Sign In\u{2026}").clicked {425 };
300 sign_in = Some(session.library.location.clone());426 if sync.queued > 0 {
427 if !caption.is_empty() {
428 caption += " · ";
301 }429 }
430 caption += &format!("{waiting} to send");
302 }431 }
303 text(ui, "sections", "Sections", theme.text, true);432 if caption.is_empty() {
304 // OneNote's sync dialog leaves out the recycle bin, unless something is wrong there.433 caption = "Checking for changes…".to_owned();
305 let listed = sections.iter().filter(|(path, sync)| {434 }
306 sync.error.is_some()435 text(ui, "caption", &caption, theme.text_dim);
307 || !path436 } else {
308 .rsplit_once('/')437 if let Some(advice) = &advice {
309 .is_some_and(|(folder, _)| library::recycle_bin(folder))438 text(ui, "advice", advice, theme.text);
310 });439 } else if !facts.conflicts.is_empty() && headline != "Syncing…" {
311 for (index, (path, sync)) in listed.enumerate() {440 text(
312 ui.open(441 ui,
313 format!("section-{index}"),442 "advice",
314 Spec {443 "Both versions are kept. Open the page to compare them.",
315 size: [fill(), children()],444 theme.text,
316 gap: 8.0,
317 ..Spec::default()
318 },
319 );
320 let name = path.strip_suffix(".one").unwrap_or(path);
321 ui.leaf(
322 "name",
323 Spec {
324 size: [fill(), px(theme.font_size * 1.6)],
325 text: Some(name),
326 overflow: Overflow::Ellipsis,
327 ..Spec::default()
328 },
329 );445 );
330 let status = match sync.queued {446 }
331 0 => describe(sync, false).0.to_owned(),447 if let Some(synced) = sync.synced {
332 queued => format!("{}, {}", describe(sync, false).0, changes(queued)),448 let prefix = match advice {
449 Some(_) => "Last synced",
450 None => "Synced",
333 };451 };
334 ui.leaf(452 text(
335 "status",453 ui,
336 Spec {454 "synced",
337 size: [fit(), px(theme.font_size * 1.6)],455 &format!("{prefix} {}", ago(synced)),
338 text: Some(&status),456 theme.text_dim,
339 color: Some(theme.text_dim),
340 ..Spec::default()
341 },
342 );457 );
343 ui.close();458 ui.wake_after(Duration::from_secs(30));
344 if let Some(error) = &sync.error {
345 let part = format!("section-{index}-error");
346 text(ui, &part, &error.to_string(), theme.text_dim, false);
347 }
348 }459 }
349 let (mut folder, mut restart) = (false, false);460 }
350 if let Some(note) = update_note(&update) {461 ui.close();
351 text(ui, "update-title", "Snowbound Update", theme.text, true);462
352 row(ui, "Update", &note);463 rule(ui, "identity-rule");
353 ui.open(464 // Which notebook, and where it lives; the full location in the tooltip.
354 "update",465 ui.open(
355 Spec {466 "identity",
356 size: [fill(), children()],467 Spec {
357 pad: [0.0, 4.0],468 axis: Axis::Y,
358 gap: 8.0,469 size: [fill(), children()],
359 ..Spec::default()470 ..Spec::default()
360 },471 },
361 );472 );
362 folder = ui::button(ui, "build-folder", "Build Folder").clicked;473 ui.leaf(
363 ui.leaf(474 "name",
364 "space",475 Spec {
365 Spec {476 size: [fill(), px(line)],
366 size: [fill(), px(1.0)],477 text: Some(facts.name),
367 ..Spec::default()478 icon: Some(art::NOTEBOOK),
368 },479 bold: true,
480 overflow: Overflow::Ellipsis,
481 ..Spec::default()
482 },
483 );
484 if !facts.place.is_empty() {
485 ui.leaf(
486 "place",
487 Spec {
488 size: [fill(), px(line)],
489 text: Some(&facts.place.join(" › ")),
490 color: Some(theme.text_dim),
491 overflow: Overflow::Ellipsis,
492 pad: [22.0, 0.0],
493 ..Spec::default()
494 },
495 );
496 ui::popup::tooltip(ui, facts.location, "", None);
497 }
498 if let Some(notice) = facts.notice {
499 ui.open(
500 "notice",
501 Spec {
502 size: [fill(), children()],
503 gap: 8.0,
504 pad: [22.0, 4.0],
505 ..Spec::default()
506 },
507 );
508 text(
509 ui,
510 "text",
511 &format!("Using the system’s connection. Snowbound couldn’t sign in: {notice}"),
512 theme.text_dim,
513 );
514 picked.sign_in = ui::button(ui, "sign-in", "Sign In…").clicked;
515 ui.close();
516 }
517 ui.close();
518
519 rule(ui, "sections-rule");
520 // Only what isn't up to date, and how many others are.
521 ui.open(
522 "sections",
523 Spec {
524 axis: Axis::Y,
525 size: [fill(), children()],
526 ..Spec::default()
527 },
528 );
529 // Where the whole notebook can't be reached, the headline says so for each section
530 // without changes waiting.
531 let unreached = |sync: &SyncStatus| {
532 !facts.offline && sync.state() == SyncState::NotConnected && sync.queued == 0
533 };
534 let behind: Vec<_> = facts
535 .sections
536 .iter()
537 .filter(|(_, sync)| sync.state() != SyncState::UpToDate)
538 .filter(|(_, sync)| !(state == SyncState::NotConnected && unreached(sync)))
539 .collect();
540 let unreached = match state {
541 SyncState::NotConnected => facts
542 .sections
543 .iter()
544 .filter(|(_, sync)| unreached(sync))
545 .count(),
546 _ => 0,
547 };
548 for (index, (path, sync)) in behind.iter().enumerate() {
549 ui.open(
550 ("section", index),
551 Spec {
552 size: [fill(), children()],
553 gap: 8.0,
554 ..Spec::default()
555 },
556 );
557 let name = path
558 .strip_suffix(".one")
559 .unwrap_or(path)
560 .replace('/', " › ");
561 ui.leaf(
562 "name",
563 Spec {
564 size: [fill(), px(line)],
565 text: Some(&name),
566 overflow: Overflow::Ellipsis,
567 ..Spec::default()
568 },
569 );
570 let color = match sync.state() {
571 SyncState::Syncing => theme.text_dim,
572 _ => theme.text,
573 };
574 ui.leaf(
575 "status",
576 Spec {
577 size: [fit(), px(line)],
578 text: Some(&brief(sync, facts.offline)),
579 color: Some(color),
580 ..Spec::default()
581 },
582 );
583 ui.close();
584 if let Some(error) = sync
585 .error
586 .as_ref()
587 .filter(|_| matches!(sync.state(), SyncState::Failed | SyncState::Unreadable))
588 {
589 text(
590 ui,
591 &format!("section-{index}-error"),
592 &error.to_string(),
593 theme.text_dim,
369 );594 );
370 if matches!(update, update::Status::Ready(..)) {
371 restart = ui::button(ui, "restart", "Restart to Update").clicked;
372 }
373 ui.close();
374 }595 }
596 }
597 for (space, title) in &facts.conflicts {
375 ui.open(598 ui.open(
376 "controls",599 ("conflict", space),
377 Spec {600 Spec {
378 size: [fill(), children()],601 size: [fill(), children()],
379 pad: [0.0, 6.0],
380 gap: 8.0,602 gap: 8.0,
381 ..Spec::default()603 ..Spec::default()
382 },604 },
383 );605 );
384 let toggled = ui::check_box(ui, "offline", "Work offline", offline).clicked;
385 ui.leaf(606 ui.leaf(
386 "space",607 "name",
387 Spec {608 Spec {
388 size: [fill(), px(1.0)],609 size: [fill(), px(line)],
610 text: Some(title),
611 icon: Some(art::SYNC_WARNING),
612 overflow: Overflow::Ellipsis,
389 ..Spec::default()613 ..Spec::default()
390 },614 },
391 );615 );
392 let file = session.library.local(session.section.file());616 if ui::button(ui, "open", "Open Page").clicked {
393 let show = match file {617 picked.conflict = Some(*space);
394 Some(_) => ui::button(ui, "show-file", platform::SHOW_FILE).clicked,618 }
395 None => {619 ui.close();
396 ui.leaf(620 }
397 "show-file",621 let summaries = [
398 Spec {622 match (behind.len() + unreached, done) {
399 size: [fit(), px(theme.font_size * 2.0)],623 (_, 0) | (0, 1) => None,
400 text: Some(platform::SHOW_FILE),624 (0, all) => Some(format!("All {all} sections up to date")),
401 color: Some(theme.text_dim),625 (_, 1) => Some("1 other section up to date".to_owned()),
402 fill: Some(theme.chip),626 (_, others) => Some(format!("{others} other sections up to date")),
403 radius: 4.0,627 },
404 pad: [theme.font_size * 0.75, 0.0],628 match (unreached, behind.is_empty() && done == 0) {
405 center: true,629 (0, _) | (1, true) => None,
406 role: Some(accesskit::Role::Button),630 (count, true) => Some(format!("All {count} sections waiting for connection")),
407 ..Spec::default()631 (1, false) => Some("1 other section waiting for connection".to_owned()),
408 },632 (count, false) => Some(format!("{count} other sections waiting for connection")),
409 );633 },
410 if let Some(node) = ui.access(ui.id("show-file")) {634 ];
411 node.set_disabled();635 for (index, summary) in summaries.iter().flatten().enumerate() {
412 }636 ui.leaf(
413 false637 ("summary", index),
638 Spec {
639 size: [fill(), px(line)],
640 text: Some(summary),
641 color: Some(theme.text_dim),
642 ..Spec::default()
643 },
644 );
645 }
646 ui.close();
647
648 if let Some(note) = update_note(facts.update) {
649 rule(ui, "update-rule");
650 ui.open(
651 "update",
652 Spec {
653 size: [fill(), children()],
654 gap: 8.0,
655 ..Spec::default()
656 },
657 );
658 ui.leaf(
659 "note",
660 Spec {
661 size: [fill(), px(theme.font_size * 2.0)],
662 text: Some(&format!("Snowbound {note}")),
663 overflow: Overflow::Ellipsis,
664 ..Spec::default()
665 },
666 );
667 picked.build_folder = ui::button(ui, "build-folder", "Build Folder").clicked;
668 if matches!(facts.update, update::Status::Ready(..)) {
669 picked.restart = ui::button(ui, "restart", "Restart to Update").clicked;
670 }
671 ui.close();
672 }
673
674 ui.open(
675 "controls",
676 Spec {
677 size: [fill(), children()],
678 pad: [0.0, 4.0],
679 gap: 8.0,
680 ..Spec::default()
681 },
682 );
683 picked.offline = ui::check_box(ui, "offline", "Work offline", facts.offline).clicked;
684 ui.leaf(
685 "space",
686 Spec {
687 size: [fill(), px(1.0)],
688 ..Spec::default()
689 },
690 );
691 picked.show_file = match facts.local {
692 true => ui::button(ui, "show-file", platform::SHOW_FILE).clicked,
693 false => {
694 ui.leaf(
695 "show-file",
696 Spec {
697 size: [fit(), px(theme.font_size * 2.0)],
698 text: Some(platform::SHOW_FILE),
699 color: Some(theme.text_dim),
700 fill: Some(theme.chip),
701 radius: 4.0,
702 pad: [theme.font_size * 0.75, 0.0],
703 center: true,
704 role: Some(accesskit::Role::Button),
705 ..Spec::default()
706 },
707 );
708 if let Some(node) = ui.access(ui.id("show-file")) {
709 node.set_disabled();
414 }710 }
711 false
712 }
713 };
714 picked.sync_now = ui::button(ui, "sync-now", "Sync Now").clicked;
715 ui.close();
716 picked
717}
718
719/// Opens the popup's box at `anchor`.
720fn open(ui: &mut Ui, anchor: Anchor) {
721 let theme = ui.theme.clone();
722 ui.open_as(
723 id(),
724 Spec {
725 axis: Axis::Y,
726 size: [px(WIDTH), children()],
727 fill: Some(theme.popup),
728 border: Some(theme.chip),
729 shadow: Some(theme.shadow),
730 radius: 8.0,
731 pad: [PAD, 12.0],
732 gap: 10.0,
733 anchor: Some(anchor),
734 role: Some(accesskit::Role::Dialog),
735 ..Spec::default()
736 },
737 );
738 if let Some(node) = ui.access(id()) {
739 node.set_label(TITLE);
740 }
741}
742
743/// `library`'s status in a line, as its context menu heads with it.
744pub(crate) fn line(library: &Library, session: &Session) -> (String, &'static [&'static str]) {
745 let sections = sections(library, session);
746 let sync = overall(&sections);
747 let offline = library::offline();
748 let (headline, icon) = describe(&sync, offline, false);
749 let done = sections
750 .iter()
751 .filter(|(_, sync)| sync.state() == SyncState::UpToDate)
752 .count();
753 let text = match sync.state() {
754 _ if offline && sync.queued > 0 => format!("Offline · {} waiting", changes(sync.queued)),
755 SyncState::Syncing if !offline && sections.len() > 1 => {
756 format!("Syncing {done} of {} sections…", sections.len())
757 }
758 SyncState::UpToDate if !offline => match sync.synced {
759 Some(synced) => format!("Up to date · {}", ago(synced)),
760 None => headline.to_owned(),
761 },
762 _ if sync.queued > 0 => format!("{headline} · {} waiting", changes(sync.queued)),
763 _ => headline.to_owned(),
764 };
765 (text, icon)
766}
767
768impl State {
769 /// Opens the sync status popup on `library`, at `at` in the window.
770 pub(crate) fn show_sync(&mut self, library: Arc<Library>, at: [f32; 2]) {
771 self.sync_notebook = Some((library, at));
772 self.ui.open_popup(id());
773 }
774
775 /// Builds the sync status popup while it is open: on the notebook its context menu
776 /// opened it for, or the open section's.
777 pub(crate) fn sync_popup(&mut self) -> Result<(), Box<dyn Error>> {
778 let (ui, notebooks) = (&mut self.ui, &self.notebooks);
779 let update = self.updates.status();
780 let Some(session) = &mut self.session else {
781 ui.close_popup(id());
782 return Ok(());
415 };783 };
416 let now = ui::button(ui, "sync-now", "Sync Now").clicked;784 if !ui.popup_open(id()) {
417 ui.close();785 self.sync_notebook = None;
786 return Ok(());
787 }
788 // Read afresh while shown, so the last sync time follows each poll.
789 session.sync = session.section.sync_status()?;
790 let (library, anchor) = match &self.sync_notebook {
791 Some((library, at)) => (Arc::clone(library), Anchor::Point(*at)),
792 None => (
793 Arc::clone(&session.library),
794 Anchor::Below(ui.rect(button()).unwrap_or_default()),
795 ),
796 };
797 let own = Arc::ptr_eq(&library, &session.library);
798 let offline = library::offline();
799 let file = match own {
800 true => library.local(session.section.file()),
801 false => library.folder().map(Path::to_owned),
802 };
803 let facts = Facts {
804 name: &library.name,
805 location: &library.location,
806 place: library.place(),
807 notice: library.notice.as_deref(),
808 sections: sections(&library, session),
809 conflicts: session
810 .conflicts
811 .iter()
812 .filter(|(_, pages)| own && !pages.is_empty())
813 .filter_map(|(space, _)| {
814 let (_, title, _) = session.pages.iter().find(|page| page.0 == *space)?;
815 Some((*space, title.as_str()))
816 })
817 .collect(),
818 offline,
819 update: &update,
820 local: file.is_some(),
821 };
822 open(ui, anchor);
823 let picked = build(ui, &facts);
418 ui.close();824 ui.close();
419 let backgrounds = notebooks825 let backgrounds = notebooks
420 .iter()826 .iter()
421 .filter_map(|library| library.background.as_ref());827 .filter_map(|library| library.background.as_ref());
422 if toggled {828 if picked.offline {
423 library::set_offline(!offline);829 library::set_offline(!offline);
424 session.section.set_offline(!offline);830 session.section.set_offline(!offline);
425 backgrounds.for_each(|background| background.set_offline(!offline));831 notebooks
426 } else if now {832 .iter()
427 session.section.wake();833 .for_each(|library| library.set_offline(!offline));
834 } else if picked.sync_now {
835 if own {
836 session.section.wake();
837 }
428 backgrounds.for_each(|background| background.wake());838 backgrounds.for_each(|background| background.wake());
429 }839 }
430 if let Some(file) = file.filter(|_| show) {840 if let Some(file) = file.filter(|_| picked.show_file) {
431 platform::show_file(&file);841 platform::show_file(&file);
432 }842 }
433 if let Some(location) = sign_in {843 if picked.sign_in {
844 let location = library.location.clone();
434 self.ui.close_popup(id());845 self.ui.close_popup(id());
435 self.commands846 self.commands
436 .push(crate::Command::OpenFromServer(Some(location)));847 .push(crate::Command::OpenFromServer(Some(location)));
437 }848 }
849 if let Some(space) = picked.conflict {
850 self.ui.close_popup(id());
851 self.commands.push(crate::Command::OpenPage(space));
852 }
438 match update {853 match update {
439 update::Status::Downloading(version)854 update::Status::Downloading(version)
440 | update::Status::Ready(version, _)855 | update::Status::Ready(version, _)
441 | update::Status::Available(version)856 | update::Status::Available(version)
442 if folder =>857 if picked.build_folder =>
443 {858 {
444 update::show_build(&version)859 update::show_build(&version)
445 }860 }
446 _ if restart => self.restart_to_update(),861 _ if picked.restart => self.restart_to_update(),
447 _ => {}862 _ => {}
448 }863 }
449 Ok(())864 Ok(())
450 }865 }
451}866}
867
868/// The popup in each state, laid out with made-up statuses on a made-up clock. A quick sync
869/// never shows, a longer one shows its bar and fills it before reading up to date. Where
870/// wgpu paints, `SNOWBOUND_SYNC_RENDER` names a directory for PNGs of each state in both
871/// appearances, and `frames-light`/`frames-dark` of a sync running, for a GIF.
872#[cfg(test)]
873mod tests {
874 use super::*;
875 use std::{io, time::Instant};
876 use winit::window::Theme as Appearance;
877
878 const SIZE: [f32; 2] = [380.0, 640.0];
879 const SCALE: f32 = 2.0;
880 const NAMES: [&str; 5] = [
881 "Recipes",
882 "Travel",
883 "Work/Meetings",
884 "Work/Projects",
885 "Journal",
886 ];
887
888 fn status(synced: bool, queued: u64, error: Option<(io::ErrorKind, &str)>) -> SyncStatus {
889 SyncStatus {
890 synced: synced.then(|| filetime() - 3 * 600_000_000),
891 error: error.map(|(kind, text)| io::Error::new(kind, text)),
892 queued,
893 }
894 }
895
896 fn sections(each: impl Fn(usize) -> SyncStatus) -> Vec<(String, SyncStatus)> {
897 NAMES
898 .iter()
899 .enumerate()
900 .map(|(index, name)| (format!("{name}.one"), each(index)))
901 .collect()
902 }
903
904 static IDLE: update::Status = update::Status::Idle;
905
906 fn facts(sections: Vec<(String, SyncStatus)>) -> Facts<'static> {
907 Facts {
908 name: "OneNote",
909 location: "/Volumes/clover/Documents/OneNote",
910 place: ["zenith", "clover", "Documents"]
911 .map(str::to_owned)
912 .to_vec(),
913 notice: None,
914 sections,
915 conflicts: Vec::new(),
916 offline: false,
917 update: &IDLE,
918 local: true,
919 }
920 }
921
922 /// One frame at `now`: the toolbar's button at the top right and the popup open below it.
923 fn frame(ui: &mut Ui, now: Instant, facts: &Facts) {
924 ui.begin(SIZE, SCALE, now);
925 ui.leaf(
926 "toolbar",
927 Spec {
928 size: [fill(), px(TOOL + 12.0)],
929 fill: Some(ui.theme.strip),
930 ..Spec::default()
931 },
932 );
933 ui.open_as(
934 button(),
935 Spec {
936 flags: Flags::FLOAT,
937 size: [px(TOOL), px(TOOL)],
938 position: [SIZE[0] - TOOL - 10.0, 6.0],
939 icon: Some(art::SYNC_DONE),
940 ..Spec::default()
941 },
942 );
943 ui.close();
944 if !ui.popup_open(id()) {
945 ui.open_popup(id());
946 }
947 open(ui, Anchor::Below(ui.rect(button()).unwrap_or_default()));
948 build(ui, facts);
949 ui.close();
950 ui.end();
951 }
952
953 fn ui(appearance: Appearance) -> Ui {
954 let theme = crate::theme(appearance, false, false);
955 let mut ui = Ui::new(theme, Duration::from_millis(500));
956 ui.icon_palette = ui.theme.icon_palette(
957 ui.theme.section(crate::section_color(None)).accent,
958 [1.0; 4],
959 );
960 ui
961 }
962
963 #[cfg(all(feature = "wgpu", not(windows)))]
964 struct Gpu {
965 renderer: draw::Renderer,
966 device: wgpu::Device,
967 queue: wgpu::Queue,
968 }
969
970 #[cfg(all(feature = "wgpu", not(windows)))]
971 impl Gpu {
972 fn new() -> Self {
973 let instance =
974 wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
975 let adapter =
976 pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
977 let (device, queue) =
978 pollster::block_on(adapter.request_device(&Default::default())).unwrap();
979 let renderer = draw::Renderer::new(
980 device.clone(),
981 queue.clone(),
982 wgpu::TextureFormat::Rgba8UnormSrgb,
983 );
984 Self {
985 renderer,
986 device,
987 queue,
988 }
989 }
990
991 /// Writes the frame `ui` laid out to `path`, cropped to the popup and its shadow, or
992 /// to `bottom` where given so a GIF's frames are one size.
993 fn save(&mut self, ui: &Ui, path: &Path, bottom: Option<f32>) {
994 let size = SIZE.map(|side| (side * SCALE) as u32);
995 let interface = ui.layers();
996 let layers: Vec<_> = interface
997 .iter()
998 .filter_map(|layer| match layer {
999 ui::Layer::Primitives(primitives) => Some(primitives.layer(SCALE)),
1000 ui::Layer::Custom { .. } => None,
1001 })
1002 .collect();
1003 let texture = self.device.create_texture(&wgpu::TextureDescriptor {
1004 label: Some("Sync popup"),
1005 size: wgpu::Extent3d {
1006 width: size[0],
1007 height: size[1],
1008 depth_or_array_layers: 1,
1009 },
1010 mip_level_count: 1,
1011 sample_count: 1,
1012 dimension: wgpu::TextureDimension::D2,
1013 format: wgpu::TextureFormat::Rgba8UnormSrgb,
1014 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
1015 view_formats: &[],
1016 });
1017 self.renderer
1018 .draw(
1019 &texture.create_view(&Default::default()),
1020 size,
1021 ui.theme.base,
1022 &layers,
1023 )
1024 .unwrap();
1025 let row = (size[0] * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
1026 let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
1027 label: Some("Sync popup readback"),
1028 size: u64::from(row) * u64::from(size[1]),
1029 usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
1030 mapped_at_creation: false,
1031 });
1032 let mut encoder = self.device.create_command_encoder(&Default::default());
1033 encoder.copy_texture_to_buffer(
1034 texture.as_image_copy(),
1035 wgpu::TexelCopyBufferInfo {
1036 buffer: &buffer,
1037 layout: wgpu::TexelCopyBufferLayout {
1038 offset: 0,
1039 bytes_per_row: Some(row),
1040 rows_per_image: Some(size[1]),
1041 },
1042 },
1043 texture.size(),
1044 );
1045 self.queue.submit([encoder.finish()]);
1046 buffer.map_async(wgpu::MapMode::Read, .., |result| result.unwrap());
1047 self.device
1048 .poll(wgpu::PollType::Wait {
1049 submission_index: None,
1050 timeout: Some(Duration::from_secs(5)),
1051 })
1052 .unwrap();
1053 let mapped = buffer.get_mapped_range(..).unwrap();
1054 let bottom = bottom
1055 .unwrap_or(ui.rect(id()).unwrap()[3] + 24.0)
1056 .min(SIZE[1]);
1057 let height = (bottom * SCALE) as u32;
1058 let pixels: Vec<u8> = mapped
1059 .chunks(row as usize)
1060 .take(height as usize)
1061 .flat_map(|line| &line[..size[0] as usize * 4])
1062 .copied()
1063 .collect();
1064 let file = std::fs::File::create(path).unwrap();
1065 let mut encoder = png::Encoder::new(file, size[0], height);
1066 encoder.set_color(png::ColorType::Rgba);
1067 encoder.set_depth(png::BitDepth::Eight);
1068 encoder
1069 .write_header()
1070 .unwrap()
1071 .write_image_data(&pixels)
1072 .unwrap();
1073 }
1074 }
1075
1076 /// Lays out `facts` until the popup has opened and settled, from `now`.
1077 fn settle(ui: &mut Ui, now: &mut Instant, facts: &Facts) {
1078 for _ in 0..90 {
1079 *now += Duration::from_millis(16);
1080 frame(ui, *now, facts);
1081 }
1082 }
1083
1084 fn bar(ui: &Ui) -> bool {
1085 ui.rect(id().child("status").child("bar")).is_some()
1086 }
1087
1088 #[test]
1089 fn a_quick_sync_never_shows_and_a_long_one_fills_before_up_to_date() {
1090 let mut ui = ui(Appearance::Light);
1091 let mut now = Instant::now();
1092 let done = facts(sections(|_| status(true, 0, None)));
1093 settle(&mut ui, &mut now, &done);
1094 let busy = facts(sections(|index| status(true, u64::from(index == 1), None)));
1095 for _ in 0..12 {
1096 now += Duration::from_millis(16);
1097 frame(&mut ui, now, &busy);
1098 assert!(!bar(&ui), "a sync under {SHOW_AFTER:?} shows");
1099 }
1100 settle(&mut ui, &mut now, &done);
1101 assert!(!bar(&ui));
1102 settle(&mut ui, &mut now, &busy);
1103 assert!(bar(&ui), "a running sync shows its bar");
1104 now += Duration::from_millis(16);
1105 frame(&mut ui, now, &done);
1106 assert!(bar(&ui), "a finished sync's bar stays a moment, full");
1107 now += LINGER + Duration::from_millis(50);
1108 frame(&mut ui, now, &done);
1109 frame(&mut ui, now, &done);
1110 assert!(!bar(&ui));
1111 }
1112
1113 #[cfg(all(feature = "wgpu", not(windows)))]
1114 #[test]
1115 fn popup_renders_each_state() {
1116 let output = std::env::var_os("SNOWBOUND_SYNC_RENDER").map(std::path::PathBuf::from);
1117 let mut gpu = output.as_ref().map(|output| {
1118 std::fs::create_dir_all(output).unwrap();
1119 Gpu::new()
1120 });
1121 type Made = fn() -> Facts<'static>;
1122 let states: Vec<(&str, Made)> = vec![
1123 ("up-to-date", || facts(sections(|_| status(true, 0, None)))),
1124 ("syncing", || {
1125 facts(sections(|index| match index {
1126 0 | 1 => status(true, 0, None),
1127 2 => status(true, 3, None),
1128 _ => status(false, 0, None),
1129 }))
1130 }),
1131 ("offline", || Facts {
1132 offline: true,
1133 ..facts(sections(|index| status(true, [0, 4, 0, 1, 0][index], None)))
1134 }),
1135 ("not-connected", || {
1136 facts(sections(|index| {
1137 status(
1138 true,
1139 [0, 2, 0, 0, 0][index],
1140 Some((io::ErrorKind::TimedOut, "The server didn’t answer")),
1141 )
1142 }))
1143 }),
1144 ("conflict", || Facts {
1145 conflicts: vec![(ExGuid::default(), "Packing list")],
1146 ..facts(sections(|_| status(true, 0, None)))
1147 }),
1148 ("error", || {
1149 facts(sections(|index| match index {
1150 3 => status(
1151 true,
1152 1,
1153 Some((io::ErrorKind::Other, "The section file ended early.")),
1154 ),
1155 _ => status(true, 0, None),
1156 }))
1157 }),
1158 ("in-use", || {
1159 facts(sections(|index| match index {
1160 2 => status(true, 2, Some((io::ErrorKind::ResourceBusy, "In use"))),
1161 _ => status(true, 0, None),
1162 }))
1163 }),
1164 ("read-only", || {
1165 facts(sections(|index| {
1166 let denied = (io::ErrorKind::PermissionDenied, "Read-only");
1167 status(true, u64::from(index == 0), (index == 0).then_some(denied))
1168 }))
1169 }),
1170 ("system-connection", || Facts {
1171 notice: Some("the Keychain has no password for clover on zenith"),
1172 place: vec!["clover".into(), "Documents".into()],
1173 ..facts(sections(|_| status(true, 0, None)))
1174 }),
1175 ("icloud", || Facts {
1176 name: "Personal",
1177 location: "/Users/clover/Library/Mobile Documents/com~apple~CloudDocs/Notes/Personal",
1178 place: vec!["iCloud Drive".into(), "Notes".into()],
1179 ..facts(sections(|_| status(true, 0, None)))
1180 }),
1181 ];
1182 for (appearance, theme) in [(Appearance::Light, "light"), (Appearance::Dark, "dark")] {
1183 for (name, facts) in &states {
1184 let mut ui = ui(appearance);
1185 let mut now = Instant::now();
1186 let facts = facts();
1187 settle(&mut ui, &mut now, &facts);
1188 assert!(ui.rect(id()).is_some(), "{name}");
1189 if let (Some(output), Some(gpu)) = (&output, &mut gpu) {
1190 gpu.save(&ui, &output.join(format!("{name}-{theme}.png")), None);
1191 }
1192 }
1193 // Changes made offline sending once the share is back, a section at a time, at
1194 // 30 frames a second; a quick one first never shows.
1195 let Some((output, gpu)) = output.as_ref().zip(gpu.as_mut()) else {
1196 continue;
1197 };
1198 let frames = output.join(format!("frames-{theme}"));
1199 std::fs::create_dir_all(&frames).unwrap();
1200 let mut ui = ui(appearance);
1201 let mut now = Instant::now();
1202 settle(
1203 &mut ui,
1204 &mut now,
1205 &facts(sections(|_| status(true, 0, None))),
1206 );
1207 let mut shot = 0;
1208 for tick in 0..300 {
1209 let seconds = tick as f32 / 60.0;
1210 let quick = (0.2..0.45).contains(&seconds);
1211 // Each section's waiting changes, and when they're sent.
1212 let sending = [(0, 0.0), (3, 1.4), (1, 2.6), (0, 0.0), (2, 3.3)];
1213 let facts = facts(sections(|index| {
1214 let (queued, sent) = sending[index];
1215 let queued = match seconds {
1216 _ if index == 0 && quick => 1,
1217 seconds if (0.8..sent).contains(&seconds) => queued,
1218 _ => 0,
1219 };
1220 status(true, queued, None)
1221 }));
1222 now += Duration::from_micros(16_667);
1223 frame(&mut ui, now, &facts);
1224 if tick % 2 == 0 {
1225 gpu.save(&ui, &frames.join(format!("{shot:03}.png")), Some(360.0));
1226 shot += 1;
1227 }
1228 }
1229 }
1230 }
1231}
crates/snowbound/src/themes.rs created+936
...@@ -0,0 +1,936 @@
1//! Style themes in the app (`resources/styles.md`): the page wears its own theme, else its
2//! section's, else its notebook's, as its paragraph style objects; the Styles gallery
3//! applies a style in the page's theme; the Themes dialog picks a theme for the page, the
4//! section or the notebook, and edits a notebook's own themes with a live preview.
5
6use crate::{Library, State, filetime};
7use accesskit::Role;
8use notebook::sidecar::themes::{
9 self as stored, Assignment, STYLES, Theme, ThemeStyle, Themes, built_in,
10};
11use onestore::page::Definition;
12use std::{error::Error, sync::Arc};
13use ui::{Anchor, Axis, Flags, Id, Spec, Ui, children, fill, popup::Item, px};
14
15/// What a theme is chosen for, from where the page is.
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub enum Scope {
18 Page,
19 Section,
20 Notebook,
21}
22
23impl Scope {
24 fn name(self) -> &'static str {
25 match self {
26 Self::Page => "This Page",
27 Self::Section => "This Section",
28 Self::Notebook => "This Notebook",
29 }
30 }
31}
32
33/// The Themes dialog while it is open.
34pub struct Dialog {
35 scope: Scope,
36 library: Arc<Library>,
37 target: stored::Scope,
38 /// The built-in themes, then the notebook's own as the dialog edits them.
39 themes: Vec<Theme>,
40 /// The notebook's own themes changed here, by id.
41 changed: Vec<String>,
42 /// The notebook's own themes deleted here.
43 deleted: Vec<Theme>,
44 selected: usize,
45 /// The style the controls edit, by its place in the gallery.
46 style: usize,
47}
48
49fn id() -> Id {
50 Id::ROOT.child("themes")
51}
52
53fn popup(name: &str) -> Id {
54 id().child(name)
55}
56
57/// Spacing the dialog offers above and below a style, in points.
58const SPACING: [f32; 9] = [0.0, 2.0, 3.0, 4.0, 6.0, 8.0, 10.0, 12.0, 18.0];
59
60/// A style's colour as `#rrggbb` from a COLORREF, and back.
61fn hex(colorref: u32) -> String {
62 let [red, green, blue, _] = colorref.to_le_bytes();
63 format!("#{red:02X}{green:02X}{blue:02X}")
64}
65
66fn colorref(hex: &str) -> Option<u32> {
67 let rgb = u32::from_str_radix(hex.strip_prefix('#')?, 16).ok()?;
68 Some((rgb >> 16) | (rgb & 0xff00) | ((rgb & 0xff) << 16))
69}
70
71/// A line of `text` in `style` as a page shows it, scaled down to fit a menu row up to
72/// `largest` pixels, its colour moved onto the popup's paper so it reads in either scheme.
73pub(crate) fn preview(ui: &mut Ui, part: &str, text: &str, style: &ThemeStyle, largest: f32) {
74 let theme = ui.theme.clone();
75 let paper = canvas::gpu::Paper {
76 color: theme.popup,
77 ink: theme.text,
78 };
79 let color = style
80 .color
81 .as_deref()
82 .and_then(colorref)
83 .map_or(theme.text, |color| paper.tint(canvas::gpu::colorref(color)));
84 ui.leaf(
85 part,
86 Spec {
87 flags: Flags::CLIP,
88 size: [fill(), fill()],
89 text: Some(text),
90 font: Some(&style.font),
91 font_size: Some((style.size * 1.2).clamp(10.0, largest)),
92 bold: style.bold,
93 italic: style.italic,
94 color: Some(color),
95 ..Spec::default()
96 },
97 );
98}
99
100impl State {
101 /// Where a theme for `scope` goes from the open page: its notebook and the scope's key.
102 pub(crate) fn theme_target(&self, scope: Scope) -> Option<(Arc<Library>, stored::Scope)> {
103 let session = self.session.as_ref()?;
104 let library = &session.library;
105 library.catalog()?;
106 let target = match scope {
107 Scope::Page => stored::Scope::page(self.view.editor.identity()?),
108 Scope::Section => {
109 stored::Scope::section(library.section_identity(&session.tabs[session.tab].path)?)
110 }
111 Scope::Notebook => stored::Scope::Notebook,
112 };
113 Some((Arc::clone(library), target))
114 }
115
116 /// The theme the open page wears, if any scope names one.
117 pub(crate) fn page_theme(&self) -> Option<Theme> {
118 let session = self.session.as_ref()?;
119 let section = session
120 .library
121 .section_identity(&session.tabs[session.tab].path);
122 session
123 .library
124 .themes()
125 .effective(section, self.view.editor.identity())
126 }
127
128 /// The styles the gallery offers: the page's theme's, else OneNote 2010's.
129 pub(crate) fn gallery_sheet(&self) -> Theme {
130 self.page_theme()
131 .unwrap_or_else(|| built_in().swap_remove(0))
132 }
133
134 /// Stored style `name` as the gallery applies it on the open page.
135 pub(crate) fn gallery_style(&self, name: &str) -> Definition {
136 let theme = self.gallery_sheet();
137 stored::definition(name, &theme.styles[name])
138 }
139
140 /// Dresses the open page in its theme: new text and Enter take the theme's styles, and
141 /// style objects the theme gives otherwise are restyled, as one edit. A page with no theme
142 /// keeps what it holds.
143 pub(crate) fn wear_theme(&mut self) -> Result<(), Box<dyn Error>> {
144 let theme = self.page_theme();
145 let sheet = theme.as_ref().map(Theme::sheet).unwrap_or_default();
146 self.view.editor.styles = sheet.clone();
147 let Some(session) = &self.session else {
148 return Ok(());
149 };
150 if theme.is_none() || session.read_only() {
151 return Ok(());
152 }
153 let page = session.section.page(session.space)?;
154 let ops = onestore::op::restyle(&page, &sheet)?;
155 if ops.is_empty() {
156 return Ok(());
157 }
158 self.persist()?;
159 let Some(session) = &self.session else {
160 return Ok(());
161 };
162 let space = session.space;
163 session.section.apply(
164 &self.author,
165 onestore::op::Edit {
166 at: filetime(),
167 ops: ops
168 .into_iter()
169 .map(|op| onestore::op::Op::Page { space, op })
170 .collect(),
171 },
172 )?;
173 self.edited(vec![space]);
174 self.refresh()
175 }
176
177 /// Opens the Themes dialog choosing a theme for `scope` of the open page.
178 pub(crate) fn open_themes(&mut self, scope: Scope) {
179 let Some((library, target)) = self.theme_target(scope) else {
180 return;
181 };
182 self.show_themes(scope, library, target);
183 }
184
185 /// Opens the Themes dialog choosing a theme for `target`, which `scope` names.
186 pub(crate) fn show_themes(
187 &mut self,
188 scope: Scope,
189 library: Arc<Library>,
190 target: stored::Scope,
191 ) {
192 let themes = library.themes();
193 let assigned = themes.assigned(&target).map(|theme| theme.id);
194 let all = themes.all();
195 let selected = assigned
196 .and_then(|id| all.iter().position(|theme| theme.id == id))
197 .unwrap_or(0);
198 self.themes = Some(Dialog {
199 scope,
200 library,
201 target,
202 themes: all,
203 changed: Vec::new(),
204 deleted: Vec::new(),
205 selected,
206 style: 0,
207 });
208 self.ui.open_popup(id());
209 }
210
211 /// Builds the Themes dialog while it is open. Apply keeps the edits and gives the theme
212 /// to the dialog's scope; No Theme takes the scope's own away; Cancel, Escape or a press
213 /// outside leave everything as it was.
214 pub(crate) fn themes_dialog(&mut self) {
215 let Some(dialog) = &mut self.themes else {
216 return;
217 };
218 let ui = &mut self.ui;
219 if !ui.popup_open(id()) {
220 self.themes = None;
221 return;
222 }
223 let theme = ui.theme.clone();
224 let row = theme.font_size * 2.0;
225 ui.open_as(
226 id(),
227 Spec {
228 axis: Axis::Y,
229 size: [px(760.0), children()],
230 fill: Some(theme.popup),
231 border: Some(theme.chip),
232 shadow: Some(theme.shadow),
233 radius: 8.0,
234 pad: [10.0, 10.0],
235 gap: 8.0,
236 anchor: Some(Anchor::Dialog),
237 role: Some(Role::Dialog),
238 ..Spec::default()
239 },
240 );
241 if let Some(node) = ui.access(id()) {
242 node.set_label("Themes");
243 }
244 let title = format!("Theme for {}", dialog.scope.name());
245 ui.leaf(
246 "title",
247 Spec {
248 size: [fill(), px(row)],
249 text: Some(&title),
250 bold: true,
251 role: Some(Role::Heading),
252 ..Spec::default()
253 },
254 );
255 ui.open(
256 "body",
257 Spec {
258 size: [fill(), px(420.0)],
259 gap: 10.0,
260 ..Spec::default()
261 },
262 );
263 // The themes, built-in first.
264 ui.open(
265 "list",
266 Spec {
267 axis: Axis::Y,
268 size: [px(170.0), fill()],
269 fill: Some(theme.panel),
270 radius: 5.0,
271 pad: [6.0, 6.0],
272 gap: 2.0,
273 role: Some(Role::ListBox),
274 ..Spec::default()
275 },
276 );
277 for (index, listed) in dialog.themes.iter().enumerate() {
278 let shown = index == dialog.selected;
279 let item = ui.open(
280 ("theme", index),
281 Spec {
282 flags: Flags::CLICKABLE,
283 size: [fill(), px(row)],
284 fill: shown.then(|| theme.hover()),
285 hover_fill: Some(theme.hover()),
286 radius: 4.0,
287 pad: [8.0, 0.0],
288 role: Some(Role::ListBoxOption),
289 ..Spec::default()
290 },
291 );
292 preview(ui, "name", &listed.name, &listed.styles["h2"], 15.0);
293 if let Some(node) = ui.access(item) {
294 node.set_label(listed.name.as_str());
295 node.set_selected(shown);
296 }
297 ui.close();
298 if ui.signal(item).clicked {
299 dialog.selected = index;
300 }
301 }
302 ui.leaf(
303 "space",
304 Spec {
305 size: [fill(), fill()],
306 ..Spec::default()
307 },
308 );
309 ui.open(
310 "list buttons",
311 Spec {
312 size: [fill(), children()],
313 gap: 6.0,
314 ..Spec::default()
315 },
316 );
317 let duplicate = ui::button(ui, "duplicate", "Duplicate").clicked;
318 let delete =
319 dialog.selected >= built_in().len() && ui::button(ui, "delete", "Delete").clicked;
320 ui.close();
321 ui.close();
322 // The selected theme's styles.
323 ui.open(
324 "editor",
325 Spec {
326 axis: Axis::Y,
327 size: [px(270.0), fill()],
328 gap: 6.0,
329 ..Spec::default()
330 },
331 );
332 let built = dialog.selected < built_in().len();
333 if built {
334 ui.leaf(
335 "locked",
336 Spec {
337 size: [fill(), px(row * 1.5)],
338 text: Some("Changing a built-in theme edits a copy."),
339 color: Some(theme.text_dim),
340 ..Spec::default()
341 },
342 );
343 } else {
344 let edited = &mut dialog.themes[dialog.selected];
345 let before = edited.name.clone();
346 ui::text_field(
347 ui,
348 popup("name"),
349 &mut edited.name,
350 "Theme name",
351 Spec {
352 size: [fill(), px(row)],
353 fill: Some(theme.base),
354 border: Some(theme.chip),
355 radius: 4.0,
356 pad: [6.0, 0.0],
357 ..Spec::default()
358 },
359 );
360 if edited.name != before && !dialog.changed.contains(&edited.id) {
361 dialog.changed.push(edited.id.clone());
362 }
363 }
364 // Each control edits the style shown; a built-in theme is copied first.
365 let mut change: Option<Change> = None;
366 let shown = dialog.themes[dialog.selected].clone();
367 let (name, label) = STYLES[dialog.style];
368 let style = &shown.styles[name];
369 let field = |ui: &mut Ui, label: &str, build: &mut dyn FnMut(&mut Ui)| {
370 ui.open(
371 label,
372 Spec {
373 size: [fill(), px(row)],
374 gap: 8.0,
375 ..Spec::default()
376 },
377 );
378 ui.leaf(
379 "label",
380 Spec {
381 size: [px(64.0), px(row)],
382 text: Some(label),
383 ..Spec::default()
384 },
385 );
386 build(ui);
387 ui.close();
388 };
389 field(ui, "Style:", &mut |ui| {
390 let combo = ui.id("combo");
391 ui::shell::combo(ui, "combo", "Style", label, 180.0, popup("styles"), true);
392 let items: Vec<Item> = STYLES
393 .iter()
394 .enumerate()
395 .map(|(at, (_, label))| Item {
396 text: label,
397 checked: Some(at == dialog.style),
398 current: at == dialog.style,
399 ..Item::default()
400 })
401 .collect();
402 let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default());
403 if let Some(index) = ui::popup::menu(ui, popup("styles"), anchor, &items, None) {
404 dialog.style = index;
405 }
406 });
407 let fonts = &self.fonts;
408 field(ui, "Font:", &mut |ui| {
409 let combo = ui.id("combo");
410 ui::shell::combo(
411 ui,
412 "combo",
413 "Font",
414 &style.font,
415 180.0,
416 popup("fonts"),
417 true,
418 );
419 let mut names: Vec<&str> = crate::FONTS.to_vec();
420 names.push("Georgia");
421 names.extend(fonts.iter().map(String::as_str));
422 let items: Vec<Item> = names
423 .iter()
424 .map(|font| Item {
425 text: font,
426 font: Some(font),
427 current: *font == style.font,
428 ..Item::default()
429 })
430 .collect();
431 let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default());
432 if let Some(index) = ui::popup::menu(ui, popup("fonts"), anchor, &items, Some("Font")) {
433 let font = names[index].to_owned();
434 change = Some(Change::Font(font));
435 }
436 });
437 field(ui, "Size:", &mut |ui| {
438 let combo = ui.id("combo");
439 let size = format!("{}", style.size);
440 ui::shell::combo(ui, "combo", "Size", &size, 60.0, popup("sizes"), true);
441 let labels: Vec<String> = crate::SIZES.iter().map(|size| format!("{size}")).collect();
442 let items: Vec<Item> = labels
443 .iter()
444 .map(|label| Item {
445 text: label,
446 checked: Some(*label == size),
447 current: *label == size,
448 ..Item::default()
449 })
450 .collect();
451 let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default());
452 if let Some(index) = ui::popup::menu(ui, popup("sizes"), anchor, &items, None) {
453 let size = crate::SIZES[index];
454 change = Some(Change::Size(size));
455 }
456 });
457 field(ui, "", &mut |ui| {
458 if ui::check_box(ui, "bold", "Bold", style.bold).clicked {
459 change = Some(Change::Bold);
460 }
461 if ui::check_box(ui, "italic", "Italic", style.italic).clicked {
462 change = Some(Change::Italic);
463 }
464 });
465 field(ui, "Color:", &mut |ui| {
466 let button = ui.id("color");
467 let shown = style.color.clone().unwrap_or_else(|| "Automatic".into());
468 ui::shell::combo(ui, "color", "Color", &shown, 120.0, popup("colors"), true);
469 let swatches: Vec<_> = crate::FONT_COLORS
470 .iter()
471 .map(|&(color, name)| (canvas::gpu::colorref(color), name))
472 .collect();
473 let anchor = Anchor::Below(ui.rect(button).unwrap_or_default());
474 if let Some(chosen) =
475 ui::popup::colors(ui, popup("colors"), anchor, "Automatic", &swatches, 10)
476 {
477 let color = chosen.and_then(|chosen| {
478 crate::FONT_COLORS
479 .iter()
480 .zip(&swatches)
481 .find(|(_, (swatch, _))| *swatch == chosen)
482 .map(|((color, _), _)| hex(*color))
483 });
484 change = Some(Change::Color(color));
485 }
486 });
487 for (part, label, value) in [
488 ("before", "Above:", style.before),
489 ("after", "Below:", style.after),
490 ] {
491 field(ui, label, &mut |ui| {
492 let combo = ui.id("combo");
493 let shown = format!("{value} pt");
494 ui::shell::combo(ui, "combo", label, &shown, 80.0, popup(part), true);
495 let labels: Vec<String> = SPACING
496 .iter()
497 .map(|points| format!("{points} pt"))
498 .collect();
499 let items: Vec<Item> = labels
500 .iter()
501 .map(|text| Item {
502 text,
503 checked: Some(*text == shown),
504 current: *text == shown,
505 ..Item::default()
506 })
507 .collect();
508 let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default());
509 if let Some(index) = ui::popup::menu(ui, popup(part), anchor, &items, None) {
510 let points = SPACING[index];
511 change = Some(Change::Spacing(part == "before", points));
512 }
513 });
514 }
515 ui.close();
516 // The preview: every style as a page in the theme shows it.
517 ui.open(
518 "preview",
519 Spec {
520 axis: Axis::Y,
521 size: [fill(), fill()],
522 fill: Some(theme.popup),
523 border: Some(theme.chip),
524 radius: 5.0,
525 pad: [14.0, 10.0],
526 role: Some(Role::Group),
527 ..Spec::default()
528 },
529 );
530 for (at, (name, label)) in STYLES.iter().enumerate() {
531 let style = &shown.styles[*name];
532 let height = (style.size * 1.2).clamp(10.0, 26.0) * 1.35
533 + (style.before + style.after).min(12.0);
534 let line = ui.open(
535 ("line", at),
536 Spec {
537 flags: Flags::CLICKABLE,
538 size: [fill(), px(height)],
539 fill: (at == dialog.style).then(|| theme.hover()),
540 radius: 3.0,
541 pad: [4.0, (style.before.min(12.0)) / 2.0],
542 ..Spec::default()
543 },
544 );
545 preview(ui, "text", label, style, 26.0);
546 ui.close();
547 if ui.signal(line).clicked {
548 dialog.style = at;
549 }
550 }
551 ui.close();
552 ui.close();
553 ui.open(
554 "buttons",
555 Spec {
556 size: [fill(), children()],
557 gap: 8.0,
558 ..Spec::default()
559 },
560 );
561 let none = ui::button(ui, "none", "No Theme").clicked;
562 ui.leaf(
563 "space",
564 Spec {
565 size: [fill(), px(1.0)],
566 ..Spec::default()
567 },
568 );
569 let cancel = ui::button(ui, "cancel", "Cancel").clicked;
570 let apply = ui::button(ui, "apply", "Apply").clicked;
571 ui.close();
572 ui.close();
573
574 let now = filetime();
575 let copy = |dialog: &mut Dialog, name: String| {
576 let mut copy = dialog.themes[dialog.selected].clone();
577 copy.id = fresh_id(now, dialog.themes.len());
578 copy.name = name;
579 copy.modified = now;
580 dialog.changed.push(copy.id.clone());
581 dialog.themes.push(copy);
582 dialog.selected = dialog.themes.len() - 1;
583 };
584 if duplicate {
585 let name = format!("{} Copy", dialog.themes[dialog.selected].name);
586 copy(dialog, name);
587 }
588 if let Some(change) = change {
589 if dialog.selected < built_in().len() {
590 let name = format!("My {}", dialog.themes[dialog.selected].name);
591 copy(dialog, name);
592 }
593 let edited = &mut dialog.themes[dialog.selected];
594 change.apply(
595 edited
596 .styles
597 .get_mut(name)
598 .expect("a theme has every style"),
599 );
600 if !dialog.changed.contains(&edited.id) {
601 dialog.changed.push(edited.id.clone());
602 }
603 }
604 if delete && dialog.selected >= built_in().len() {
605 let mut gone = dialog.themes.remove(dialog.selected);
606 gone.deleted = true;
607 dialog.deleted.push(gone);
608 dialog.selected = 0;
609 }
610 if !(apply || none || cancel) {
611 return;
612 }
613 if !cancel {
614 let themes = dialog
615 .themes
616 .iter()
617 .filter(|listed| dialog.changed.contains(&listed.id))
618 .chain(&dialog.deleted)
619 .map(|listed| Theme {
620 modified: now,
621 ..listed.clone()
622 })
623 .collect();
624 let chosen = &dialog.themes[dialog.selected];
625 let assignment = Assignment {
626 scope: dialog.target.clone(),
627 theme: apply.then(|| chosen.id.clone()),
628 assigned: now,
629 };
630 dialog.library.save_themes(Themes {
631 themes,
632 assignments: vec![assignment],
633 });
634 }
635 self.ui.close_popup(id());
636 self.themes = None;
637 if !cancel && let Err(error) = self.wear_theme() {
638 eprintln!("Restyling the page failed: {error}");
639 }
640 }
641}
642
643/// An edit the dialog's controls make to the style they show.
644enum Change {
645 Font(String),
646 Size(f32),
647 Bold,
648 Italic,
649 Color(Option<String>),
650 /// Space above (true) or below, in points.
651 Spacing(bool, f32),
652}
653
654impl Change {
655 fn apply(self, style: &mut ThemeStyle) {
656 match self {
657 Self::Font(font) => style.font = font,
658 Self::Size(size) => style.size = size,
659 Self::Bold => style.bold = !style.bold,
660 Self::Italic => style.italic = !style.italic,
661 Self::Color(color) => style.color = color,
662 Self::Spacing(true, points) => style.before = points,
663 Self::Spacing(false, points) => style.after = points,
664 }
665 }
666}
667
668/// A new theme's id: unique to this machine's clock and the themes it knows.
669fn fresh_id(now: u64, count: usize) -> String {
670 format!("{now:x}-{count}")
671}
672
673/// The Styles gallery under the toolbar's Styles button: the eleven styles drawn in
674/// `sheet`, the one at the caret outlined, then the theme commands. Returns the command chosen.
675pub(crate) fn gallery(
676 ui: &mut Ui,
677 menu: Id,
678 anchor: Anchor,
679 sheet: &Theme,
680 current: Option<&str>,
681) -> Option<crate::commands::Id> {
682 use crate::commands::Id as Cmd;
683 let scopes = [Scope::Page, Scope::Section, Scope::Notebook];
684 let groups = [
685 ui::popup::Group {
686 heading: &sheet.name,
687 cells: STYLES.len(),
688 columns: 1,
689 size: [240.0, 34.0],
690 },
691 ui::popup::Group {
692 heading: "Theme",
693 cells: scopes.len(),
694 columns: 1,
695 size: [240.0, 24.0],
696 },
697 ];
698 let shown: Vec<usize> = STYLES
699 .iter()
700 .position(|(name, _)| Some(*name) == current)
701 .into_iter()
702 .collect();
703 let chosen = ui::popup::gallery(ui, menu, anchor, &groups, &shown, |ui, index| match STYLES
704 .get(index)
705 {
706 Some((name, label)) => {
707 preview(ui, "preview", label, &sheet.styles[*name], 24.0);
708 if let Some(node) = ui.access(ui.id("preview")) {
709 node.set_label(*label);
710 }
711 }
712 None => {
713 let label = format!("{}…", scopes[index - STYLES.len()].name());
714 ui.leaf(
715 "scope",
716 Spec {
717 size: [fill(), fill()],
718 text: Some(&label),
719 ..Spec::default()
720 },
721 );
722 }
723 })?;
724 Some(match scopes.get(chosen.wrapping_sub(STYLES.len())) {
725 Some(scope) => Cmd::Theme(*scope),
726 None => Cmd::Style(chosen),
727 })
728}
729
730#[cfg(test)]
731mod tests {
732 use super::*;
733 use canvas::{
734 document::TextPosition,
735 editor::{CanvasEditor, Formatting},
736 layout::TextEngine,
737 };
738 use onestore::{
739 RevisionIndex, Store,
740 document::{Document, Kind},
741 op,
742 page::Page,
743 };
744
745 fn page(bytes: &[u8]) -> (onestore::ExGuid, Page) {
746 let store = Store::parse(bytes).unwrap();
747 let index = RevisionIndex::parse(&store).unwrap();
748 let document = Document::parse(&index).unwrap();
749 let (space, _) = document.pages().unwrap()[0];
750 (space, Page::from_space(&document, space).unwrap())
751 }
752
753 /// The name of each paragraph's style on `page`, with its definition, in page order.
754 fn styled(page: &Page) -> Vec<(String, Definition)> {
755 let mut out = Vec::new();
756 let mut pending: Vec<&onestore::page::PageParagraph> = Vec::new();
757 for object in &page.objects {
758 match object {
759 onestore::page::PageObject::Outline(outline) => {
760 pending.extend(outline.paragraphs.iter());
761 }
762 onestore::page::PageObject::Title(title) => {
763 pending.extend(title.outlines.iter().flat_map(|o| &o.paragraphs));
764 }
765 _ => {}
766 }
767 }
768 for paragraph in pending {
769 let Some(style) = paragraph.style else {
770 continue;
771 };
772 let definition = page.definitions[&style].clone();
773 if let Kind::Style {
774 name: Some(name), ..
775 } = &definition.kind
776 {
777 out.push((name.clone(), definition));
778 }
779 }
780 out
781 }
782
783 /// A section per built-in theme, each a page with a paragraph in every gallery style,
784 /// applied through the editor as the gallery does and the title restyled as opening the
785 /// page does: every style object holds the theme's definition under OneNote's name, the
786 /// next paragraph after a heading is Normal, and a second theme restyles the page whole.
787 /// `SNOWBOUND_STYLES_EXPORT` names a new directory receiving the notebook, its themes
788 /// assigned by section, for a cold open in OneNote (`corpus/styles`).
789 #[test]
790 fn themed_pages_hold_their_theme_under_onenote_s_names() {
791 let temporary =
792 std::env::temp_dir().join(format!("snowbound-styles-{}", std::process::id()));
793 let _ = std::fs::remove_dir_all(&temporary);
794 let root = temporary.join("Styles");
795 std::fs::create_dir_all(&root).unwrap();
796 let mut engine = TextEngine::default();
797 let mut sections = Vec::new();
798 for theme in built_in() {
799 let file = format!("{}.one", theme.name);
800 let source = onestore::create_section(&file, "", "Author").unwrap();
801 let (space, before) = page(&source);
802 let mut editor = CanvasEditor::from_page(before, &mut engine).unwrap();
803 let sheet = theme.sheet();
804 editor.styles = sheet.clone();
805 let body = editor
806 .outlines()
807 .iter()
808 .find(|outline| !outline.title)
809 .unwrap()
810 .id;
811 editor.focus_outline(body).unwrap();
812 for (paragraph, (name, label)) in STYLES.iter().enumerate() {
813 if paragraph > 0 {
814 editor.enter(&mut engine, false).unwrap();
815 }
816 editor
817 .insert(&mut engine, &format!("{label} in {}", theme.name))
818 .unwrap();
819 editor
820 .select(
821 [TextPosition {
822 paragraph,
823 offset: 0,
824 }; 2]
825 .into(),
826 )
827 .unwrap();
828 editor
829 .format(&mut engine, Formatting::Style(sheet[*name].clone()))
830 .unwrap();
831 let offset = editor.outlines()[editor
832 .outlines()
833 .iter()
834 .position(|outline| outline.id == body)
835 .unwrap()]
836 .document()
837 .paragraphs()
838 .nth(paragraph)
839 .unwrap()
840 .text()
841 .encode_utf16()
842 .count() as u32;
843 editor
844 .select([TextPosition { paragraph, offset }; 2].into())
845 .unwrap();
846 }
847 let arena = onestore::Arena::default();
848 let mut section = onestore::Section::open(&arena, source.to_vec()).unwrap();
849 let edit = |ops: Vec<op::PageOp>, at| op::Edit {
850 at,
851 ops: ops
852 .into_iter()
853 .map(|op| op::Op::Page { space, op })
854 .collect(),
855 };
856 section
857 .apply(
858 "Author",
859 &edit(editor.take_ops().unwrap(), 134_000_000_000_000_000),
860 )
861 .unwrap();
862 let typed = section.page(space).unwrap();
863 let restyle = op::restyle(&typed, &sheet).unwrap();
864 section
865 .apply("Author", &edit(restyle, 134_000_000_000_000_001))
866 .unwrap();
867 section.seal().unwrap();
868 let written = section.image();
869 let (_, stored) = page(&written);
870 let styles: Vec<_> = styled(&stored)
871 .into_iter()
872 .filter(|(name, _)| sheet.contains_key(name))
873 .collect();
874 let names: std::collections::BTreeSet<&str> =
875 styles.iter().map(|(name, _)| name.as_str()).collect();
876 assert_eq!(names.len(), STYLES.len(), "{}", theme.name);
877 for (name, definition) in &styles {
878 assert_eq!(definition, &sheet[name], "{} {name}", theme.name);
879 }
880 assert!(op::restyle(&stored, &sheet).unwrap().is_empty());
881 // Another theme restyles the page whole, keeping the names.
882 let other = built_in().swap_remove(3).sheet();
883 let mut copy = onestore::Section::open(&arena, written.clone()).unwrap();
884 copy.apply(
885 "Author",
886 &edit(
887 op::restyle(&stored, &other).unwrap(),
888 134_000_000_000_000_002,
889 ),
890 )
891 .unwrap();
892 for (name, definition) in styled(&copy.page(space).unwrap()) {
893 if !other.contains_key(&name) {
894 continue;
895 }
896 assert_eq!(definition, other[&name], "{} to modern {name}", theme.name);
897 }
898 std::fs::write(root.join(&file), &written).unwrap();
899 let file_id = Store::parse(&written).unwrap().header.file_id;
900 sections.push((file, file_id, theme.id));
901 }
902 let listed: Vec<(&str, [u8; 16])> = sections
903 .iter()
904 .map(|(file, id, _)| (file.as_str(), *id))
905 .collect();
906 let toc = onestore::create_table_of_contents("Open Notebook.onetoc2", &listed).unwrap();
907 std::fs::write(root.join("Open Notebook.onetoc2"), toc).unwrap();
908 let notebook = notebook::session::Notebook::open(&root, temporary.join("cache")).unwrap();
909 let themes = notebook
910 .save_themes(Themes {
911 assignments: sections
912 .iter()
913 .map(|(_, id, theme)| Assignment {
914 scope: stored::Scope::section(*id),
915 theme: Some(theme.clone()),
916 assigned: 134_000_000_000_000_000,
917 })
918 .collect(),
919 ..Themes::default()
920 })
921 .unwrap();
922 for (_, id, theme) in &sections {
923 assert_eq!(&themes.effective(Some(*id), None).unwrap().id, theme);
924 }
925 if let Some(directory) = std::env::var_os("SNOWBOUND_STYLES_EXPORT") {
926 let status = std::process::Command::new("cp")
927 .arg("-R")
928 .arg(&root)
929 .arg(directory)
930 .status()
931 .unwrap();
932 assert!(status.success());
933 }
934 std::fs::remove_dir_all(&temporary).unwrap();
935 }
936}
crates/snowbound/src/undo.rs created+1630
...@@ -0,0 +1,1630 @@
1//! Undo and Redo across pages, sections and notebooks (`arc/canvas.md`). Each page keeps
2//! its own history in its editor, kept while the window is open as OneNote 2010 keeps it; the
3//! window keeps what was done to pages and sections between those edits, so Undo takes back
4//! whichever came last where the user is. Every step taken back is an edit of its own.
5
6use crate::{Command, Library, Loaded, State, UserEvent};
7use canvas::editor::CanvasEditor;
8use notebook::{Replica, discover::Folder};
9use onestore::{
10 ExGuid, PageCreation, PageEdit,
11 op::{Edit, Op, SectionOp},
12 page::{Page, PageObject},
13};
14use std::{error::Error, sync::Arc};
15
16/// Pages whose editors are kept with their history once left, at most.
17const KEPT: usize = 50;
18
19#[derive(Default)]
20pub struct Timeline {
21 undo: Vec<Step>,
22 redo: Vec<Step>,
23 /// Editors of pages left holding history, the longest left first.
24 parked: Vec<(ExGuid, CanvasEditor)>,
25 /// Actions on their way, while which Undo and Redo wait.
26 busy: usize,
27}
28
29enum Step {
30 /// One step of a page's own history, which its editor holds.
31 Edit(ExGuid),
32 Action(Action),
33}
34
35impl Step {
36 fn edits(&self, page: ExGuid) -> bool {
37 matches!(self, Step::Edit(edited) if *edited == page)
38 }
39}
40
41/// A change to pages or sections and where it was made: a notebook, by location, and
42/// for a change to pages, the section by file identity. Undo reaches it there.
43pub struct Action {
44 notebook: String,
45 section: Option<[u8; 16]>,
46 change: Change,
47}
48
49/// What an action does when taken; doing one gives the action that takes it back.
50pub enum Change {
51 /// Takes pages out of the section, to the recycle bin where the notebook has one. Pages
52 /// `created` go only while they hold nothing, and never to the bin. Shows `show` after,
53 /// else the page after the first one taken, or before it.
54 Delete {
55 pages: Vec<ExGuid>,
56 show: Option<ExGuid>,
57 created: bool,
58 },
59 /// Puts pages back where they were, taking them out of the recycle bin unless `created`,
60 /// and takes out `added`, the page a section left without pages gained, while it holds
61 /// nothing.
62 Restore {
63 pages: Vec<Kept>,
64 added: Option<ExGuid>,
65 created: bool,
66 },
67 /// Puts the pages `moved` back as `order` lists the section's pages, with their levels.
68 Arrange {
69 order: Vec<(ExGuid, u32)>,
70 moved: Vec<ExGuid>,
71 },
72 /// Titles a page `to` while its title is `from`.
73 Retitle {
74 page: ExGuid,
75 from: String,
76 to: String,
77 },
78 /// Moves a page to the end of the section `target` names, as a drop on its tab does.
79 Send { page: ExGuid, target: [u8; 16] },
80 /// Brings a page sent to `target` back, before `before` at `level`, taking out `added`
81 /// while it holds nothing.
82 Return {
83 page: ExGuid,
84 target: [u8; 16],
85 before: Option<ExGuid>,
86 level: u32,
87 added: Option<ExGuid>,
88 },
89 /// Names a section or group `to` while it is named `from`.
90 Rename {
91 entry: [u8; 16],
92 from: String,
93 to: String,
94 },
95 /// Puts a section or group, `entry`, in the folder `folder` names (the notebook's own
96 /// without), which then takes `order`.
97 Place {
98 entry: Option<[u8; 16]>,
99 folder: Option<[u8; 16]>,
100 order: Vec<[u8; 16]>,
101 },
102}
103
104/// A page taken out of its section, and where it was: before `before` at `level`.
105pub struct Kept {
106 page: Page,
107 before: Option<ExGuid>,
108 level: u32,
109}
110
111/// Where the user is: the open section's notebook, by location, and the section.
112pub struct Here {
113 notebook: String,
114 section: [u8; 16],
115}
116
117impl Action {
118 fn applies(&self, here: &Here) -> bool {
119 self.notebook == here.notebook && self.section.is_none_or(|section| section == here.section)
120 }
121}
122
123/// What Undo or Redo takes next.
124pub enum Next {
125 /// The open page's last edit, or the last undone.
126 Edit,
127 Action(Action),
128 /// The page a created page Undo would take out still holds edits made since, so
129 /// Undo shows it, where those go back first.
130 Show(ExGuid),
131}
132
133impl Timeline {
134 /// Notes `action`, just done; what Redo held of actions goes.
135 pub fn record(&mut self, action: Action) {
136 self.undo.push(Step::Action(action));
137 self.redo.retain(|step| matches!(step, Step::Edit(_)));
138 }
139
140 /// Notes `action`, which takes back one the user just did (`taken` none), or Undo, or
141 /// `taken` Redo, just took.
142 fn note(&mut self, taken: Option<bool>, action: Action) {
143 match taken {
144 None => self.record(action),
145 Some(true) => self.undo.push(Step::Action(action)),
146 Some(false) => self.redo.push(Step::Action(action)),
147 }
148 }
149
150 /// Follows page `page`'s history, `depth` steps of Undo and Redo, after an edit or
151 /// before one is taken: steps it gained are the newest; `run`, a run of typing that
152 /// went on, makes its last step the newest.
153 pub fn edited(&mut self, page: ExGuid, [undo, redo]: [usize; 2], run: bool) {
154 let count = |steps: &[Step]| steps.iter().filter(|step| step.edits(page)).count();
155 let (had, had_redo) = (count(&self.undo), count(&self.redo));
156 if undo < had {
157 drop_newest(&mut self.undo, page, had - undo);
158 } else if undo > had || run {
159 // A run of typing that went on makes its step the newest.
160 if undo == had
161 && let Some(at) = self.undo.iter().rposition(|step| step.edits(page))
162 {
163 let step = self.undo.remove(at);
164 self.undo.push(step);
165 }
166 self.undo.extend((had..undo).map(|_| Step::Edit(page)));
167 // A new edit ends what actions Redo held.
168 self.redo.retain(|step| matches!(step, Step::Edit(_)));
169 }
170 if redo < had_redo {
171 drop_newest(&mut self.redo, page, had_redo - redo);
172 } else {
173 self.redo.extend((had_redo..redo).map(|_| Step::Edit(page)));
174 }
175 }
176
177 /// What Undo, or `redo` Redo, takes next on page `page`, its history `depth` steps deep,
178 /// with `here` open: the newer of the page's own step and the last action made there.
179 /// An action is taken off the timeline; an edit stays until `stepped`.
180 pub fn next(
181 &mut self,
182 redo: bool,
183 page: ExGuid,
184 depth: [usize; 2],
185 here: &Here,
186 ) -> Option<Next> {
187 if self.busy > 0 {
188 return None;
189 }
190 self.edited(page, depth, false);
191 let steps = if redo { &mut self.redo } else { &mut self.undo };
192 let at = steps.iter().rposition(|step| match step {
193 Step::Edit(edited) => *edited == page,
194 Step::Action(action) => action.applies(here),
195 })?;
196 match &steps[at] {
197 Step::Edit(_) => return Some(Next::Edit),
198 Step::Action(Action {
199 change:
200 Change::Delete {
201 pages,
202 created: true,
203 ..
204 },
205 ..
206 }) if !redo => {
207 let edited = pages
208 .iter()
209 .find(|page| steps.iter().any(|step| step.edits(**page)));
210 if let Some(edited) = edited {
211 return Some(Next::Show(*edited));
212 }
213 }
214 Step::Action(_) => {}
215 }
216 match steps.remove(at) {
217 Step::Action(action) => Some(Next::Action(action)),
218 Step::Edit(_) => unreachable!("an edit is not taken off"),
219 }
220 }
221
222 /// Notes that Undo, or `redo` Redo, took page `page`'s step.
223 pub fn stepped(&mut self, page: ExGuid, redo: bool) {
224 let (from, to) = if redo {
225 (&mut self.redo, &mut self.undo)
226 } else {
227 (&mut self.undo, &mut self.redo)
228 };
229 if let Some(at) = from.iter().rposition(|step| step.edits(page)) {
230 to.push(from.remove(at));
231 }
232 }
233
234 /// Whether Undo, or `redo` Redo, has an action to take `here`.
235 pub fn reaches(&self, redo: bool, here: &Here) -> bool {
236 let steps = if redo { &self.redo } else { &self.undo };
237 self.busy == 0
238 && steps
239 .iter()
240 .any(|step| matches!(step, Step::Action(action) if action.applies(here)))
241 }
242
243 /// Keeps the editor of page `page`, just left, for its history.
244 pub fn park(&mut self, page: ExGuid, editor: CanvasEditor) {
245 self.parked.retain(|(parked, _)| *parked != page);
246 if editor.history_depth() == [0, 0] || editor.marked_range().is_some() {
247 return self.forget(&[page]);
248 }
249 self.parked.push((page, editor));
250 if self.parked.len() > KEPT {
251 let (oldest, _) = self.parked.remove(0);
252 self.forget(&[oldest]);
253 }
254 }
255
256 /// The editor page `page` was left with, which takes up its history again.
257 pub fn resume(&mut self, page: ExGuid) -> Option<CanvasEditor> {
258 let at = self.parked.iter().position(|(parked, _)| *parked == page)?;
259 Some(self.parked.remove(at).1)
260 }
261
262 /// Drops the histories of pages gone.
263 pub fn forget(&mut self, pages: &[ExGuid]) {
264 self.parked.retain(|(parked, _)| !pages.contains(parked));
265 for steps in [&mut self.undo, &mut self.redo] {
266 steps.retain(|step| !matches!(step, Step::Edit(page) if pages.contains(page)));
267 }
268 }
269
270 /// Drops the actions of the notebook at `location`, closed.
271 pub fn close(&mut self, location: &str) {
272 for steps in [&mut self.undo, &mut self.redo] {
273 steps.retain(
274 |step| !matches!(step, Step::Action(action) if action.notebook == location),
275 );
276 }
277 }
278}
279
280/// Removes the `count` newest steps of page `page`.
281fn drop_newest(steps: &mut Vec<Step>, page: ExGuid, count: usize) {
282 for _ in 0..count {
283 if let Some(at) = steps.iter().rposition(|step| step.edits(page)) {
284 steps.remove(at);
285 }
286 }
287}
288
289/// Whether `page` holds nothing but its title, untitled.
290fn blank(page: &Page) -> bool {
291 page.title.trim().is_empty()
292 && page
293 .objects
294 .iter()
295 .all(|object| matches!(object, PageObject::Title(_)))
296}
297
298/// The page shown once `gone` leave the section listing `listed`: `prefer` if it stays,
299/// else the page after the first one gone, or before it, as OneNote 2010 shows.
300fn neighbor(
301 listed: &[(ExGuid, String, u32)],
302 gone: &[ExGuid],
303 prefer: Option<ExGuid>,
304) -> Option<ExGuid> {
305 let stays = |space: &ExGuid| !gone.contains(space) && listed.iter().any(|(s, ..)| s == space);
306 if let Some(prefer) = prefer.filter(stays) {
307 return Some(prefer);
308 }
309 let at = listed
310 .iter()
311 .position(|(space, ..)| gone.contains(space))
312 .unwrap_or_default();
313 listed[at..]
314 .iter()
315 .chain(listed[..at].iter().rev())
316 .map(|(space, ..)| *space)
317 .find(stays)
318}
319
320/// The space of the page an import op creates.
321fn imported(op: &Op) -> ExGuid {
322 match op {
323 Op::Section(SectionOp::Import { creation, .. }) => creation.space(),
324 _ => unreachable!("notebook::session::moved imports"),
325 }
326}
327
328/// What a change to pages works with: the open section, its notebook, who changes it, and
329/// a page to give it should it be left without pages.
330struct Site {
331 replica: Arc<Replica>,
332 library: Arc<Library>,
333 author: String,
334 fresh: PageCreation,
335}
336
337/// A change to pages done: the change taking it back, the page to show, and pages gone.
338struct Applied {
339 undo: Change,
340 show: ExGuid,
341 gone: Vec<ExGuid>,
342}
343
344impl Site {
345 fn apply(&self, ops: Vec<Op>) -> Result<(), Box<dyn Error>> {
346 self.replica.apply(
347 &self.author,
348 Edit {
349 at: crate::filetime(),
350 ops,
351 },
352 )?;
353 Ok(())
354 }
355
356 /// The catalog path of the section `identity` names.
357 fn section_path(&self, identity: [u8; 16]) -> Option<String> {
358 entry_of(self.library.catalog()?, identity).map(|(_, path)| path)
359 }
360
361 /// Does `change` to the section's pages, `shown` the page open; `None` where what it
362 /// changes is gone or holds what it did not make, which it leaves alone.
363 fn change(&self, change: Change, shown: ExGuid) -> Result<Option<Applied>, Box<dyn Error>> {
364 let listed = self.replica.pages()?;
365 let at = |space: ExGuid| listed.iter().position(|(listed, ..)| *listed == space);
366 let binned = matches!(self.library.notebook, Ok(Some(_)));
367 Ok(Some(match change {
368 Change::Delete {
369 pages,
370 show,
371 created,
372 } => {
373 let pages: Vec<ExGuid> = pages
374 .into_iter()
375 .filter(|page| at(*page).is_some())
376 .collect();
377 let read: Vec<Page> = pages
378 .iter()
379 .map(|page| self.replica.page(*page))
380 .collect::<Result<_, _>>()?;
381 if pages.is_empty() || created && !read.iter().all(blank) {
382 return Ok(None);
383 }
384 let kept: Vec<Kept> = pages
385 .iter()
386 .zip(read)
387 .map(|(space, page)| {
388 let at = at(*space).expect("listed");
389 Kept {
390 page,
391 before: listed[at + 1..]
392 .iter()
393 .map(|(space, ..)| *space)
394 .find(|space| !pages.contains(space)),
395 level: listed[at].2,
396 }
397 })
398 .collect();
399 let mut ops = vec![Op::Section(SectionOp::Delete(pages.clone()))];
400 let (show, added) = match neighbor(&listed, &pages, show) {
401 Some(show) => (show, None),
402 None => {
403 let space = self.fresh.space();
404 ops.push(Op::Section(SectionOp::Create(self.fresh.clone())));
405 (space, Some(space))
406 }
407 };
408 if !created && binned {
409 let pages: Vec<Page> = kept.iter().map(|kept| kept.page.clone()).collect();
410 self.library.reopen()?.recycle_pages(&pages, &self.author)?;
411 }
412 self.apply(ops)?;
413 Applied {
414 undo: Change::Restore {
415 pages: kept,
416 added,
417 created,
418 },
419 show,
420 gone: pages,
421 }
422 }
423 Change::Restore {
424 pages,
425 added,
426 created,
427 } => {
428 // A page someone put back already, listed by its identity, stays as it is.
429 let identity = |space: ExGuid| self.replica.page(space).ok()?.identity;
430 let mut back = Vec::new();
431 for kept in pages {
432 let listed_again = kept.page.identity.is_some()
433 && listed.iter().any(|(space, title, _)| {
434 *title == kept.page.title && identity(*space) == kept.page.identity
435 });
436 if !listed_again {
437 back.push(kept);
438 }
439 }
440 if back.is_empty() {
441 return Ok(None);
442 }
443 let mut ops = Vec::new();
444 let mut spaces = Vec::new();
445 for kept in &back {
446 let import = notebook::session::moved(&kept.page, &self.author)?;
447 let space = imported(&import);
448 let before = kept.before.filter(|before| at(*before).is_some());
449 ops.push(import);
450 ops.push(Op::Section(SectionOp::Pages(vec![PageEdit::move_to(
451 space, before, kept.level,
452 )?])));
453 spaces.push(space);
454 }
455 let gone: Vec<ExGuid> = added
456 .filter(|added| {
457 at(*added).is_some() && self.replica.page(*added).is_ok_and(|p| blank(&p))
458 })
459 .into_iter()
460 .collect();
461 if !gone.is_empty() {
462 ops.push(Op::Section(SectionOp::Delete(gone.clone())));
463 }
464 self.apply(ops)?;
465 if !created && binned {
466 let identities: Vec<[u8; 16]> =
467 back.iter().filter_map(|kept| kept.page.identity).collect();
468 self.library.reopen()?.unrecycle_pages(&identities)?;
469 }
470 Applied {
471 undo: Change::Delete {
472 pages: spaces.clone(),
473 show: Some(shown),
474 created,
475 },
476 show: spaces[0],
477 gone,
478 }
479 }
480 Change::Arrange { order, moved } => {
481 let moving = |space: &ExGuid| moved.contains(space) && at(*space).is_some();
482 let mut edits = Vec::new();
483 for (index, (space, level)) in order.iter().enumerate() {
484 if !moving(space) {
485 continue;
486 }
487 let before = order[index + 1..]
488 .iter()
489 .map(|(space, _)| *space)
490 .find(|space| !moved.contains(space) && at(*space).is_some());
491 edits.push(PageEdit::move_to(*space, before, *level)?);
492 }
493 let Some(show) = edits.first().map(PageEdit::space) else {
494 return Ok(None);
495 };
496 self.apply(vec![Op::Section(SectionOp::Pages(edits))])?;
497 Applied {
498 undo: Change::Arrange {
499 order: listed
500 .iter()
501 .map(|(space, _, level)| (*space, *level))
502 .collect(),
503 moved,
504 },
505 show,
506 gone: Vec::new(),
507 }
508 }
509 Change::Retitle { page, from, to } => {
510 if at(page).is_none_or(|at| listed[at].1.trim() != from.trim()) {
511 return Ok(None);
512 }
513 let op = crate::rename::retitled(&self.replica.page(page)?, page, to.clone())?;
514 self.apply(vec![op])?;
515 Applied {
516 undo: Change::Retitle {
517 page,
518 from: to,
519 to: from,
520 },
521 show: page,
522 gone: Vec::new(),
523 }
524 }
525 Change::Send { page, target } => {
526 let (Some(from), Some(path)) = (at(page), self.section_path(target)) else {
527 return Ok(None);
528 };
529 let import = notebook::session::moved(&self.replica.page(page)?, &self.author)?;
530 let arrived = imported(&import);
531 let before = listed[from + 1..].first().map(|(space, ..)| *space);
532 let level = listed[from].2;
533 let mut ops = vec![Op::Section(SectionOp::Delete(vec![page]))];
534 let (show, added) = match neighbor(&listed, &[page], None) {
535 Some(show) => (show, None),
536 None => {
537 let space = self.fresh.space();
538 ops.push(Op::Section(SectionOp::Create(self.fresh.clone())));
539 (space, Some(space))
540 }
541 };
542 let section = self.library.open(&path, || {})?;
543 section.replica().apply(
544 &self.author,
545 Edit {
546 at: crate::filetime(),
547 ops: vec![import],
548 },
549 )?;
550 section.close()?;
551 self.apply(ops)?;
552 Applied {
553 undo: Change::Return {
554 page: arrived,
555 target,
556 before,
557 level,
558 added,
559 },
560 show,
561 gone: vec![page],
562 }
563 }
564 Change::Return {
565 page,
566 target,
567 before,
568 level,
569 added,
570 } => {
571 let Some(path) = self.section_path(target) else {
572 return Ok(None);
573 };
574 let section = self.library.open(&path, || {})?;
575 let sent = match section.pages()?.iter().any(|(listed, ..)| *listed == page) {
576 true => Some(section.page(page)?),
577 false => None,
578 };
579 let Some(sent) = sent else {
580 section.close()?;
581 return Ok(None);
582 };
583 let import = notebook::session::moved(&sent, &self.author)?;
584 let back = imported(&import);
585 let mut ops = vec![
586 import,
587 Op::Section(SectionOp::Pages(vec![PageEdit::move_to(
588 back,
589 before.filter(|before| at(*before).is_some()),
590 level,
591 )?])),
592 ];
593 let gone: Vec<ExGuid> = added
594 .filter(|added| {
595 at(*added).is_some() && self.replica.page(*added).is_ok_and(|p| blank(&p))
596 })
597 .into_iter()
598 .collect();
599 if !gone.is_empty() {
600 ops.push(Op::Section(SectionOp::Delete(gone.clone())));
601 }
602 self.apply(ops)?;
603 section.replica().apply(
604 &self.author,
605 Edit {
606 at: crate::filetime(),
607 ops: vec![Op::Section(SectionOp::Delete(vec![page]))],
608 },
609 )?;
610 section.close()?;
611 Applied {
612 undo: Change::Send { page: back, target },
613 show: back,
614 gone,
615 }
616 }
617 Change::Rename { .. } | Change::Place { .. } => {
618 unreachable!("a section's own change is the notebook's")
619 }
620 }))
621 }
622}
623
624/// The identity of each section and group in `folder`, in order, with its catalog path;
625/// the recycle bin is left out.
626fn entries(folder: &Folder) -> Vec<([u8; 16], String)> {
627 folder
628 .sections
629 .iter()
630 .map(|section| (section.file_id, section.path.clone()))
631 .chain(folder.groups.iter().filter_map(|group| {
632 let toc = group.toc.as_ref()?;
633 (!crate::library::recycle_bin(&group.path)).then(|| (toc.file_id, group.path.clone()))
634 }))
635 .collect()
636}
637
638/// The identity of a group's folder; none for the notebook's own.
639fn folder_id(folder: &Folder) -> Option<[u8; 16]> {
640 let toc = folder.toc.as_ref()?;
641 (!folder.path.is_empty()).then_some(toc.file_id)
642}
643
644/// Every folder of `catalog`, the notebook's own first.
645fn folders(catalog: &Folder) -> Vec<&Folder> {
646 let mut all = vec![catalog];
647 let mut at = 0;
648 while let Some(folder) = all.get(at) {
649 all.extend(&folder.groups);
650 at += 1;
651 }
652 all
653}
654
655/// The folder `identity` names, the notebook's own without one.
656fn folder_of(catalog: &Folder, identity: Option<[u8; 16]>) -> Option<&Folder> {
657 match identity {
658 Some(_) => folders(catalog)
659 .into_iter()
660 .find(|folder| folder_id(folder) == identity),
661 None => Some(catalog),
662 }
663}
664
665/// The section or group `identity` names: its folder and catalog path.
666fn entry_of(catalog: &Folder, identity: [u8; 16]) -> Option<(&Folder, String)> {
667 folders(catalog).into_iter().find_map(|folder| {
668 let (_, path) = entries(folder)
669 .into_iter()
670 .find(|(id, _)| *id == identity)?;
671 Some((folder, path))
672 })
673}
674
675/// The identity of the section or group at catalog `path`.
676fn identity_at(catalog: &Folder, path: &str) -> Option<[u8; 16]> {
677 folders(catalog).into_iter().find_map(|folder| {
678 let (id, _) = entries(folder).into_iter().find(|(_, at)| at == path)?;
679 Some(id)
680 })
681}
682
683/// A section or group's name, as its catalog path ends.
684fn name_of(path: &str) -> &str {
685 let name = path.rsplit('/').next().unwrap_or(path);
686 name.strip_suffix(".one").unwrap_or(name)
687}
688
689/// The action taking back `change` to `library`'s sections and groups, which is about to be
690/// made; none for changes Undo does not take back, as OneNote 2010 takes back none.
691pub fn structure_undo(library: &Library, change: &crate::manage::Structure) -> Option<Action> {
692 use crate::manage::Structure;
693 let catalog = library.catalog()?;
694 let change = match change {
695 Structure::Rename { path, name } => Change::Rename {
696 entry: identity_at(catalog, path)?,
697 from: name.clone(),
698 to: name_of(path).to_owned(),
699 },
700 Structure::Move { path, .. } => {
701 let entry = identity_at(catalog, path)?;
702 let (folder, _) = entry_of(catalog, entry)?;
703 Change::Place {
704 entry: Some(entry),
705 folder: folder_id(folder),
706 order: entries(folder).into_iter().map(|(id, _)| id).collect(),
707 }
708 }
709 Structure::Reorder { folder, .. } => {
710 let folder = folders(catalog)
711 .into_iter()
712 .find(|listed| listed.path == *folder)?;
713 Change::Place {
714 entry: None,
715 folder: folder_id(folder),
716 order: entries(folder).into_iter().map(|(id, _)| id).collect(),
717 }
718 }
719 _ => return None,
720 };
721 Some(Action {
722 notebook: library.location.clone(),
723 section: None,
724 change,
725 })
726}
727
728/// The structure change carrying out a section or group's `change` in `library` now, with
729/// the action taking it back; none where what it changes is gone or was changed since.
730fn restructuring(library: &Library, change: &Change) -> Option<(crate::manage::Structure, Action)> {
731 use crate::manage::Structure;
732 let catalog = library.catalog()?;
733 let (structure, undo) = match change {
734 Change::Rename { entry, from, to } => {
735 let (_, path) = entry_of(catalog, *entry)?;
736 if name_of(&path) != from {
737 return None;
738 }
739 (
740 Structure::Rename {
741 path,
742 name: to.clone(),
743 },
744 Change::Rename {
745 entry: *entry,
746 from: to.clone(),
747 to: from.clone(),
748 },
749 )
750 }
751 Change::Place {
752 entry,
753 folder,
754 order,
755 } => {
756 let target = folder_of(catalog, *folder)?;
757 let placed = match entry {
758 Some(entry) => Some(entry_of(catalog, *entry)?),
759 None => None,
760 };
761 // The folder the entry is in now, which the action taking this back reorders.
762 let now = placed.as_ref().map_or(target, |(folder, _)| *folder);
763 let undo = Change::Place {
764 entry: *entry,
765 folder: folder_id(now),
766 order: entries(now).into_iter().map(|(id, _)| id).collect(),
767 };
768 let moving = placed.filter(|(from, _)| from.path != target.path);
769 let arrived = moving.as_ref().map(|(_, path)| {
770 let name = path.rsplit('/').next().unwrap_or(path);
771 match target.path.as_str() {
772 "" => name.to_owned(),
773 folder => format!("{folder}/{name}"),
774 }
775 });
776 let current = entries(target);
777 let paths: Vec<String> = order
778 .iter()
779 .filter_map(|id| match &arrived {
780 Some(arrived) if Some(*id) == *entry => Some(arrived.clone()),
781 _ => current
782 .iter()
783 .find(|(at, _)| at == id)
784 .map(|(_, path)| path.clone()),
785 })
786 .collect();
787 let structure = match moving {
788 Some((_, path)) => Structure::Return {
789 path,
790 folder: target.path.clone(),
791 paths,
792 },
793 None => Structure::Reorder {
794 folder: target.path.clone(),
795 paths,
796 },
797 };
798 (structure, undo)
799 }
800 _ => return None,
801 };
802 Some((
803 structure,
804 Action {
805 notebook: library.location.clone(),
806 section: None,
807 change: undo,
808 },
809 ))
810}
811
812impl State {
813 /// The open section and its notebook, where Undo reaches. A section opened on its own
814 /// is its notebook's only one.
815 fn here(&self) -> Option<Here> {
816 let session = self.session.as_ref()?;
817 let path = &session.tabs.get(session.tab)?.path;
818 Some(Here {
819 notebook: session.library.location.clone(),
820 section: session.library.section_identity(path).unwrap_or_default(),
821 })
822 }
823
824 /// Whether Undo, or `redo` Redo, has something to take.
825 pub(crate) fn can_step(&self, redo: bool) -> bool {
826 let editor = &self.view.editor;
827 (if redo {
828 editor.can_redo()
829 } else {
830 editor.can_undo()
831 }) || self
832 .here()
833 .is_some_and(|here| self.undo.reaches(redo, &here))
834 }
835
836 /// Undo, or `redo` Redo: the open page's last edit or the last action made where the
837 /// user is, whichever came last.
838 pub(crate) fn step(&mut self, redo: bool) -> Result<(), Box<dyn Error>> {
839 self.persist()?;
840 let depth = self.view.editor.history_depth();
841 // A page kept in no notebook has only its own history.
842 let shown = self.session.as_ref().map(|session| session.space);
843 let next = match (self.here(), shown) {
844 (Some(here), Some(page)) => self.undo.next(redo, page, depth, &here),
845 _ => Some(Next::Edit),
846 };
847 match next {
848 None => {}
849 Some(Next::Edit) => {
850 let response = self.view.undo(redo)?;
851 self.respond(response);
852 if let Some(page) = shown
853 && self.view.editor.history_depth() != depth
854 {
855 self.undo.stepped(page, redo);
856 }
857 }
858 Some(Next::Show(page)) => self.commands.push(Command::OpenPage(page)),
859 Some(Next::Action(action)) => self.perform(action, Some(redo))?,
860 }
861 Ok(())
862 }
863
864 /// Does `action`: one the user asked for, or that Undo, or `taken` Redo, took. What
865 /// takes it back goes on the timeline.
866 pub(crate) fn perform(
867 &mut self,
868 action: Action,
869 taken: Option<bool>,
870 ) -> Result<(), Box<dyn Error>> {
871 let Action {
872 notebook,
873 section,
874 change,
875 } = action;
876 let Some(section) = section else {
877 let library = self
878 .notebooks
879 .iter()
880 .find(|library| library.location == notebook)
881 .cloned()
882 .ok_or("That notebook is closed")?;
883 let Some((structure, undo)) = restructuring(&library, &change) else {
884 return match taken {
885 Some(redo) => self.step(redo),
886 None => Ok(()),
887 };
888 };
889 self.undo.note(taken, undo);
890 self.restructure(library, structure);
891 return Ok(());
892 };
893 self.persist()?;
894 let session = self.session.as_ref().ok_or("No section is open")?;
895 let site = Site {
896 replica: Arc::clone(session.section.replica()),
897 library: Arc::clone(&session.library),
898 author: self.author.clone(),
899 fresh: self.dated_page(None)?,
900 };
901 let shown = session.space;
902 let proxy = self.proxy.clone();
903 self.undo.busy += 1;
904 self.load(move || {
905 let applied = site.change(change, shown);
906 let show = match &applied {
907 Ok(Some(applied)) => applied.show,
908 _ => shown,
909 };
910 let failed = applied.as_ref().err().map(ToString::to_string);
911 let (stale, applied) = (failed.is_none(), applied.ok().flatten());
912 let _ = proxy.send_event(UserEvent::Then(Box::new(move |state: &mut State| {
913 state.undo.busy -= 1;
914 let Some(applied) = applied else {
915 // What it would change is gone: Undo goes on to the step before.
916 return match taken {
917 Some(redo) if stale => state.step(redo),
918 _ => Ok(()),
919 };
920 };
921 state.undo.forget(&applied.gone);
922 let undo = Action {
923 notebook,
924 section: Some(section),
925 change: applied.undo,
926 };
927 state.undo.note(taken, undo);
928 state.edited(Vec::new());
929 Ok(())
930 })));
931 if let Some(failed) = failed {
932 return Err(failed.into());
933 }
934 Ok(Loaded::Page(show, site.replica.page(show)?))
935 });
936 Ok(())
937 }
938
939 /// An action the user takes on the open section's pages: `change`.
940 pub(crate) fn change_pages(&mut self, change: Change) -> Result<(), Box<dyn Error>> {
941 let here = self.here().ok_or("No section is open")?;
942 self.perform(
943 Action {
944 notebook: here.notebook,
945 section: Some(here.section),
946 change,
947 },
948 None,
949 )
950 }
951
952 /// Notes `change`, just made on the open section's pages, for Undo to take back.
953 pub(crate) fn made(&mut self, change: Change) {
954 if let Some(here) = self.here() {
955 self.undo.record(Action {
956 notebook: here.notebook,
957 section: Some(here.section),
958 change,
959 });
960 }
961 }
962}
963
964#[cfg(test)]
965mod tests {
966 use super::*;
967 use notebook::session::Notebook;
968
969 fn page(n: u32) -> ExGuid {
970 ExGuid { guid: [9; 16], n }
971 }
972
973 const SECTION: [u8; 16] = [1; 16];
974
975 fn here() -> Here {
976 Here {
977 notebook: "/n".into(),
978 section: SECTION,
979 }
980 }
981
982 fn created(space: ExGuid) -> Action {
983 Action {
984 notebook: "/n".into(),
985 section: Some(SECTION),
986 change: Change::Delete {
987 pages: vec![space],
988 show: None,
989 created: true,
990 },
991 }
992 }
993
994 fn is_action(next: Option<Next>) -> bool {
995 matches!(next, Some(Next::Action(_)))
996 }
997
998 fn is_edit(next: Option<Next>) -> bool {
999 matches!(next, Some(Next::Edit))
1000 }
1001
1002 /// Cmd+N, a title typed, then Undo: the title goes, then the page.
1003 #[test]
1004 fn undo_takes_back_a_new_page_after_its_title() {
1005 let mut timeline = Timeline::default();
1006 let (old, new) = (page(1), page(2));
1007 timeline.edited(old, [3, 0], false);
1008 timeline.record(created(new));
1009 timeline.edited(new, [1, 0], false);
1010 assert!(is_edit(timeline.next(false, new, [1, 0], &here())));
1011 timeline.stepped(new, false);
1012 assert!(is_action(timeline.next(false, new, [0, 1], &here())));
1013 // The page shown before keeps its own history.
1014 assert!(is_edit(timeline.next(false, old, [3, 0], &here())));
1015 }
1016
1017 /// The newer of the page's own step and the last action goes first, as OneNote 2010
1018 /// takes back a page deleted after typing on the page it then shows.
1019 #[test]
1020 fn the_newer_of_page_and_action_goes_first() {
1021 let mut timeline = Timeline::default();
1022 let shown = page(1);
1023 timeline.edited(shown, [1, 0], false);
1024 timeline.record(created(page(5)));
1025 assert!(is_action(timeline.next(false, shown, [1, 0], &here())));
1026 timeline.edited(shown, [2, 0], false);
1027 timeline.record(created(page(6)));
1028 // Typing that goes on a run started before the action is newer than it.
1029 timeline.edited(shown, [2, 0], true);
1030 assert!(is_edit(timeline.next(false, shown, [2, 0], &here())));
1031 }
1032
1033 /// Another page's edits are its own; a new page holding edits is shown before it goes.
1034 #[test]
1035 fn a_created_page_with_edits_is_shown_first() {
1036 let mut timeline = Timeline::default();
1037 let (other, new) = (page(1), page(2));
1038 timeline.record(created(new));
1039 timeline.edited(new, [2, 0], false);
1040 assert!(matches!(
1041 timeline.next(false, other, [0, 0], &here()),
1042 Some(Next::Show(shown)) if shown == new
1043 ));
1044 // Its edits taken back there, it goes.
1045 timeline.stepped(new, false);
1046 timeline.stepped(new, false);
1047 assert!(is_action(timeline.next(false, other, [0, 0], &here())));
1048 }
1049
1050 /// Actions are reached where they were made: a page change in its section, a section
1051 /// change anywhere in its notebook.
1052 #[test]
1053 fn actions_are_reached_where_they_were_made() {
1054 let mut timeline = Timeline::default();
1055 timeline.record(created(page(2)));
1056 timeline.record(Action {
1057 notebook: "/n".into(),
1058 section: None,
1059 change: Change::Rename {
1060 entry: [3; 16],
1061 from: "B".into(),
1062 to: "A".into(),
1063 },
1064 });
1065 let elsewhere = Here {
1066 notebook: "/n".into(),
1067 section: [2; 16],
1068 };
1069 let other = Here {
1070 notebook: "/m".into(),
1071 section: SECTION,
1072 };
1073 assert!(!timeline.reaches(false, &other));
1074 assert!(matches!(
1075 timeline.next(false, page(7), [0, 0], &elsewhere),
1076 Some(Next::Action(Action {
1077 change: Change::Rename { .. },
1078 ..
1079 }))
1080 ));
1081 assert!(timeline.next(false, page(7), [0, 0], &elsewhere).is_none());
1082 assert!(is_action(timeline.next(false, page(7), [0, 0], &here())));
1083 timeline.record(created(page(3)));
1084 timeline.close("/n");
1085 assert!(!timeline.reaches(false, &here()));
1086 }
1087
1088 /// Redo takes back the last undone first; a new edit ends what actions it held, and
1089 /// waits while an action is on its way.
1090 #[test]
1091 fn redo_mirrors_undo_and_new_edits_end_it() {
1092 let mut timeline = Timeline::default();
1093 let shown = page(1);
1094 timeline.record(created(page(2)));
1095 let Some(Next::Action(action)) = timeline.next(false, shown, [0, 0], &here()) else {
1096 panic!()
1097 };
1098 timeline.note(Some(false), action);
1099 timeline.edited(shown, [1, 0], false);
1100 assert!(is_edit(timeline.next(false, shown, [1, 0], &here())));
1101 timeline.stepped(shown, false);
1102 assert!(is_edit(timeline.next(true, shown, [0, 1], &here())));
1103 // The undone action went with the edit made after it.
1104 timeline.stepped(shown, true);
1105 timeline.stepped(shown, false);
1106 assert!(!timeline.reaches(true, &here()));
1107 timeline.record(created(page(3)));
1108 timeline.busy = 1;
1109 assert!(timeline.next(false, shown, [0, 1], &here()).is_none());
1110 }
1111
1112 const AUTHOR: &str = "Rust Author";
1113
1114 fn dated() -> PageCreation {
1115 PageCreation::new(None, Some(""), AUTHOR)
1116 .unwrap()
1117 .dated("Wednesday, September 30, 2026", "7:04 PM")
1118 .unwrap()
1119 }
1120
1121 /// A notebook as the app makes one, its first section holding pages titled `titles`
1122 /// and open, beside a section "B"; deleted when dropped.
1123 struct Fixture {
1124 folder: std::path::PathBuf,
1125 library: Arc<Library>,
1126 section: Option<notebook::session::Section>,
1127 }
1128
1129 impl Fixture {
1130 fn new(name: &str, titles: &[&str]) -> Self {
1131 let folder =
1132 std::env::temp_dir().join(format!("snowbound-undo-{name}-{}", std::process::id()));
1133 let _ = std::fs::remove_dir_all(&folder);
1134 std::fs::create_dir_all(&folder).unwrap();
1135 let root = folder.join("Notebook");
1136 let mut notebook =
1137 Notebook::create(&root, folder.join("cache"), Notebook::NEW_COLOR, &dated())
1138 .unwrap();
1139 notebook.create_section("", "B", &dated()).unwrap();
1140 let library = Arc::new(Library::created(
1141 root.to_str().unwrap(),
1142 notebook,
1143 &folder.join("cache"),
1144 ));
1145 let section = library.open("New Section 1.one", || {}).unwrap();
1146 let fixture = Self {
1147 folder,
1148 library,
1149 section: Some(section),
1150 };
1151 let first = fixture.pages()[0].0;
1152 fixture.retitle(first, titles[0]);
1153 for title in &titles[1..] {
1154 let creation = dated();
1155 let space = creation.space();
1156 fixture.edit(vec![Op::Section(SectionOp::Create(creation))]);
1157 fixture.retitle(space, title);
1158 }
1159 fixture
1160 }
1161
1162 fn section(&self) -> &notebook::session::Section {
1163 self.section.as_ref().unwrap()
1164 }
1165
1166 fn site(&self) -> Site {
1167 Site {
1168 replica: Arc::clone(self.section().replica()),
1169 library: Arc::clone(&self.library),
1170 author: AUTHOR.into(),
1171 fresh: dated(),
1172 }
1173 }
1174
1175 /// Another device's edit, or the user's outside Undo.
1176 fn edit(&self, ops: Vec<Op>) {
1177 self.section()
1178 .replica()
1179 .apply(
1180 AUTHOR,
1181 Edit {
1182 at: crate::filetime(),
1183 ops,
1184 },
1185 )
1186 .unwrap();
1187 }
1188
1189 fn retitle(&self, space: ExGuid, title: &str) {
1190 let page = self.section().page(space).unwrap();
1191 self.edit(vec![
1192 crate::rename::retitled(&page, space, title.into()).unwrap(),
1193 ]);
1194 }
1195
1196 fn pages(&self) -> Vec<(ExGuid, String, u32)> {
1197 self.section().pages().unwrap()
1198 }
1199
1200 fn titles(&self) -> Vec<(String, u32)> {
1201 self.pages()
1202 .into_iter()
1203 .map(|(_, title, level)| (title, level))
1204 .collect()
1205 }
1206
1207 fn space(&self, title: &str) -> ExGuid {
1208 self.pages()
1209 .into_iter()
1210 .find(|(_, listed, _)| listed == title)
1211 .unwrap()
1212 .0
1213 }
1214
1215 /// The titles in the recycle bin's Deleted Pages.
1216 fn binned(&self) -> Vec<String> {
1217 let file = self
1218 .folder
1219 .join("Notebook/OneNote_RecycleBin/OneNote_DeletedPages.one");
1220 std::fs::read(file).map_or_else(
1221 |_| Vec::new(),
1222 |bytes| {
1223 notebook::session::stored_pages(&bytes)
1224 .unwrap()
1225 .into_iter()
1226 .map(|stored| stored.page.title)
1227 .collect()
1228 },
1229 )
1230 }
1231
1232 /// Does `change`, as the app's thread does, returning what takes it back.
1233 fn change(&self, change: Change) -> Option<Applied> {
1234 let shown = self.pages()[0].0;
1235 self.site().change(change, shown).unwrap()
1236 }
1237 }
1238
1239 impl Drop for Fixture {
1240 fn drop(&mut self) {
1241 if let Some(section) = self.section.take() {
1242 let _ = section.close();
1243 }
1244 let _ = std::fs::remove_dir_all(&self.folder);
1245 }
1246 }
1247
1248 fn titled(titles: &[&str]) -> Vec<(String, u32)> {
1249 titles.iter().map(|title| (title.to_string(), 1)).collect()
1250 }
1251
1252 /// A page deleted goes to the recycle bin; Undo puts it back where it was and takes it
1253 /// out of the bin, as OneNote 2010 does; Redo deletes it again. A page another device
1254 /// put back meanwhile is left alone.
1255 #[test]
1256 fn deleted_pages_come_back_where_they_were_and_leave_the_bin() {
1257 let fixture = Fixture::new("delete", &["First", "Second", "Third"]);
1258 let second = fixture.space("Second");
1259 let deleted = fixture
1260 .change(Change::Delete {
1261 pages: vec![second],
1262 show: None,
1263 created: false,
1264 })
1265 .unwrap();
1266 assert_eq!(fixture.titles(), titled(&["First", "Third"]));
1267 assert_eq!(deleted.show, fixture.space("Third"));
1268 assert_eq!(fixture.binned(), ["Second"]);
1269 let restored = fixture.change(deleted.undo).unwrap();
1270 assert_eq!(fixture.titles(), titled(&["First", "Second", "Third"]));
1271 assert_eq!(restored.show, fixture.space("Second"));
1272 assert!(fixture.binned().is_empty());
1273 let again = fixture.change(restored.undo).unwrap();
1274 assert_eq!(fixture.titles(), titled(&["First", "Third"]));
1275 assert_eq!(fixture.binned(), ["Second"]);
1276
1277 // Put back elsewhere first, the page stays as that device left it.
1278 let Change::Restore { pages, .. } = &again.undo else {
1279 panic!()
1280 };
1281 let copy = notebook::session::moved(&pages[0].page, AUTHOR).unwrap();
1282 fixture.edit(vec![copy]);
1283 assert!(fixture.change(again.undo).is_none());
1284 assert_eq!(fixture.titles(), titled(&["First", "Third", "Second"]));
1285 }
1286
1287 /// New Page taken back: the page goes for good while it holds nothing, the page shown
1288 /// before it shows again, and Redo brings it back. Once another device wrote on it,
1289 /// it stays.
1290 #[test]
1291 fn a_new_page_goes_only_while_it_holds_nothing() {
1292 let fixture = Fixture::new("create", &["First", "Second"]);
1293 let creation = dated();
1294 let new = creation.space();
1295 fixture.edit(vec![Op::Section(SectionOp::Create(creation))]);
1296 let first = fixture.space("First");
1297 let removed = fixture
1298 .change(Change::Delete {
1299 pages: vec![new],
1300 show: Some(first),
1301 created: true,
1302 })
1303 .unwrap();
1304 assert_eq!(fixture.titles(), titled(&["First", "Second"]));
1305 assert_eq!(removed.show, first);
1306 assert!(fixture.binned().is_empty());
1307 let back = fixture.change(removed.undo).unwrap();
1308 assert_eq!(fixture.titles(), titled(&["First", "Second", ""]));
1309 let Change::Delete { pages, .. } = &back.undo else {
1310 panic!()
1311 };
1312 fixture.retitle(pages[0], "Written elsewhere");
1313 assert!(fixture.change(back.undo).is_none());
1314 assert_eq!(
1315 fixture.titles(),
1316 titled(&["First", "Second", "Written elsewhere"])
1317 );
1318 }
1319
1320 /// Pages moved and indented go back to their places and levels, before the next page
1321 /// still there when another device deleted the one they followed.
1322 #[test]
1323 fn arranged_pages_go_back_before_the_next_page_still_there() {
1324 let fixture = Fixture::new("arrange", &["A", "B", "C", "D"]);
1325 let order: Vec<(ExGuid, u32)> = fixture
1326 .pages()
1327 .into_iter()
1328 .map(|(space, _, level)| (space, level))
1329 .collect();
1330 let [a, b, c, d] = ["A", "B", "C", "D"].map(|title| fixture.space(title));
1331 fixture.edit(vec![Op::Section(SectionOp::Pages(vec![
1332 PageEdit::move_to(a, None, 1).unwrap(),
1333 PageEdit::set_level(c, 2).unwrap(),
1334 ]))]);
1335 assert_eq!(
1336 fixture.titles(),
1337 [("B", 1), ("C", 2), ("D", 1), ("A", 1)].map(|(t, l)| (t.to_owned(), l))
1338 );
1339 let arrange = Change::Arrange {
1340 order: order.clone(),
1341 moved: vec![a, c],
1342 };
1343 let arranged = fixture.change(arrange).unwrap();
1344 assert_eq!(fixture.titles(), titled(&["A", "B", "C", "D"]));
1345 assert_eq!(arranged.show, a);
1346 fixture.change(arranged.undo).unwrap();
1347 fixture.edit(vec![Op::Section(SectionOp::Delete(vec![b]))]);
1348 fixture
1349 .change(Change::Arrange {
1350 order,
1351 moved: vec![a, c, d],
1352 })
1353 .unwrap();
1354 assert_eq!(fixture.titles(), titled(&["A", "C", "D"]));
1355 }
1356
1357 /// A page sent to another section comes back where it was and leaves that section;
1358 /// gone from there meanwhile, it does not.
1359 #[test]
1360 fn a_page_sent_away_comes_back() {
1361 let fixture = Fixture::new("send", &["First", "Second", "Third"]);
1362 let target = fixture.library.section_identity("B.one").unwrap();
1363 let in_b = |fixture: &Fixture| {
1364 let section = fixture.library.open("B.one", || {}).unwrap();
1365 let titles: Vec<String> = section
1366 .pages()
1367 .unwrap()
1368 .into_iter()
1369 .map(|(_, title, _)| title)
1370 .collect();
1371 section.close().unwrap();
1372 titles
1373 };
1374 let sent = fixture
1375 .change(Change::Send {
1376 page: fixture.space("Second"),
1377 target,
1378 })
1379 .unwrap();
1380 assert_eq!(fixture.titles(), titled(&["First", "Third"]));
1381 assert_eq!(in_b(&fixture), ["", "Second"]);
1382 let returned = fixture.change(sent.undo).unwrap();
1383 assert_eq!(fixture.titles(), titled(&["First", "Second", "Third"]));
1384 assert_eq!(in_b(&fixture), [""]);
1385 let sent = fixture.change(returned.undo).unwrap();
1386 let Change::Return { page, .. } = &sent.undo else {
1387 panic!()
1388 };
1389 let section = fixture.library.open("B.one", || {}).unwrap();
1390 section.delete_pages(&[*page]).unwrap();
1391 section.close().unwrap();
1392 assert!(fixture.change(sent.undo).is_none());
1393 assert_eq!(fixture.titles(), titled(&["First", "Third"]));
1394 }
1395
1396 /// A title given in the page list goes back while the page still has it.
1397 #[test]
1398 fn a_title_goes_back_while_the_page_has_it() {
1399 let fixture = Fixture::new("retitle", &["First", "Renamed"]);
1400 let page = fixture.space("Renamed");
1401 let back = fixture
1402 .change(Change::Retitle {
1403 page,
1404 from: "Renamed".into(),
1405 to: "Second".into(),
1406 })
1407 .unwrap();
1408 assert_eq!(fixture.titles(), titled(&["First", "Second"]));
1409 fixture.retitle(page, "Changed elsewhere");
1410 assert!(fixture.change(back.undo).is_none());
1411 }
1412
1413 /// Carries out `change` as `manage::State::restructure` does.
1414 fn carry(notebook: &mut Notebook, change: crate::manage::Structure) {
1415 use crate::manage::Structure;
1416 match change {
1417 Structure::Rename { path, name } => drop(notebook.rename(&path, &name).unwrap()),
1418 Structure::Move { path, folder } => drop(notebook.move_entry(&path, &folder).unwrap()),
1419 Structure::Reorder { folder, paths } => {
1420 let paths: Vec<&str> = paths.iter().map(String::as_str).collect();
1421 notebook.reorder(&folder, &paths).unwrap();
1422 }
1423 Structure::Return {
1424 path,
1425 folder,
1426 paths,
1427 } => {
1428 notebook.move_entry(&path, &folder).unwrap();
1429 let paths: Vec<&str> = paths.iter().map(String::as_str).collect();
1430 notebook.reorder(&folder, &paths).unwrap();
1431 }
1432 _ => unreachable!(),
1433 }
1434 }
1435
1436 /// Sections renamed, reordered and moved into a group go back, the moved one to its
1437 /// place among the others; renamed again elsewhere, a section keeps that name.
1438 #[test]
1439 fn sections_go_back_to_their_names_and_places() {
1440 use crate::manage::Structure;
1441 let folder =
1442 std::env::temp_dir().join(format!("snowbound-undo-sections-{}", std::process::id()));
1443 let _ = std::fs::remove_dir_all(&folder);
1444 std::fs::create_dir_all(&folder).unwrap();
1445 let root = folder.join("Notebook");
1446 let mut notebook =
1447 Notebook::create(&root, folder.join("cache"), Notebook::NEW_COLOR, &dated()).unwrap();
1448 for name in ["B", "C"] {
1449 notebook.create_section("", name, &dated()).unwrap();
1450 }
1451 notebook.create_group("", "Group").unwrap();
1452 let order = |notebook: &Notebook, folder: &str| -> Vec<String> {
1453 let folder = folders(notebook.catalog())
1454 .into_iter()
1455 .find(|listed| listed.path == folder)
1456 .unwrap();
1457 entries(folder).into_iter().map(|(_, path)| path).collect()
1458 };
1459 let opened = Library::created(root.to_str().unwrap(), notebook, &folder.join("cache"));
1460 let library = |notebook: Notebook| opened.with(notebook);
1461 let mut notebook = opened.reopen().unwrap();
1462 let mut steps = Vec::new();
1463 for change in [
1464 Structure::Rename {
1465 path: "B.one".into(),
1466 name: "Kitchen".into(),
1467 },
1468 Structure::Reorder {
1469 folder: String::new(),
1470 paths: vec!["C.one".into(), "New Section 1.one".into()],
1471 },
1472 Structure::Move {
1473 path: "New Section 1.one".into(),
1474 folder: "Group".into(),
1475 },
1476 ] {
1477 let shown = library(notebook);
1478 steps.push(structure_undo(&shown, &change).unwrap());
1479 notebook = shown.reopen().unwrap();
1480 carry(&mut notebook, change);
1481 }
1482 assert_eq!(order(&notebook, ""), ["C.one", "Kitchen.one", "Group"]);
1483 assert_eq!(order(&notebook, "Group"), ["Group/New Section 1.one"]);
1484 let mut redo = Vec::new();
1485 while let Some(step) = steps.pop() {
1486 let shown = library(notebook);
1487 let (structure, undo) = restructuring(&shown, &step.change).unwrap();
1488 redo.push(undo);
1489 notebook = shown.reopen().unwrap();
1490 carry(&mut notebook, structure);
1491 }
1492 assert_eq!(
1493 order(&notebook, ""),
1494 ["New Section 1.one", "B.one", "C.one", "Group"]
1495 );
1496 // Redo, then Undo of a rename another device changed since leaves it.
1497 let shown = library(notebook);
1498 let rename = redo.pop().unwrap();
1499 let (structure, undo) = restructuring(&shown, &rename.change).unwrap();
1500 notebook = shown.reopen().unwrap();
1501 carry(&mut notebook, structure);
1502 notebook.rename("Kitchen.one", "Pantry").unwrap();
1503 assert!(restructuring(&library(notebook), &undo.change).is_none());
1504 let _ = std::fs::remove_dir_all(&folder);
1505 }
1506
1507 /// Every kind of step taken back and redone in one notebook, written as the app writes
1508 /// it. `SNOWBOUND_UNDO_EXPORT` names a new directory that receives it for a cold
1509 /// reopen in OneNote 2010: "New Section 1" lists First, Second, Fourth and Recreated,
1510 /// "B" its own page, and Deleted Pages only Third.
1511 #[test]
1512 fn every_step_back_is_stored_as_onenote_stores_one() {
1513 let mut fixture = Fixture::new("export", &["First", "Second", "Third", "Fourth"]);
1514 let back = |fixture: &Fixture, change| {
1515 let done = fixture.change(change).unwrap();
1516 fixture.change(done.undo).unwrap()
1517 };
1518 back(
1519 &fixture,
1520 Change::Delete {
1521 pages: vec![fixture.space("Second")],
1522 show: None,
1523 created: false,
1524 },
1525 );
1526 fixture.change(Change::Delete {
1527 pages: vec![fixture.space("Third")],
1528 show: None,
1529 created: false,
1530 });
1531 let order = fixture
1532 .pages()
1533 .into_iter()
1534 .map(|(space, _, level)| (space, level))
1535 .collect();
1536 let fourth = fixture.space("Fourth");
1537 fixture.edit(vec![Op::Section(SectionOp::Pages(vec![
1538 PageEdit::move_to(fourth, Some(fixture.space("First")), 1).unwrap(),
1539 ]))]);
1540 fixture.change(Change::Arrange {
1541 order,
1542 moved: vec![fourth],
1543 });
1544 back(
1545 &fixture,
1546 Change::Send {
1547 page: fixture.space("First"),
1548 target: fixture.library.section_identity("B.one").unwrap(),
1549 },
1550 );
1551 let creation = dated();
1552 let created = creation.space();
1553 fixture.edit(vec![Op::Section(SectionOp::Create(creation))]);
1554 let restored = back(
1555 &fixture,
1556 Change::Delete {
1557 pages: vec![created],
1558 show: None,
1559 created: true,
1560 },
1561 );
1562 fixture.retitle(restored.show, "Recreated");
1563 assert_eq!(
1564 fixture.titles(),
1565 titled(&["First", "Second", "Fourth", "Recreated"])
1566 );
1567 assert_eq!(fixture.binned(), ["Third"]);
1568
1569 let mut notebook = fixture.library.reopen().unwrap();
1570 for change in [
1571 crate::manage::Structure::Rename {
1572 path: "B.one".into(),
1573 name: "Kitchen".into(),
1574 },
1575 crate::manage::Structure::Reorder {
1576 folder: String::new(),
1577 paths: vec!["B.one".into(), "New Section 1.one".into()],
1578 },
1579 ] {
1580 let shown = fixture.library.with(notebook);
1581 let undo = structure_undo(&shown, &change).unwrap();
1582 notebook = shown.reopen().unwrap();
1583 carry(&mut notebook, change);
1584 let (structure, _) =
1585 restructuring(&fixture.library.with(notebook), &undo.change).unwrap();
1586 notebook = fixture.library.reopen().unwrap();
1587 carry(&mut notebook, structure);
1588 }
1589 let order: Vec<String> = entries(notebook.catalog())
1590 .into_iter()
1591 .map(|(_, path)| path)
1592 .collect();
1593 assert_eq!(order, ["New Section 1.one", "B.one"]);
1594
1595 fixture.section.take().unwrap().close().unwrap();
1596 if let Some(directory) = std::env::var_os("SNOWBOUND_UNDO_EXPORT") {
1597 copy(
1598 &fixture.folder.join("Notebook"),
1599 std::path::Path::new(&directory),
1600 );
1601 }
1602 }
1603
1604 fn copy(from: &std::path::Path, to: &std::path::Path) {
1605 std::fs::create_dir_all(to).unwrap();
1606 for entry in std::fs::read_dir(from).unwrap().flatten() {
1607 let target = to.join(entry.file_name());
1608 if entry.file_type().unwrap().is_dir() {
1609 copy(&entry.path(), &target);
1610 } else {
1611 std::fs::copy(entry.path(), target).unwrap();
1612 }
1613 }
1614 }
1615
1616 /// Steps a page's editor no longer holds, dropped by a change made elsewhere, leave the
1617 /// timeline; a page gone takes its steps.
1618 #[test]
1619 fn steps_follow_the_page_history() {
1620 let mut timeline = Timeline::default();
1621 let shown = page(1);
1622 timeline.edited(shown, [3, 0], false);
1623 timeline.record(created(page(2)));
1624 timeline.edited(shown, [1, 0], false);
1625 assert!(is_action(timeline.next(false, shown, [1, 0], &here())));
1626 assert!(is_edit(timeline.next(false, shown, [1, 0], &here())));
1627 timeline.forget(&[shown]);
1628 assert!(timeline.next(false, shown, [0, 0], &here()).is_none());
1629 }
1630}
crates/snowbound/src/windows.rs+189-3
...@@ -158,14 +158,72 @@ fn composed() -> bool {...@@ -158,14 +158,72 @@ fn composed() -> bool {
158 unsafe { Dwm::DwmIsCompositionEnabled(&mut on) >= 0 && on != 0 }158 unsafe { Dwm::DwmIsCompositionEnabled(&mut on) >= 0 && on != 0 }
159}159}
160160
161pub fn event_loop(_headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLoopError> {161pub fn event_loop(headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLoopError> {
162 let event_loop = EventLoop::with_user_event().build()?;162 let event_loop = EventLoop::with_user_event().build()?;
163 QUIT.set(event_loop.create_proxy())163 QUIT.set(event_loop.create_proxy())
164 .expect("Only one application event loop is created");164 .expect("Only one application event loop is created");
165 COMPOSED.store(composed(), Ordering::Relaxed);165 COMPOSED.store(composed(), Ordering::Relaxed);
166 if !headless {
167 offer_to_open();
168 }
166 Ok(event_loop)169 Ok(event_loop)
167}170}
168171
172/// Lists this executable under Open with for OneNote's sections and tables of contents, for
173/// this user, leaving the app that opens them as it was; a moved executable lists its new
174/// path. One cargo built and runs where it put it, in a folder CACHEDIR.TAG marks, isn't listed.
175fn offer_to_open() {
176 use windows_sys::Win32::System::Registry;
177 const PROG_ID: &str = "Snowbound.OneNote";
178 let Ok(executable) = std::env::current_exe() else {
179 return;
180 };
181 if executable
182 .ancestors()
183 .any(|folder| folder.join("CACHEDIR.TAG").exists())
184 {
185 return;
186 }
187 let executable = executable.display();
188 let set = |key: &str, value: Option<&str>, data: &str| {
189 let (data, value) = (wide(data), value.map(wide));
190 let result = unsafe {
191 Registry::RegSetKeyValueW(
192 Registry::HKEY_CURRENT_USER,
193 wide(format!(r"Software\Classes\{key}")).as_ptr(),
194 value
195 .as_ref()
196 .map_or(std::ptr::null(), |value| value.as_ptr()),
197 Registry::REG_SZ,
198 data.as_ptr().cast(),
199 (data.len() * 2) as u32,
200 )
201 };
202 if result != 0 {
203 eprintln!("Cannot register {key}: error {result}");
204 }
205 };
206 set(PROG_ID, None, "OneNote File");
207 set(
208 &format!(r"{PROG_ID}\DefaultIcon"),
209 None,
210 &format!("{executable},0"),
211 );
212 set(
213 &format!(r"{PROG_ID}\shell\open"),
214 Some("FriendlyAppName"),
215 "Snowbound",
216 );
217 set(
218 &format!(r"{PROG_ID}\shell\open\command"),
219 None,
220 &format!("\"{executable}\" \"%1\""),
221 );
222 for extension in [".one", ".onetoc2"] {
223 set(&format!(r"{extension}\OpenWithProgids"), Some(PROG_ID), "");
224 }
225}
226
169/// The executable's icon, and where the row draws the caption buttons, no system frame:227/// The executable's icon, and where the row draws the caption buttons, no system frame:
170/// the desktop draws the window's shadow.228/// the desktop draws the window's shadow.
171pub fn window_attributes() -> WindowAttributes {229pub fn window_attributes() -> WindowAttributes {
...@@ -760,8 +818,132 @@ pub fn move_cursor() -> winit::window::CursorIcon {...@@ -760,8 +818,132 @@ pub fn move_cursor() -> winit::window::CursorIcon {
760/// No system border lies under the chrome.818/// No system border lies under the chrome.
761pub fn cover_border_line(_: &mut Ui, _: f32) {}819pub fn cover_border_line(_: &mut Ui, _: f32) {}
762820
763pub fn with_pool<R>(run: impl FnOnce() -> R) -> R {821/// Runs the app. Where nothing reads stderr, as when Explorer starts it, stderr goes to a
764 run()822/// log, with panics' backtraces and the faults that end the process, and a run that ends
823/// in an error or a panic says where the log is.
824pub fn with_pool(
825 run: impl FnOnce() -> Result<(), Box<dyn std::error::Error>>,
826) -> Result<(), Box<dyn std::error::Error>> {
827 use std::panic;
828 let log = log_stderr();
829 let (major, minor, build) = version();
830 eprintln!(
831 "Snowbound {} on Windows {major}.{minor}.{build}",
832 option_env!("SNOWBOUND_BUILD").unwrap_or("development")
833 );
834 panic::set_hook(Box::new(|info| {
835 let thread = std::thread::current();
836 let backtrace = std::backtrace::Backtrace::force_capture();
837 eprintln!(
838 "Thread {:?} {info}\n{backtrace}",
839 thread.name().unwrap_or("")
840 );
841 }));
842 unsafe {
843 windows_sys::Win32::System::Diagnostics::Debug::AddVectoredExceptionHandler(0, Some(fault))
844 };
845 let result = panic::catch_unwind(panic::AssertUnwindSafe(run));
846 let Some(log) = log else {
847 return result.unwrap_or_else(|payload| panic::resume_unwind(payload));
848 };
849 let details = format!("The log is at {}.", log.display());
850 // The window is gone, and a message box it owned wouldn't show.
851 WINDOW.store(0, Ordering::Relaxed);
852 match result {
853 Ok(Err(error)) => {
854 alert(&error.to_string(), &details);
855 Err(error)
856 }
857 Ok(done) => done,
858 Err(payload) => {
859 alert("Snowbound stopped because of a problem.", &details);
860 panic::resume_unwind(payload)
861 }
862 }
863}
864
865/// Points stderr at `snowbound.log` in the cache folder where it goes nowhere, keeping the
866/// last run's as `snowbound.old.log`.
867fn log_stderr() -> Option<PathBuf> {
868 use std::os::windows::io::IntoRawHandle;
869 use windows_sys::Win32::{
870 Storage::FileSystem::{FILE_TYPE_UNKNOWN, GetFileType},
871 System::Console::{GetStdHandle, STD_ERROR_HANDLE, SetStdHandle},
872 };
873 // A console's handles inherited without the console are as good as none.
874 if unsafe { GetFileType(GetStdHandle(STD_ERROR_HANDLE)) } != FILE_TYPE_UNKNOWN {
875 return None;
876 }
877 let folder = cache_dir()?;
878 std::fs::create_dir_all(&folder).ok()?;
879 let log = folder.join("snowbound.log");
880 let _ = std::fs::rename(&log, folder.join("snowbound.old.log"));
881 let file = std::fs::File::create(&log).ok()?;
882 // Leaked: stderr writes to it until the process ends.
883 unsafe { SetStdHandle(STD_ERROR_HANDLE, file.into_raw_handle()) };
884 Some(log)
885}
886
887/// Logs an access violation or a bad instruction, such as a driver's, by the module it
888/// happened in. Seen before any handler, as Windows reports one from a window procedure
889/// to no unhandled-exception filter; it usually ends the process.
890unsafe extern "system" fn fault(
891 pointers: *mut windows_sys::Win32::System::Diagnostics::Debug::EXCEPTION_POINTERS,
892) -> i32 {
893 use windows_sys::Win32::{
894 Foundation::{
895 EXCEPTION_ACCESS_VIOLATION, EXCEPTION_ILLEGAL_INSTRUCTION, EXCEPTION_PRIV_INSTRUCTION,
896 },
897 System::LibraryLoader::{
898 GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
899 GetModuleFileNameW, GetModuleHandleExW,
900 },
901 };
902 let record = unsafe { &*(*pointers).ExceptionRecord };
903 if ![
904 EXCEPTION_ACCESS_VIOLATION,
905 EXCEPTION_ILLEGAL_INSTRUCTION,
906 EXCEPTION_PRIV_INSTRUCTION,
907 ]
908 .contains(&record.ExceptionCode)
909 {
910 return 0;
911 }
912 let place = |address: usize| {
913 let mut module = std::ptr::null_mut();
914 let mut name = [0u16; 260];
915 let found = unsafe {
916 GetModuleHandleExW(
917 GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS
918 | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
919 address as *const u16,
920 &mut module,
921 ) != 0
922 && GetModuleFileNameW(module, name.as_mut_ptr(), name.len() as u32) != 0
923 };
924 if found {
925 format!("{} + {:#x}", narrow(&name), address - module as usize)
926 } else {
927 format!("{address:#x}, in no module")
928 }
929 };
930 // Where a call went to a bad address, the backtrace can't leave it: the return address
931 // names the caller.
932 let context = unsafe { &*(*pointers).ContextRecord };
933 #[cfg(target_arch = "x86_64")]
934 let caller = unsafe { *(context.Rsp as *const usize) };
935 #[cfg(target_arch = "aarch64")]
936 let caller = unsafe { context.Anonymous.Anonymous.Lr } as usize;
937 eprintln!(
938 "Fault {:#010x} at {}, on {:#x}, perhaps called from {}\n{}",
939 record.ExceptionCode as u32,
940 place(record.ExceptionAddress as usize),
941 record.ExceptionInformation[1],
942 place(caller),
943 std::backtrace::Backtrace::force_capture()
944 );
945 // EXCEPTION_CONTINUE_SEARCH.
946 0
765}947}
766948
767/// The interface keeps its own font, which fontique finds as Segoe UI.949/// The interface keeps its own font, which fontique finds as Segoe UI.
...@@ -820,6 +1002,10 @@ impl Clipboard {...@@ -820,6 +1002,10 @@ impl Clipboard {
820 self.0.get().file_list().unwrap_or_default()1002 self.0.get().file_list().unwrap_or_default()
821 }1003 }
8221004
1005 pub fn get_html(&mut self) -> Option<String> {
1006 self.0.get().html().ok()
1007 }
1008
823 /// The clipboard's PNG, or else its device-independent bitmap.1009 /// The clipboard's PNG, or else its device-independent bitmap.
824 pub fn get_picture(&mut self) -> Option<Vec<u8>> {1010 pub fn get_picture(&mut self) -> Option<Vec<u8>> {
825 crate::paste::bitmap(self.0.get_image().ok()?)1011 crate::paste::bitmap(self.0.get_image().ok()?)
crates/snowbound/tests/replay.rs+130
...@@ -122,3 +122,133 @@ fn each_toolbar_control_has_a_name_of_its_own_at_every_width() {...@@ -122,3 +122,133 @@ fn each_toolbar_control_has_a_name_of_its_own_at_every_width() {
122 assert_named_once(&tree);122 assert_named_once(&tree);
123 }123 }
124}124}
125
126/// The menu items listed under the first `Menu` of `tree`, one a line.
127fn menu_items(tree: &str) -> Vec<&str> {
128 tree.lines()
129 .skip_while(|line| !line.trim_start().starts_with("Menu"))
130 .skip(1)
131 .take_while(|line| !line.trim_start().starts_with("Menu") || line.contains("MenuItem"))
132 .filter(|line| line.contains("MenuItem"))
133 .map(|line| line.split('"').nth(1).unwrap_or_default())
134 .collect()
135}
136
137#[test]
138fn the_palette_lists_recent_pages_and_the_actions_a_context_menu_offers() {
139 let scratch = Scratch::new("palette-actions");
140 let notebook =
141 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate");
142 let palette = ["modifiers command", "key p", "modifiers", "wait 400"];
143 let chord = ["modifiers command", "key k", "modifiers", "wait 400"];
144 let mut steps = Vec::from(palette);
145 steps.extend(["type type over", "wait 200", "key Enter", "wait 800"]);
146 steps.extend(palette);
147 steps.push("accessibility recent");
148 steps.extend(chord);
149 steps.push("accessibility actions");
150 steps.extend(["key Escape", "wait 400", "accessibility back"]);
151 steps.extend(["key Escape", "wait 400", "move 1000 87", "press right"]);
152 steps.extend(["release right", "wait 400", "accessibility context"]);
153 let [recent, actions, back, context] = replay(&scratch, Some(&notebook), &steps)
154 .try_into()
155 .unwrap();
156 let first = recent
157 .lines()
158 .find(|line| line.contains("ListBoxOption"))
159 .unwrap_or_default();
160 assert!(
161 first.contains(r#""Delete into a table" -- "Cross" [selected]"#),
162 "the page left starts the list, highlighted:\n{recent}"
163 );
164 let actions = menu_items(&actions);
165 assert_eq!(actions.first(), Some(&"Open"), "{actions:?}");
166 assert!(
167 back.contains("Dialog") && menu_items(&back).is_empty(),
168 "{back}"
169 );
170 // Open leads; rows past what the panel shows unscrolled are not built.
171 let context = menu_items(&context);
172 assert!(
173 actions.len() > 5 && context.starts_with(&actions[1..]),
174 "one list feeds both: {actions:?} {context:?}"
175 );
176}
177
178/// The page tabs listed in `tree`, the selected one marked with `*`.
179fn page_tabs(tree: &str) -> Vec<String> {
180 tree.lines()
181 .skip_while(|line| line.trim() != r#"TabList "Pages""#)
182 .skip(1)
183 .take_while(|line| line.trim_start().starts_with("Tab "))
184 .map(|line| {
185 let name = line.split('"').nth(1).unwrap_or_default();
186 match line.ends_with("[selected]") {
187 true => format!("*{name}"),
188 false => name.to_owned(),
189 }
190 })
191 .collect()
192}
193
194/// Undo walks back through two new pages and what was typed on each, crossing pages as
195/// it goes: each page keeps its own history once left, and a new page goes once what was
196/// typed on it has gone.
197#[test]
198fn undo_walks_back_through_new_pages_and_their_titles() {
199 let scratch = Scratch::new("undo-pages");
200 let notebook =
201 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate");
202 let chord =
203 |modifiers: &str, key: &str| [modifiers, key, "modifiers", "wait 800"].map(String::from);
204 let mut steps = Vec::new();
205 for title in ["type One", "type Two"] {
206 steps.extend(chord("modifiers command", "key n"));
207 steps.extend([title, "wait 300"].map(String::from));
208 }
209 steps.extend(chord("modifiers command control", "key Left"));
210 steps.push("accessibility back".into());
211 for name in ["shown", "two", "removed", "one", "first"] {
212 steps.extend(chord("modifiers command", "key z"));
213 steps.push(format!("accessibility {name}"));
214 }
215 steps.extend(chord("modifiers command shift", "key z"));
216 steps.push("accessibility redone".into());
217 let steps: Vec<&str> = steps.iter().map(String::as_str).collect();
218 let trees = replay(&scratch, Some(&notebook), &steps);
219 let tabs: Vec<Vec<String>> = trees.iter().map(|tree| page_tabs(tree)).collect();
220 let corpus = [
221 "Delete into a table",
222 "Delete out of a table",
223 "Type over a row",
224 "Delete a whole table",
225 ];
226 let list = |extra: &[&str], selected: &str| -> Vec<String> {
227 corpus
228 .iter()
229 .chain(extra)
230 .map(|name| match *name == selected {
231 true => format!("*{name}"),
232 false => name.to_string(),
233 })
234 .collect()
235 };
236 let expected = [
237 // Back to the first new page.
238 list(&["One", "Two"], "One"),
239 // Undo there shows the newer page, whose typing goes first.
240 list(&["One", "Two"], "Two"),
241 list(&["One", "Untitled page"], "Untitled page"),
242 // Then the page, back to the page shown before it.
243 list(&["One"], "One"),
244 // The first page's own typing, kept while it was left.
245 list(&["Untitled page"], "Untitled page"),
246 list(&[], "Delete into a table"),
247 // Redo brings the page back.
248 list(&["Untitled page"], "Untitled page"),
249 ];
250 assert_eq!(tabs.len(), expected.len());
251 for ((tabs, expected), tree) in tabs.iter().zip(&expected).zip(&trees) {
252 assert_eq!(tabs, expected, "{tree}");
253 }
254}
crates/ui/src/layout.rs+61-2
...@@ -1,4 +1,6 @@...@@ -1,4 +1,6 @@
1use crate::{Anchor, Axis, Built, Flags, ICON, ICON_GAP, Id, Overflow, Size, State, text::Texts};1use crate::{
2 Anchor, Axis, Built, Flags, ICON, ICON_GAP, Id, Overflow, Size, State, fitting, text::Texts,
3};
2use std::collections::HashMap;4use std::collections::HashMap;
35
4/// Sizes each box on both axes, then places it: standalone sizes, sizes taken from6/// Sizes each box on both axes, then places it: standalone sizes, sizes taken from
...@@ -49,8 +51,13 @@ pub(crate) fn solve(...@@ -49,8 +51,13 @@ pub(crate) fn solve(
49 Size::Fraction(_) | Size::Children => 0.0,51 Size::Fraction(_) | Size::Children => 0.0,
50 };52 };
51 }53 }
54 fit_popups(nodes, axis);
52 for index in 1..nodes.len() {55 for index in 1..nodes.len() {
53 if let Size::Fraction(fraction) = nodes[index].size[axis].size {56 if let Size::Fraction(fraction) = nodes[index].size[axis].size {
57 if stretches(nodes, index, axis) {
58 nodes[index].computed[axis] = 0.0;
59 continue;
60 }
54 let mut ancestor = nodes[index].parent;61 let mut ancestor = nodes[index].parent;
55 while ancestor != 0 && nodes[ancestor].size[axis].size == Size::Children {62 while ancestor != 0 && nodes[ancestor].size[axis].size == Size::Children {
56 ancestor = nodes[ancestor].parent;63 ancestor = nodes[ancestor].parent;
...@@ -65,6 +72,8 @@ pub(crate) fn solve(...@@ -65,6 +72,8 @@ pub(crate) fn solve(
65 nodes[index].computed[axis] = content + 2.0 * nodes[index].pad[axis];72 nodes[index].computed[axis] = content + 2.0 * nodes[index].pad[axis];
66 }73 }
67 }74 }
75 yield_popups(nodes, axis);
76 fit_popups(nodes, axis);
68 for index in 0..nodes.len() {77 for index in 0..nodes.len() {
69 let node = &nodes[index];78 let node = &nodes[index];
70 if node.children.is_empty() || (axis == 1 && node.flags.contains(Flags::SCROLL)) {79 if node.children.is_empty() || (axis == 1 && node.flags.contains(Flags::SCROLL)) {
...@@ -118,6 +127,12 @@ pub(crate) fn solve(...@@ -118,6 +127,12 @@ pub(crate) fn solve(
118 for child in children {127 for child in children {
119 let room =128 let room =
120 (across(nodes, index, child, axis) - 2.0 * nodes[index].pad[axis]).max(0.0);129 (across(nodes, index, child, axis) - 2.0 * nodes[index].pad[axis]).max(0.0);
130 if let Size::Fraction(fraction) = nodes[child].size[axis].size
131 && stretches(nodes, child, axis)
132 {
133 nodes[child].computed[axis] = room * fraction;
134 continue;
135 }
121 let over = nodes[child].computed[axis] - room;136 let over = nodes[child].computed[axis] - room;
122 if over > 0.0 {137 if over > 0.0 {
123 nodes[child].computed[axis] -=138 nodes[child].computed[axis] -=
...@@ -197,6 +212,50 @@ pub(crate) fn solve(...@@ -197,6 +212,50 @@ pub(crate) fn solve(
197 }212 }
198}213}
199214
215/// Whether `index`, sized from an ancestor, is across a parent sized by its children: it takes
216/// no room while the parent sums its other children, then that share of the parent's room.
217fn stretches(nodes: &[Built], index: usize, axis: usize) -> bool {
218 let node = &nodes[index];
219 let parent = &nodes[node.parent];
220 index != 0
221 && parent.size[axis].size == Size::Children
222 && !along(parent, axis)
223 && !(axis == 1 && parent.flags.contains(Flags::SCROLL))
224 && !node.flags.contains(Flags::FLOAT)
225 && node.anchor.is_none()
226}
227
228/// Lets each popup sized loosely give way to the window, as far as its strictness lets it:
229/// a dialog keeps below it the margin it opens under.
230fn yield_popups(nodes: &mut [Built], axis: usize) {
231 let window = nodes[0].computed[axis];
232 for node in &mut nodes[1..] {
233 let room = match node.anchor {
234 None => continue,
235 Some(Anchor::Dialog | Anchor::Top) if axis == 1 => window * 3.0 / 4.0,
236 Some(_) => fitting(window),
237 };
238 let over = node.computed[axis] - room;
239 if over > 0.0 {
240 node.computed[axis] -= over * (1.0 - node.size[axis].strictness.clamp(0.0, 1.0));
241 }
242 }
243}
244
245/// Shrinks each popup longer than the window lets it be on `axis`; one cut short vertically
246/// scrolls what it holds.
247fn fit_popups(nodes: &mut [Built], axis: usize) {
248 let most = fitting(nodes[0].computed[axis]);
249 for node in &mut nodes[1..] {
250 if node.anchor.is_some() && node.computed[axis] > most {
251 node.computed[axis] = most;
252 if axis == 1 {
253 node.flags = node.flags | Flags::SCROLL | Flags::CLIP;
254 }
255 }
256 }
257}
258
200/// The length `parent` lays `child` out across: a popup widening over its anchor lays its259/// The length `parent` lays `child` out across: a popup widening over its anchor lays its
201/// contents out at its full width, but for boxes standing in for the anchor, which widen with it.260/// contents out at its full width, but for boxes standing in for the anchor, which widen with it.
202fn across(nodes: &[Built], parent: usize, child: usize, axis: usize) -> f32 {261fn across(nodes: &[Built], parent: usize, child: usize, axis: usize) -> f32 {
...@@ -205,7 +264,7 @@ fn across(nodes: &[Built], parent: usize, child: usize, axis: usize) -> f32 {...@@ -205,7 +264,7 @@ fn across(nodes: &[Built], parent: usize, child: usize, axis: usize) -> f32 {
205 (Some(Anchor::Over(_)), Size::Pixels(full))264 (Some(Anchor::Over(_)), Size::Pixels(full))
206 if axis == 0 && !nodes[child].flags.contains(Flags::STILL) =>265 if axis == 0 && !nodes[child].flags.contains(Flags::STILL) =>
207 {266 {
208 full267 full.min(fitting(nodes[0].computed[axis]))
209 }268 }
210 _ => node.computed[axis],269 _ => node.computed[axis],
211 }270 }
crates/ui/src/lib.rs+57-17
...@@ -85,7 +85,8 @@ pub enum Size {...@@ -85,7 +85,8 @@ pub enum Size {
85 Pixels(f32),85 Pixels(f32),
86 /// The label and padding.86 /// The label and padding.
87 Text,87 Text,
88 /// A fraction of the nearest ancestor not sized by its children.88 /// A fraction of the nearest ancestor not sized by its children; across a parent sized by
89 /// its children, a fraction of what the parent's other children make it.
89 Fraction(f32),90 Fraction(f32),
90 /// The children laid out along this axis, and padding.91 /// The children laid out along this axis, and padding.
91 Children,92 Children,
...@@ -207,6 +208,14 @@ pub enum Overflow {...@@ -207,6 +208,14 @@ pub enum Overflow {
207 Ellipsis,208 Ellipsis,
208}209}
209210
211/// The least a popup keeps between itself and each of the window's edges.
212const POPUP_MARGIN: f32 = 4.0;
213
214/// The longest a popup may be in a window `room` long; one longer scrolls or squeezes.
215fn fitting(room: f32) -> f32 {
216 (room - 2.0 * POPUP_MARGIN).max(0.0)
217}
218
210/// Where a popup opens, flipping to the far side of its anchor where the window ends first.219/// Where a popup opens, flipping to the far side of its anchor where the window ends first.
211#[derive(Clone, Copy, Debug, PartialEq)]220#[derive(Clone, Copy, Debug, PartialEq)]
212pub enum Anchor {221pub enum Anchor {
...@@ -231,13 +240,18 @@ impl Anchor {...@@ -231,13 +240,18 @@ impl Anchor {
231 /// anchor on the axis it opens along, level with it otherwise. Shown only `shown` long240 /// anchor on the axis it opens along, level with it otherwise. Shown only `shown` long
232 /// as it opens, it keeps the edge it would have at full size.241 /// as it opens, it keeps the edge it would have at full size.
233 fn place(self, axis: usize, size: f32, shown: f32, room: f32) -> f32 {242 fn place(self, axis: usize, size: f32, shown: f32, room: f32) -> f32 {
243 let size = size.min(fitting(room));
244 let shown = shown.min(size);
245 let most = (room - POPUP_MARGIN - size).max(POPUP_MARGIN);
234 let (rect, along) = match self {246 let (rect, along) = match self {
235 Anchor::Below(rect) => (rect, Some(1)),247 Anchor::Below(rect) => (rect, Some(1)),
236 Anchor::Right(rect) => (rect, Some(0)),248 Anchor::Right(rect) => (rect, Some(0)),
237 Anchor::Over(rect) => (rect, None),249 Anchor::Over(rect) => (rect, None),
238 Anchor::Point([x, y]) => ([x, y, x, y], Some(1)),250 Anchor::Point([x, y]) => ([x, y, x, y], Some(1)),
239 Anchor::Dialog | Anchor::Top if axis == 0 => return ((room - size) / 2.0).max(0.0),251 Anchor::Dialog | Anchor::Top if axis == 0 => {
240 Anchor::Dialog | Anchor::Top => return (room / 8.0).min(room - size).max(0.0),252 return ((room - size) / 2.0).max(POPUP_MARGIN);
253 }
254 Anchor::Dialog | Anchor::Top => return (room / 8.0).clamp(POPUP_MARGIN, most),
241 };255 };
242 let [low, high] = [rect[axis], rect[axis + 2]];256 let [low, high] = [rect[axis], rect[axis + 2]];
243 let (first, second) = if along == Some(axis) {257 let (first, second) = if along == Some(axis) {
...@@ -245,12 +259,12 @@ impl Anchor {...@@ -245,12 +259,12 @@ impl Anchor {
245 } else {259 } else {
246 (low, high - size)260 (low, high - size)
247 };261 };
248 if first + size <= room {262 if first + size <= room - POPUP_MARGIN {
249 first263 first.max(POPUP_MARGIN)
250 } else if second >= 0.0 {264 } else if second >= POPUP_MARGIN {
251 second + size - shown265 second.min(most) + size - shown
252 } else {266 } else {
253 first.min(room - size).max(0.0) + size - shown267 first.clamp(POPUP_MARGIN, most) + size - shown
254 }268 }
255 }269 }
256270
...@@ -323,6 +337,8 @@ pub struct Spec<'a> {...@@ -323,6 +337,8 @@ pub struct Spec<'a> {
323 pub font_size: Option<f32>,337 pub font_size: Option<f32>,
324 /// Shapes the label semibold.338 /// Shapes the label semibold.
325 pub bold: bool,339 pub bold: bool,
340 /// Shapes the label italic.
341 pub italic: bool,
326 pub font: Option<&'a str>,342 pub font: Option<&'a str>,
327 pub overflow: Overflow,343 pub overflow: Overflow,
328 /// The label's colour; the theme's text colour otherwise.344 /// The label's colour; the theme's text colour otherwise.
...@@ -548,6 +564,8 @@ struct State {...@@ -548,6 +564,8 @@ struct State {
548 held: Option<(ScrollerPart, Instant)>,564 held: Option<(ScrollerPart, Instant)>,
549 /// An animated value and its target, for `Ui::animate`.565 /// An animated value and its target, for `Ui::animate`.
550 tween: Option<[f32; 2]>,566 tween: Option<[f32; 2]>,
567 /// When the condition `Ui::lasted` follows last became true.
568 since: Option<Instant>,
551}569}
552570
553/// An open popup, above the one opened before it.571/// An open popup, above the one opened before it.
...@@ -839,6 +857,18 @@ impl Ui {...@@ -839,6 +857,18 @@ impl Ui {
839 self.states.get(&id).map(|state| state.rect)857 self.states.get(&id).map(|state| state.rect)
840 }858 }
841859
860 /// Scrolls `scroller`, a scrolling box, until `target` within it starts at its top, both
861 /// as last laid out; the wheel's easing carries it there.
862 pub fn scroll_to(&mut self, scroller: Id, target: Id) {
863 let (Some(top), Some(start)) = (self.rect(target), self.rect(scroller)) else {
864 return;
865 };
866 let state = self.states.entry(scroller).or_default();
867 let most = (state.content - (state.rect[3] - state.rect[1])).max(0.0);
868 state.scroll_target = (state.scroll + top[1] - start[1]).clamp(0.0, most);
869 self.animating |= state.scroll != state.scroll_target;
870 }
871
842 /// The pointer's cursor, or None over a custom box, whose host chooses.872 /// The pointer's cursor, or None over a custom box, whose host chooses.
843 pub fn cursor(&self) -> Option<CursorIcon> {873 pub fn cursor(&self) -> Option<CursorIcon> {
844 let hit = self874 let hit = self
...@@ -1180,7 +1210,7 @@ impl Ui {...@@ -1180,7 +1210,7 @@ impl Ui {
1180 let label = spec.text.map(|text| {1210 let label = spec.text.map(|text| {
1181 let size = spec.font_size.unwrap_or(self.theme.font_size);1211 let size = spec.font_size.unwrap_or(self.theme.font_size);
1182 self.texts1212 self.texts
1183 .label(text, size, spec.bold, spec.font, self.frame)1213 .styled(text, size, spec.bold, spec.italic, spec.font, self.frame)
1184 });1214 });
1185 // Popups hang from the root, outside the clips and flow of where they are built.1215 // Popups hang from the root, outside the clips and flow of where they are built.
1186 let parent = if spec.anchor.is_some() {1216 let parent = if spec.anchor.is_some() {
...@@ -1236,6 +1266,18 @@ impl Ui {...@@ -1236,6 +1266,18 @@ impl Ui {
1236 *value1266 *value
1237 }1267 }
12381268
1269 /// How long `held` has stayed true through the frames that ask of `id`; zero while
1270 /// false, as a status waits before showing a state that may pass at once.
1271 pub fn lasted(&mut self, id: Id, held: bool) -> Duration {
1272 let now = self.now;
1273 let state = self.states.entry(id).or_default();
1274 state.touched = self.frame;
1275 state.since = held.then(|| state.since.unwrap_or(now));
1276 state
1277 .since
1278 .map_or(Duration::ZERO, |since| now.saturating_duration_since(since))
1279 }
1280
1239 /// Makes `family` the interface's font, where fontique doesn't know the system's, as1281 /// Makes `family` the interface's font, where fontique doesn't know the system's, as
1240 /// with Mac OS X 10.6's Lucida Grande.1282 /// with Mac OS X 10.6's Lucida Grande.
1241 pub fn set_system_font(&mut self, family: &str) {1283 pub fn set_system_font(&mut self, family: &str) {
...@@ -1627,10 +1669,12 @@ impl Ui {...@@ -1627,10 +1669,12 @@ impl Ui {
1627 radius: *radius,1669 radius: *radius,
1628 });1670 });
1629 }1671 }
1672 // A box squeezed narrower than its padding keeps an empty inside, not an inverted one.
1673 let left = (painted[0] + node.pad[0]).min(painted[2]);
1630 let inner = [1674 let inner = [
1631 painted[0] + node.pad[0],1675 left,
1632 painted[1],1676 painted[1],
1633 painted[2] - node.pad[0],1677 (painted[2] - node.pad[0]).max(left),
1634 painted[3],1678 painted[3],
1635 ];1679 ];
1636 let mut x = if node.center {1680 let mut x = if node.center {
...@@ -1650,12 +1694,8 @@ impl Ui {...@@ -1650,12 +1694,8 @@ impl Ui {
1650 let rect = if node.label.is_some() {1694 let rect = if node.label.is_some() {
1651 [x, top, x + ICON, top + ICON]1695 [x, top, x + ICON, top + ICON]
1652 } else {1696 } else {
1653 [1697 let top = (painted[1] + node.pad[1]).min(painted[3]);
1654 inner[0],1698 [inner[0], top, inner[2], (painted[3] - node.pad[1]).max(top)]
1655 painted[1] + node.pad[1],
1656 inner[2],
1657 painted[3] - node.pad[1],
1658 ]
1659 };1699 };
1660 self.display.push(Display::Image {1700 self.display.push(Display::Image {
1661 image: image.clone(),1701 image: image.clone(),
crates/ui/src/list.rs+7
...@@ -55,6 +55,13 @@ pub(crate) struct State {...@@ -55,6 +55,13 @@ pub(crate) struct State {
55 selected: Option<u64>,55 selected: Option<u64>,
56}56}
5757
58impl State {
59 /// The keys of the rows built last frame.
60 pub(crate) fn shown(&self) -> impl Iterator<Item = u64> + '_ {
61 self.shown.iter().map(|(key, _)| *key)
62 }
63}
64
58/// Builds `list` into a box of `spec`, calling `build` inside each row in view, and moves65/// Builds `list` into a box of `spec`, calling `build` inside each row in view, and moves
59/// `selected` with the list's keys. Returns the index of the row clicked.66/// `selected` with the list's keys. Returns the index of the row clicked.
60pub fn list<R: Rows>(67pub fn list<R: Rows>(
crates/ui/src/popup.rs+60-10
...@@ -75,6 +75,8 @@ pub struct Item<'a> {...@@ -75,6 +75,8 @@ pub struct Item<'a> {
75 pub separated: bool,75 pub separated: bool,
76 /// Names the group it starts: shown only while unfiltered, never chosen.76 /// Names the group it starts: shown only while unfiltered, never chosen.
77 pub heading: bool,77 pub heading: bool,
78 /// Repeats an item listed further on, so shows only while unfiltered.
79 pub repeated: bool,
78}80}
7981
80impl Item<'_> {82impl Item<'_> {
...@@ -327,22 +329,38 @@ fn tooltip_below(...@@ -327,22 +329,38 @@ fn tooltip_below(
327 ui.close();329 ui.close();
328}330}
329331
332/// A palette's item picked, by mode and index: to run, or for its actions, which the menu
333/// `actions` names lists beside its row.
334#[derive(Clone, Copy, Debug, PartialEq)]
335pub enum Pick {
336 Run(usize, usize),
337 Actions(usize, usize),
338}
339
340/// The menu of the actions on palette `id`'s item, which Escape closes back to the palette.
341pub fn actions(id: Id) -> Id {
342 id.child("actions")
343}
344
330/// Builds popup `id` as a command palette across the top of the window while it is open,345/// Builds popup `id` as a command palette across the top of the window while it is open,
331/// under a filter field showing `placeholder` while empty. What is typed picks the first of346/// under a filter field showing `placeholder` while empty. What is typed picks the first of
332/// `modes` whose prefix it starts with, and the rest of it narrows that mode's items, most347/// `modes` whose prefix it starts with, and the rest of it narrows that mode's items, most
333/// relevant first. Returns the mode and the index of the item chosen.348/// relevant first. Enter or a click runs the item; ⌘K (Ctrl+K elsewhere) on the highlighted
334pub fn palette(349/// one or a right-click on any opens its `actions` menu.
335 ui: &mut Ui,350pub fn palette(ui: &mut Ui, id: Id, modes: &[(&str, &[Item])], placeholder: &str) -> Option<Pick> {
336 id: Id,
337 modes: &[(&str, &[Item])],
338 placeholder: &str,
339) -> Option<(usize, usize)> {
340 if !ui.popup_open(id) {351 if !ui.popup_open(id) {
341 return None;352 return None;
342 }353 }
354 let rows = id.child("rows");
355 let clicked = (ui.lists.get(&rows).into_iter())
356 .flat_map(crate::list::State::shown)
357 .find(|key| {
358 (ui.signals.get(&rows.child(*key))).is_some_and(|signal| signal.context.is_some())
359 });
360 let chord = chord(ui, id.child("filter"), "k");
343 let window = ui.rect(Id::ROOT).unwrap_or_default();361 let window = ui.rect(Id::ROOT).unwrap_or_default();
344 let width = PALETTE.min(window[2] - 8.0 * PAD).max(NARROWEST);362 let width = PALETTE.min(window[2] - 8.0 * PAD).max(NARROWEST);
345 choose(363 if let Some((mode, index)) = choose(
346 ui,364 ui,
347 id,365 id,
348 Role::Dialog,366 Role::Dialog,
...@@ -351,7 +369,39 @@ pub fn palette(...@@ -351,7 +369,39 @@ pub fn palette(
351 Some(placeholder),369 Some(placeholder),
352 width,370 width,
353 ROW,371 ROW,
354 )372 ) {
373 return Some(Pick::Run(mode, index));
374 }
375 if !ui.popup_open(id) {
376 return None;
377 }
378 let popup = state(ui, id);
379 popup.highlight = clicked.or(popup.highlight);
380 let key = clicked.or(popup.highlight.filter(|_| chord))?;
381 let mode = modes
382 .iter()
383 .position(|(prefix, _)| popup.query.starts_with(prefix))?;
384 if modes[mode].1.get(key as usize)?.heading {
385 return None;
386 }
387 ui.open_submenu(actions(id), id, rows.child(key));
388 Some(Pick::Actions(mode, key as usize))
389}
390
391/// Takes a press of `character` with the shortcut modifier, ⌘ or Ctrl, routed to `owner`.
392fn chord(ui: &mut Ui, owner: Id, character: &str) -> bool {
393 if !crate::edit_modifiers(ui.modifiers).command {
394 return false;
395 }
396 let Some(signal) = ui.signals.get_mut(&owner) else {
397 return false;
398 };
399 let before = signal.events.len();
400 signal.events.retain(|event| {
401 !matches!(event, Event::Key { key: Key::Character(typed), .. }
402 if typed.eq_ignore_ascii_case(character))
403 });
404 signal.events.len() != before
355}405}
356406
357/// Opens popup `id` with `query` typed in its filter field and the caret after it, or407/// Opens popup `id` with `query` typed in its filter field and the caret after it, or
...@@ -1409,7 +1459,7 @@ impl<'a> Matches<'a> {...@@ -1409,7 +1459,7 @@ impl<'a> Matches<'a> {
1409 items1459 items
1410 .iter()1460 .iter()
1411 .enumerate()1461 .enumerate()
1412 .filter(|(_, item)| !item.heading)1462 .filter(|(_, item)| !item.heading && !item.repeated)
1413 .filter_map(|(index, item)| {1463 .filter_map(|(index, item)| {
1414 let text = Utf32Str::new(item.text, &mut chars);1464 let text = Utf32Str::new(item.text, &mut chars);
1415 Some((pattern.score(text, matcher)?, index))1465 Some((pattern.score(text, matcher)?, index))
crates/ui/src/tests.rs+295-8
...@@ -15,14 +15,19 @@ thread_local! {...@@ -15,14 +15,19 @@ thread_local! {
15 static FRAMES: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };15 static FRAMES: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
16}16}
1717
18/// Builds a frame 16 ms after this thread's previous one.18/// Builds a frame in a 400 by 300 window, 16 ms after this thread's previous one.
19fn frame(ui: &mut Ui, build: impl FnOnce(&mut Ui)) {19fn frame(ui: &mut Ui, build: impl FnOnce(&mut Ui)) {
20 sized_frame(ui, [400.0, 300.0], build);
21}
22
23/// Builds a frame in a window `size` large, 16 ms after this thread's previous one.
24fn sized_frame(ui: &mut Ui, size: [f32; 2], build: impl FnOnce(&mut Ui)) {
20 let frames = FRAMES.with(|frames| {25 let frames = FRAMES.with(|frames| {
21 frames.set(frames.get() + 1);26 frames.set(frames.get() + 1);
22 frames.get()27 frames.get()
23 });28 });
24 let now = START.with(|start| *start) + Duration::from_millis(16) * frames;29 let now = START.with(|start| *start) + Duration::from_millis(16) * frames;
25 ui.begin([400.0, 300.0], 2.0, now);30 ui.begin(size, 2.0, now);
26 build(ui);31 build(ui);
27 ui.end();32 ui.end();
28}33}
...@@ -1087,13 +1092,17 @@ fn tooltips_wait_then_switch_at_once_and_hide_on_press_until_left() {...@@ -1087,13 +1092,17 @@ fn tooltips_wait_then_switch_at_once_and_hide_on_press_until_left() {
1087 let [Some(first), None] = rest(&mut ui, 200) else {1092 let [Some(first), None] = rest(&mut ui, 200) else {
1088 panic!("the first tooltip shows after its delay");1093 panic!("the first tooltip shows after its delay");
1089 };1094 };
1090 assert_eq!(first[..2], [0.0, 20.0 + popup::PAD]);1095 assert_eq!(
1096 first[..2],
1097 [POPUP_MARGIN, 20.0 + popup::PAD],
1098 "off the window's edge"
1099 );
10911100
1092 ui.event(Event::PointerMoved([390.0, 10.0]));1101 ui.event(Event::PointerMoved([390.0, 10.0]));
1093 let [None, Some(second)] = tip_frame(&mut ui) else {1102 let [None, Some(second)] = tip_frame(&mut ui) else {
1094 panic!("moving to another shows its tooltip at once");1103 panic!("moving to another shows its tooltip at once");
1095 };1104 };
1096 assert_eq!(second[2], 400.0, "kept inside the window");1105 assert_eq!(second[2], 400.0 - POPUP_MARGIN, "kept inside the window");
10971106
1098 press(&mut ui, Instant::now(), true);1107 press(&mut ui, Instant::now(), true);
1099 press(&mut ui, Instant::now(), false);1108 press(&mut ui, Instant::now(), false);
...@@ -1140,6 +1149,96 @@ fn popups_open_beside_their_anchor_and_flip_to_stay_in_the_window() {...@@ -1140,6 +1149,96 @@ fn popups_open_beside_their_anchor_and_flip_to_stay_in_the_window() {
1140 );1149 );
1141}1150}
11421151
1152#[test]
1153fn popups_too_large_for_a_narrow_window_shrink_inside_its_margin() {
1154 let inside = |rect: [f32; 4], window: [f32; 2]| {
1155 rect[0] >= POPUP_MARGIN
1156 && rect[1] >= POPUP_MARGIN
1157 && rect[2] <= window[0] - POPUP_MARGIN
1158 && rect[3] <= window[1] - POPUP_MARGIN
1159 };
1160 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
1161 let narrow = [100.0, 300.0];
1162 let menu = |ui: &mut Ui| {
1163 sized_frame(ui, narrow, |ui| {
1164 popup::menu(ui, menu_id(), BELOW, &items(), None);
1165 });
1166 };
1167 menu(&mut ui);
1168 ui.open_popup(menu_id());
1169 for _ in 0..40 {
1170 menu(&mut ui);
1171 }
1172 let rect = ui.rect(menu_id()).unwrap();
1173 assert!(inside(rect, narrow), "{rect:?} leaves the window's margin");
1174 assert_eq!(
1175 rect[2] - rect[0],
1176 100.0 - 2.0 * POPUP_MARGIN,
1177 "as wide as fits"
1178 );
1179 let row = ui.rect(menu_id().child("rows").child(0_u64)).unwrap();
1180 assert!(row[2] <= rect[2], "its rows narrow with it");
1181
1182 // A dialog taller than a short window scrolls what it holds.
1183 let short = [400.0, 120.0];
1184 let dialog = Id::ROOT.child("dialog");
1185 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
1186 let build = |ui: &mut Ui| {
1187 sized_frame(ui, short, |ui| {
1188 ui.open_as(
1189 dialog,
1190 Spec {
1191 axis: Axis::Y,
1192 size: [px(600.0), children()],
1193 anchor: Some(Anchor::Dialog),
1194 ..Spec::default()
1195 },
1196 );
1197 for line in 0..10 {
1198 ui.leaf(
1199 line,
1200 Spec {
1201 size: [fill(), px(30.0)],
1202 text: Some("Line"),
1203 // Wider than the squeezed dialog leaves it.
1204 pad: [250.0, 0.0],
1205 ..Spec::default()
1206 },
1207 );
1208 }
1209 ui.close();
1210 });
1211 };
1212 build(&mut ui);
1213 ui.open_popup(dialog);
1214 for _ in 0..40 {
1215 build(&mut ui);
1216 }
1217 let rect = ui.rect(dialog).unwrap();
1218 assert!(inside(rect, short), "{rect:?} leaves the window's margin");
1219 let first = ui.rect(dialog.child(0)).unwrap();
1220 ui.event(Event::PointerMoved([200.0, 60.0]));
1221 ui.event(Event::Wheel([0.0, -100.0]));
1222 for _ in 0..40 {
1223 build(&mut ui);
1224 }
1225 let scrolled = ui.rect(dialog.child(0)).unwrap();
1226 assert!(scrolled[1] < first[1], "the wheel scrolls it");
1227 for layer in ui.layers() {
1228 let Layer::Primitives(layer) = layer else {
1229 continue;
1230 };
1231 for primitive in &layer.primitives {
1232 if let draw::Primitive::Text {
1233 clip: Some(clip), ..
1234 } = primitive
1235 {
1236 assert!(clip[0] <= clip[2], "{clip:?} is inverted");
1237 }
1238 }
1239 }
1240}
1241
1143#[test]1242#[test]
1144fn popups_show_at_once_by_their_anchor() {1243fn popups_show_at_once_by_their_anchor() {
1145 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);1244 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
...@@ -1348,7 +1447,10 @@ fn the_palette_centres_near_the_windows_top_and_chooses_the_best_match() {...@@ -1348,7 +1447,10 @@ fn the_palette_centres_near_the_windows_top_and_chooses_the_best_match() {
1348 frame(ui, |ui| {1447 frame(ui, |ui| {
1349 chosen = popup::palette(ui, palette, &[("", &commands)], "Search commands")1448 chosen = popup::palette(ui, palette, &[("", &commands)], "Search commands")
1350 });1449 });
1351 chosen.map(|(_, index)| index)1450 match chosen {
1451 Some(popup::Pick::Run(_, index)) => Some(index),
1452 _ => None,
1453 }
1352 };1454 };
1353 build(&mut ui);1455 build(&mut ui);
1354 ui.open_popup(palette);1456 ui.open_popup(palette);
...@@ -1385,7 +1487,11 @@ fn a_prefix_typed_in_the_palette_switches_what_it_lists() {...@@ -1385,7 +1487,11 @@ fn a_prefix_typed_in_the_palette_switches_what_it_lists() {
1385 build(&mut ui);1487 build(&mut ui);
1386 ui.event(typed("sel"));1488 ui.event(typed("sel"));
1387 ui.event(key(NamedKey::Enter));1489 ui.event(key(NamedKey::Enter));
1388 assert_eq!(build(&mut ui), Some((0, 3)), "typed after the prefix");1490 assert_eq!(
1491 build(&mut ui),
1492 Some(popup::Pick::Run(0, 3)),
1493 "typed after the prefix"
1494 );
1389 popup::open_with(&mut ui, palette, ">");1495 popup::open_with(&mut ui, palette, ">");
1390 build(&mut ui);1496 build(&mut ui);
1391 ui.event(key(NamedKey::Backspace));1497 ui.event(key(NamedKey::Backspace));
...@@ -1393,7 +1499,75 @@ fn a_prefix_typed_in_the_palette_switches_what_it_lists() {...@@ -1393,7 +1499,75 @@ fn a_prefix_typed_in_the_palette_switches_what_it_lists() {
1393 assert_eq!(popup::query(&ui, palette), Some(""));1499 assert_eq!(popup::query(&ui, palette), Some(""));
1394 ui.event(typed("tnr"));1500 ui.event(typed("tnr"));
1395 ui.event(key(NamedKey::Enter));1501 ui.event(key(NamedKey::Enter));
1396 assert_eq!(build(&mut ui), Some((1, 42)), "back among the places");1502 assert_eq!(
1503 build(&mut ui),
1504 Some(popup::Pick::Run(1, 42)),
1505 "back among the places"
1506 );
1507}
1508
1509#[test]
1510fn the_palette_opens_an_items_actions_on_command_k_or_a_right_click() {
1511 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
1512 let palette = Id::ROOT.child("palette");
1513 let actions = popup::actions(palette);
1514 let mut places = items();
1515 // The first repeats an item further on, as a recent page does.
1516 places[0].repeated = true;
1517 let build = |ui: &mut Ui| {
1518 let mut picked = None;
1519 frame(ui, |ui| {
1520 picked = popup::palette(ui, palette, &[("", &places)], "Go to");
1521 popup::menu(
1522 ui,
1523 actions,
1524 Anchor::Point([0.0; 2]),
1525 &items(),
1526 Some("Actions"),
1527 );
1528 });
1529 picked
1530 };
1531 build(&mut ui);
1532 ui.open_popup(palette);
1533 build(&mut ui);
1534 ui.event(key(NamedKey::ArrowDown));
1535 build(&mut ui);
1536 ui.event(Event::Modifiers(SHORTCUT));
1537 ui.event(typed("k"));
1538 assert_eq!(build(&mut ui), Some(popup::Pick::Actions(0, 0)));
1539 assert!(ui.popup_open(actions));
1540 assert_eq!(
1541 popup::query(&ui, palette),
1542 Some(""),
1543 "the chord types nothing"
1544 );
1545 ui.event(Event::Modifiers(ModifiersState::empty()));
1546 build(&mut ui);
1547 ui.event(key(NamedKey::Escape));
1548 build(&mut ui);
1549 assert!(
1550 ui.popup_open(palette) && !ui.popup_open(actions),
1551 "Escape goes back to the palette"
1552 );
1553 let row = ui.rect(palette.child("rows").child(3_u64)).unwrap();
1554 ui.event(Event::PointerMoved([row[0] + 4.0, row[1] + 4.0]));
1555 ui.event(Event::Button {
1556 button: MouseButton::Right,
1557 pressed: true,
1558 at: Instant::now(),
1559 });
1560 assert_eq!(build(&mut ui), Some(popup::Pick::Actions(0, 3)));
1561 assert!(ui.popup_open(actions));
1562 ui.event(key(NamedKey::Escape));
1563 build(&mut ui);
1564 ui.event(typed("c"));
1565 build(&mut ui);
1566 assert_eq!(
1567 ui.rect(palette.child("rows").child(0_u64)),
1568 None,
1569 "a repeated item shows only while unfiltered"
1570 );
1397}1571}
13981572
1399#[test]1573#[test]
...@@ -2297,11 +2471,21 @@ fn every_control_in_an_open_dialog_takes_clicks_where_it_rests() {...@@ -2297,11 +2471,21 @@ fn every_control_in_an_open_dialog_takes_clicks_where_it_rests() {
2297fn a_combo_s_field_widens_over_rows_laid_out_where_they_end_and_its_rows_take_clicks() {2471fn a_combo_s_field_widens_over_rows_laid_out_where_they_end_and_its_rows_take_clicks() {
2298 let mut ui = Ui::new(Theme::light(), DOUBLE_CLICK);2472 let mut ui = Ui::new(Theme::light(), DOUBLE_CLICK);
2299 let fonts = fonts();2473 let fonts = fonts();
2300 let combo = Id::ROOT.child("combo");2474 // Inset, as a popup keeps off the window's edges.
2475 let combo = Id::ROOT.child("inset").child("combo");
2301 let mut chosen = None;2476 let mut chosen = None;
2302 let mut build = |ui: &mut Ui| {2477 let mut build = |ui: &mut Ui| {
2303 frame(ui, |ui| {2478 frame(ui, |ui| {
2479 ui.open(
2480 "inset",
2481 Spec {
2482 size: [children(), children()],
2483 pad: [10.0, 10.0],
2484 ..Spec::default()
2485 },
2486 );
2304 shell::combo(ui, "combo", "Font", "Calibri", 120.0, menu_id(), true);2487 shell::combo(ui, "combo", "Font", "Calibri", 120.0, menu_id(), true);
2488 ui.close();
2305 let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default());2489 let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default());
2306 chosen = popup::menu(ui, menu_id(), anchor, &fonts, Some("Font"));2490 chosen = popup::menu(ui, menu_id(), anchor, &fonts, Some("Font"));
2307 });2491 });
...@@ -2754,3 +2938,106 @@ fn password_fields_edit_the_characters_behind_their_bullets() {...@@ -2754,3 +2938,106 @@ fn password_fields_edit_the_characters_behind_their_bullets() {
2754 edit(&mut ui, &mut text);2938 edit(&mut ui, &mut text);
2755 assert_eq!(text, "pß");2939 assert_eq!(text, "pß");
2756}2940}
2941
2942#[test]
2943fn a_loose_dialog_fits_its_contents_to_the_window_and_its_side_stretches_to_its_list() {
2944 let dialog = Id::ROOT.child("dialog");
2945 let side = dialog.child("body").child("side");
2946 let list = dialog.child("body").child("list");
2947 let loose = Extent {
2948 size: Size::Children,
2949 strictness: 0.0,
2950 };
2951 let build = |ui: &mut Ui, rows: usize| {
2952 frame(ui, |ui| {
2953 ui.open_as(
2954 dialog,
2955 Spec {
2956 axis: Axis::Y,
2957 size: [px(300.0), loose],
2958 anchor: Some(Anchor::Dialog),
2959 ..Spec::default()
2960 },
2961 );
2962 ui.leaf(
2963 "header",
2964 Spec {
2965 size: [fill(), px(30.0)],
2966 ..Spec::default()
2967 },
2968 );
2969 ui.open(
2970 "body",
2971 Spec {
2972 size: [fill(), loose],
2973 ..Spec::default()
2974 },
2975 );
2976 ui.leaf(
2977 "side",
2978 Spec {
2979 size: [px(100.0), fill()],
2980 ..Spec::default()
2981 },
2982 );
2983 ui.open(
2984 "list",
2985 Spec {
2986 flags: Flags::SCROLL | Flags::CLIP,
2987 axis: Axis::Y,
2988 size: [fill(), loose],
2989 ..Spec::default()
2990 },
2991 );
2992 for row in 0..rows {
2993 ui.leaf(
2994 row,
2995 Spec {
2996 size: [fill(), px(30.0)],
2997 ..Spec::default()
2998 },
2999 );
3000 }
3001 ui.close();
3002 ui.close();
3003 ui.leaf(
3004 "footer",
3005 Spec {
3006 size: [fill(), px(30.0)],
3007 ..Spec::default()
3008 },
3009 );
3010 ui.close();
3011 });
3012 };
3013 let height = |ui: &Ui, id: Id| ui.rect(id).map(|rect| rect[3] - rect[1]).unwrap();
3014 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
3015 build(&mut ui, 2);
3016 ui.open_popup(dialog);
3017 for _ in 0..40 {
3018 build(&mut ui, 2);
3019 }
3020 assert_eq!(height(&ui, dialog), 120.0, "as tall as what it holds");
3021 assert_eq!(height(&ui, side), 60.0, "the side as tall as the list");
3022
3023 for _ in 0..40 {
3024 build(&mut ui, 20);
3025 }
3026 assert_eq!(
3027 height(&ui, dialog),
3028 225.0,
3029 "no taller than the window lets a dialog be"
3030 );
3031 assert_eq!(height(&ui, list), 165.0, "the list gives way");
3032 assert_eq!(height(&ui, side), 165.0);
3033
3034 ui.scroll_to(list, list.child(10_usize));
3035 for _ in 0..60 {
3036 build(&mut ui, 20);
3037 }
3038 let [top, row] = [list, list.child(10_usize)].map(|id| ui.rect(id).unwrap()[1]);
3039 assert!(
3040 (row - top).abs() < 0.5,
3041 "row 10 at {row}, the list's top at {top}"
3042 );
3043}
crates/ui/src/text.rs+21-4
...@@ -12,7 +12,7 @@ pub(crate) struct Label {...@@ -12,7 +12,7 @@ pub(crate) struct Label {
12 pub key: LabelKey,12 pub key: LabelKey,
13}13}
1414
15type LabelKey = (String, u32, bool, Option<String>);15type LabelKey = (String, u32, bool, bool, Option<String>);
1616
17/// Labels shaped this frame or the previous one, keyed by their text and size.17/// Labels shaped this frame or the previous one, keyed by their text and size.
18#[derive(Default)]18#[derive(Default)]
...@@ -32,11 +32,25 @@ impl Texts {...@@ -32,11 +32,25 @@ impl Texts {
32 bold: bool,32 bold: bool,
33 font: Option<&str>,33 font: Option<&str>,
34 frame: u64,34 frame: u64,
35 ) -> Rc<Label> {
36 self.styled(text, size, bold, false, font, frame)
37 }
38
39 /// `label`, slanted where `italic`.
40 pub fn styled(
41 &mut self,
42 text: &str,
43 size: f32,
44 bold: bool,
45 italic: bool,
46 font: Option<&str>,
47 frame: u64,
35 ) -> Rc<Label> {48 ) -> Rc<Label> {
36 let key = (49 let key = (
37 text.to_owned(),50 text.to_owned(),
38 size.to_bits(),51 size.to_bits(),
39 bold,52 bold,
53 italic,
40 font.map(str::to_owned),54 font.map(str::to_owned),
41 );55 );
42 if let Some((label, touched)) = self.cache.get_mut(&key) {56 if let Some((label, touched)) = self.cache.get_mut(&key) {
...@@ -62,6 +76,9 @@ impl Texts {...@@ -62,6 +76,9 @@ impl Texts {
62 if bold {76 if bold {
63 builder.push_default(StyleProperty::FontWeight(parley::FontWeight::SEMI_BOLD));77 builder.push_default(StyleProperty::FontWeight(parley::FontWeight::SEMI_BOLD));
64 }78 }
79 if italic {
80 builder.push_default(StyleProperty::FontStyle(parley::FontStyle::Italic));
81 }
65 let mut layout = builder.build(text);82 let mut layout = builder.build(text);
66 layout.break_all_lines(None);83 layout.break_all_lines(None);
67 let size = [layout.full_width(), layout.height()];84 let size = [layout.full_width(), layout.height()];
...@@ -76,9 +93,9 @@ impl Texts {...@@ -76,9 +93,9 @@ impl Texts {
7693
77 /// `label` cut short to fit `width` with an ellipsis.94 /// `label` cut short to fit `width` with an ellipsis.
78 pub fn ellipsis(&mut self, label: &Label, width: f32, frame: u64) -> Rc<Label> {95 pub fn ellipsis(&mut self, label: &Label, width: f32, frame: u64) -> Rc<Label> {
79 let (text, size, bold, font) = &label.key;96 let (text, size, bold, italic, font) = &label.key;
80 let (size, font) = (f32::from_bits(*size), font.as_deref());97 let (size, font) = (f32::from_bits(*size), font.as_deref());
81 let room = width - self.label("…", size, *bold, font, frame).size[0];98 let room = width - self.styled("…", size, *bold, *italic, font, frame).size[0];
82 let mut end = 0;99 let mut end = 0;
83 let mut advance = 0.0;100 let mut advance = 0.0;
84 let mut cluster = Cluster::from_byte_index(&label.layout, 0);101 let mut cluster = Cluster::from_byte_index(&label.layout, 0);
...@@ -91,7 +108,7 @@ impl Texts {...@@ -91,7 +108,7 @@ impl Texts {
91 cluster = next.next_logical();108 cluster = next.next_logical();
92 }109 }
93 let cut = format!("{}…", text[..end].trim_end());110 let cut = format!("{}…", text[..end].trim_end());
94 self.label(&cut, size, *bold, font, frame)111 self.styled(&cut, size, *bold, *italic, font, frame)
95 }112 }
96113
97 /// Makes `family` the interface's font, where fontique doesn't know the system's.114 /// Makes `family` the interface's font, where fontique doesn't know the system's.
tools/RELEASE.md+1-1
...@@ -55,7 +55,7 @@ the account holder's legal name, which nothing here prints or stores....@@ -55,7 +55,7 @@ the account holder's legal name, which nothing here prints or stores.
55provisioning profile for `net.paperclover.snowbound` ("Snowbound Developer ID",55provisioning profile for `net.paperclover.snowbound` ("Snowbound Developer ID",
56found where Xcode keeps profiles) as `Contents/embedded.provisionprofile`, with56found where Xcode keeps profiles) as `Contents/embedded.provisionprofile`, with
57hardened runtime, a secure timestamp, the production iCloud container57hardened runtime, a secure timestamp, the production iCloud container
58`iCloud.net.paperclover.snowbound` that Use iCloud Drive needs, and the58`iCloud.net.paperclover.snowbound` that iCloud notebooks need, and the
59microphone and camera entitlements recording needs. It fails rather than fall59microphone and camera entitlements recording needs. It fails rather than fall
60back to ad hoc. `--ad-hoc` signs ad hoc instead, without iCloud; the 10.6 bundle60back to ad hoc. `--ad-hoc` signs ad hoc instead, without iCloud; the 10.6 bundle
61stays unsigned, as it predates Developer ID. codesign fails with61stays unsigned, as it predates Developer ID. codesign fails with
tools/canvas/README.md+2-2
...@@ -14,7 +14,7 @@ cargo test -p draw -- --ignored...@@ -14,7 +14,7 @@ cargo test -p draw -- --ignored
14cargo test -p canvas --features gpu gpu:: -- --ignored14cargo test -p canvas --features gpu gpu:: -- --ignored
15```15```
1616
17The builder signs and verifies the local bundle: with team `9R7DPNW28H`'s Developer ID Application identity (found in the keychain by team, chosen by SHA-1, or `--sign-identity SHA1`) and a Developer ID provisioning profile for `net.paperclover.snowbound` naming `iCloud.net.paperclover.snowbound` (found in Xcode's profile folders, or `--profile PATH`), with hardened runtime and the iCloud container, which Use iCloud Drive needs; without both, ad hoc. `--sign developer-id` fails instead of falling back, and `--sign ad-hoc` skips Developer ID. To preserve an existing app during review, provide a new bundle path and a distinct identifier together:17The builder signs and verifies the local bundle: with team `9R7DPNW28H`'s Developer ID Application identity (found in the keychain by team, chosen by SHA-1, or `--sign-identity SHA1`) and a Developer ID provisioning profile for `net.paperclover.snowbound` naming `iCloud.net.paperclover.snowbound` (found in Xcode's profile folders, or `--profile PATH`), with hardened runtime and the iCloud container, which iCloud notebooks need; without both, ad hoc. `--sign developer-id` fails instead of falling back, and `--sign ad-hoc` skips Developer ID. To preserve an existing app during review, provide a new bundle path and a distinct identifier together:
1818
19```sh19```sh
20python3 tools/canvas/build_macos.py --release --output '/PATH/Snowbound Review.app' --bundle-id net.paperclover.snowbound.review20python3 tools/canvas/build_macos.py --release --output '/PATH/Snowbound Review.app' --bundle-id net.paperclover.snowbound.review
...@@ -52,7 +52,7 @@ snowbound title bar · toolbar · section frame · page tabs · the page as a...@@ -52,7 +52,7 @@ snowbound title bar · toolbar · section frame · page tabs · the page as a
5252
53Launched without a file, the app reopens the notebooks open when it last closed, at the one shown last, or shows "No notebooks open" with New Notebook and Open Existing. Settings (the open notebooks, the sidebar, recent fonts and the user name edits are stored under, which Settings… (⌘,; Options in the sidebar and Ctrl+, on Linux) changes and which starts as the account's full name) are JSON in `~/Library/Application Support/Snowbound/settings.json` or `$XDG_CONFIG_HOME/snowbound`; `--settings FILE` uses another file, `--notebook FOLDER` adds a notebook to them, and `--screenshot` never writes them. Open Existing takes a notebook folder, its `Open Notebook.onetoc2` or a section file, which opens in the notebook its folders make up. A notebook on a mounted SMB share opens through the embedded SMB client, signed in with the password the system keychain holds, and through the mount only when that fails. The notebook button left of the section tabs opens OneNote 2010's navigation bar: the open notebooks with their sections and section groups, the open section marked; dragging a row reorders a folder or, onto a group, moves into it, and right-clicking a row, a section tab or a page tab offers OneNote's commands (Rename, Delete to the notebook's recycle bin, New Section, New Section Group, Close This Notebook; Delete, New Page, Make Subpage, Promote Subpage). Page tabs drag to reorder. The `+` above the page tabs adds a page at the end of the section, titled with its date and time as OneNote titles one, and puts the caret in its title; until its body is typed in, templates offer our recreations of OneNote 2010's art (Ivy first), its page colours and Informal Meeting Notes' content, written as ops (`corpus/notebook-management`).53Launched without a file, the app reopens the notebooks open when it last closed, at the one shown last, or shows "No notebooks open" with New Notebook and Open Existing. Settings (the open notebooks, the sidebar, recent fonts and the user name edits are stored under, which Settings… (⌘,; Options in the sidebar and Ctrl+, on Linux) changes and which starts as the account's full name) are JSON in `~/Library/Application Support/Snowbound/settings.json` or `$XDG_CONFIG_HOME/snowbound`; `--settings FILE` uses another file, `--notebook FOLDER` adds a notebook to them, and `--screenshot` never writes them. Open Existing takes a notebook folder, its `Open Notebook.onetoc2` or a section file, which opens in the notebook its folders make up. A notebook on a mounted SMB share opens through the embedded SMB client, signed in with the password the system keychain holds, and through the mount only when that fails. The notebook button left of the section tabs opens OneNote 2010's navigation bar: the open notebooks with their sections and section groups, the open section marked; dragging a row reorders a folder or, onto a group, moves into it, and right-clicking a row, a section tab or a page tab offers OneNote's commands (Rename, Delete to the notebook's recycle bin, New Section, New Section Group, Close This Notebook; Delete, New Page, Make Subpage, Promote Subpage). Page tabs drag to reorder. The `+` above the page tabs adds a page at the end of the section, titled with its date and time as OneNote titles one, and puts the caret in its title; until its body is typed in, templates offer our recreations of OneNote 2010's art (Ivy first), its page colours and Informal Meeting Notes' content, written as ops (`corpus/notebook-management`).
5454
55The `ui` crate builds every frame from the application's state. A cache keyed by stable box ids keeps hover, press, focus, scroll and animation values, and the previous frame's layout routes the frame's input before building, so a frame answers input one layout late and paints with its own. Sizes are solved per axis after building: fixed, label-sized, a fraction of an ancestor, or the sum of children, with overflow shared out by each box's strictness. The page is a custom box: `ui` routes it the pointer, wheel, key and input-method events that land on it, in order, and the host hands them to `PageView` and paints the page in a clipped layer of the same frame. Page scrollbars are `ui` widgets over that box; the canvas reports only its scroll bounds. Text fields edit through `draw::edit`, as the page does, so keys, clicks and drags select and move the same way in both. Work the frame asks for (page requests, saving) runs after the frame is painted and requests the frame that shows it. Opening a section or page reads and lays it out on a thread of its own while the current page stays live; the newest replaces the page once the pictures it shows first are drawn, or after 200 ms.55The `ui` crate builds every frame from the application's state. A cache keyed by stable box ids keeps hover, press, focus, scroll and animation values, and the previous frame's layout routes the frame's input before building, so a frame answers input one layout late and paints with its own. Sizes are solved per axis after building: fixed, label-sized, a fraction of an ancestor, or the sum of children, with overflow shared out by each box's strictness. The page is a custom box: `ui` routes it the pointer, wheel, key and input-method events that land on it, in order, and the host hands them to `PageView` and paints the page in a clipped layer of the same frame. Page scrollbars are `ui` widgets over that box; the canvas reports only its scroll bounds. Text fields edit through `draw::edit`, as the page does, so keys, clicks and drags select and move the same way in both. Work the frame asks for (page requests, saving) runs after the frame is painted and requests the frame that shows it. Opening a section or page reads and lays it out on a thread of its own while the current page stays live; the newest replaces the page once the pictures it shows first are drawn, or after 200 ms. Its tab is selected at once, and an open that takes over 80 ms shows a page's outline in place of the page leaving, which takes no input. The pages beside the open one, the sections beside the open section and a hovered page or section tab are read ahead on a thread of their own. A notebook keeps the last three sections left or read ahead open, which `Library::open` hands out, and the last 32 pages shown or read ahead keep their scenes, with up to 128 MiB of decoded pictures, so a page shown again is laid out afresh around pictures already drawn.
5656
57A covered window receives no redraws, so interaction can be scripted: `SNOWBOUND_REPLAY` names a file of `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `pinch FACTOR`, `key NAME`, `type TEXT`, `modifiers [shift] [command]`, `wait MS`, `snapshot PNG_PATH`, `accessibility TEXT_PATH` (the window's accessibility tree, a node a line, as the platform shows it), `appearance light|dark`, `resize WIDTH HEIGHT` and `quit` lines in logical pixels, and each step and every 16 ms of a wait draws a frame. With `--screenshot` as well, the window stays hidden and only the replay's snapshots capture it. Snapshots wait for the page's pictures, file icons and background art to finish rasterizing; `SNOWBOUND_FRAMES` frames never wait, so art appears in the first frame drawn after it is ready. Snapshots omit the window's traffic lights, which AppKit draws. Replays edit and save like a user, so point them at a copy of a notebook. Their edits carry the settings' user name, so give fixture runs a `--settings` file holding `{"user_name": "Snowbound Test"}`.57A covered window receives no redraws, so interaction can be scripted: `SNOWBOUND_REPLAY` names a file of `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `pinch FACTOR`, `key NAME`, `type TEXT`, `modifiers [shift] [command]`, `wait MS`, `snapshot PNG_PATH`, `accessibility TEXT_PATH` (the window's accessibility tree, a node a line, as the platform shows it), `appearance light|dark`, `resize WIDTH HEIGHT` and `quit` lines in logical pixels, and each step and every 16 ms of a wait draws a frame. With `--screenshot` as well, the window stays hidden and only the replay's snapshots capture it. Snapshots wait for the page's pictures, file icons and background art to finish rasterizing; `SNOWBOUND_FRAMES` frames never wait, so art appears in the first frame drawn after it is ready. Snapshots omit the window's traffic lights, which AppKit draws. Replays edit and save like a user, so point them at a copy of a notebook. Their edits carry the settings' user name, so give fixture runs a `--settings` file holding `{"user_name": "Snowbound Test"}`.
5858
tools/canvas/build_macos.py+18-1
...@@ -39,11 +39,14 @@ if args.output and (args.output.suffix != '.app' or args.output.exists()):...@@ -39,11 +39,14 @@ if args.output and (args.output.suffix != '.app' or args.output.exists()):
39root = Path(__file__).resolve().parents[2]39root = Path(__file__).resolve().parents[2]
40# The Apple Developer team and what is registered under it. With the team's Developer ID40# The Apple Developer team and what is registered under it. With the team's Developer ID
41# Application identity and a Developer ID profile for BUNDLE_ID naming CONTAINER, the app is41# Application identity and a Developer ID profile for BUNDLE_ID naming CONTAINER, the app is
42# signed with hardened runtime and the iCloud container (Use iCloud Drive, NSUbiquitousContainers);42# signed with hardened runtime and the iCloud container (iCloud notebooks, NSUbiquitousContainers);
43# without them, ad hoc, and iCloud Drive works through folders the user picks.43# without them, ad hoc, and iCloud Drive works through folders the user picks.
44TEAM = '9R7DPNW28H'44TEAM = '9R7DPNW28H'
45BUNDLE_ID = 'net.paperclover.snowbound'45BUNDLE_ID = 'net.paperclover.snowbound'
46CONTAINER = 'iCloud.net.paperclover.snowbound'46CONTAINER = 'iCloud.net.paperclover.snowbound'
47# OneNote's sections and tables of contents, which no Mac app declares; OneNote may open them too.
48ONENOTE_TYPES = [('com.microsoft.onenote.section', 'OneNote Section', 'one'),
49 ('com.microsoft.onenote.table-of-contents', 'OneNote Table of Contents', 'onetoc2')]
4750
4851
49def developer_id():52def developer_id():
...@@ -147,6 +150,20 @@ if build:...@@ -147,6 +150,20 @@ if build:
147 'NSUbiquitousContainerSupportedFolderLevels': 'Any',150 'NSUbiquitousContainerSupportedFolderLevels': 'Any',
148 }},151 }},
149 'LSMinimumSystemVersion': minimum,152 'LSMinimumSystemVersion': minimum,
153 'CFBundleDocumentTypes': [{
154 'CFBundleTypeName': 'OneNote Notebook',
155 'CFBundleTypeRole': 'Editor',
156 'LSHandlerRank': 'Alternate',
157 'LSItemContentTypes': [identifier for identifier, _, _ in ONENOTE_TYPES],
158 'CFBundleTypeIconSystemGenerated': 1,
159 }],
160 'UTImportedTypeDeclarations': [{
161 'UTTypeIdentifier': identifier,
162 'UTTypeDescription': description,
163 'UTTypeConformsTo': ['public.data'],
164 'UTTypeTagSpecification': {'public.filename-extension': [extension],
165 'public.mime-type': 'application/onenote'},
166 } for identifier, description, extension in ONENOTE_TYPES],
150} | versions))167} | versions))
151# 10.6 runs the bundle unsigned.168# 10.6 runs the bundle unsigned.
152if not args.snow_leopard:169if not args.snow_leopard:
tools/native/styles.ps1 created+7
...@@ -0,0 +1,7 @@
1param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost)
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost
5# Snowbound makes its folder hidden, which the transfer's archive does not keep.
6$sidecar = Get-Item -LiteralPath (Join-Path $Root 'notebook\.snowbound') -Force
7$sidecar.Attributes = $sidecar.Attributes -bor [IO.FileAttributes]::Hidden
tools/native_styles.py created+15
...@@ -0,0 +1,15 @@
1"""Cold-open a notebook of themed pages in OneNote 2010 and capture what it draws and
2reports of each page's styles.
3
4Usage: native_styles.py NOTEBOOK_DIR OUTPUT_DIR
5"""
6import sys
7from pathlib import Path
8
9sys.path.insert(0, str(Path(__file__).resolve().parent))
10import native_runner as runner
11
12if __name__ == '__main__':
13 notebook, output = Path(sys.argv[1]), Path(sys.argv[2])
14 runner.capture(notebook, output, expected_pages=4, author=Path(__file__).resolve().parent / 'native/styles.ps1',
15 screenshots=True, collect_notebook=True)
tools/test_default_font.py created+37
...@@ -0,0 +1,37 @@
1"""VM-free gate for corpus/default-font: OneNote 2010 reads the editor's new text and title
2in the Default font, colour included, as the quick styles it writes itself."""
3
4import pathlib
5import unittest
6import xml.etree.ElementTree as ElementTree
7
8ROOT = pathlib.Path(__file__).resolve().parents[1] / "corpus" / "default-font"
9ONE = "{http://schemas.microsoft.com/office/onenote/2010/onenote}"
10
11
12class DefaultFont(unittest.TestCase):
13 def test_cold_read_keeps_the_default_font_styles(self):
14 page = ElementTree.parse(ROOT / "cold" / "page.xml").getroot()
15 styles = {
16 style.get("index"): (
17 style.get("name"),
18 style.get("font"),
19 style.get("fontSize"),
20 style.get("fontColor"),
21 )
22 for style in page.iter(ONE + "QuickStyleDef")
23 }
24 self.assertIn(("PageTitle", "Georgia", "17.0", "#0070C0"), styles.values())
25 bodies = [
26 element
27 for outline in page.iter(ONE + "Outline")
28 for element in outline.iter(ONE + "OE")
29 ]
30 self.assertEqual(len(bodies), 2)
31 for body in bodies:
32 self.assertEqual(styles[body.get("quickStyleIndex")], ("p", "Georgia", "14.0", "#0070C0"))
33 self.assertIsNone(body.find(ONE + "T").get("style"))
34
35
36if __name__ == "__main__":
37 unittest.main()
tools/test_object_tags.py created+56
...@@ -0,0 +1,56 @@
1from pathlib import Path
2import re
3import runpy
4import shutil
5from tempfile import TemporaryDirectory
6import unittest
7
8ROOT = Path(__file__).resolve().parent.parent
9FIXTURE = ROOT / 'corpus/object-tags'
10compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
11
12
13def page(read, title):
14 for path in sorted(read.glob('page-*.xml')):
15 xml = path.read_text(encoding='utf-8-sig')
16 if re.search(r'<one:Page [^>]*name="%s"' % re.escape(title), xml):
17 return xml
18 raise AssertionError(title)
19
20
21def tags(xml):
22 """Each object's check box state in page order: the outline's picture and file, then
23 the picture and file on the page."""
24 return [m == 'true' for m in re.findall(r'<one:Tag index="0" completed="(\w+)"', xml)]
25
26
27class ObjectTagsTest(unittest.TestCase):
28 def test_tags_on_pictures_and_files_reopen_as_checked(self):
29 with TemporaryDirectory() as temporary:
30 read = Path(temporary) / 'read'
31 shutil.copytree(FIXTURE / 'cold/read', read)
32 compare(FIXTURE / 'candidate', read)
33 native = page(FIXTURE / 'native/read', 'Pictures and files')
34 cold = page(FIXTURE / 'cold/read', 'Pictures and files')
35 self.assertEqual(tags(native), [False] * 4)
36 self.assertEqual(tags(cold), [True, False, True, True])
37 # OneNote keeps its tags on the objects themselves, and the picture's recognised text.
38 for xml in (native, cold):
39 self.assertRegex(xml, r'<one:Image [^>]*>(<one:Position[^>]*/>)?<one:Size[^>]*/><one:Tag ')
40 self.assertRegex(xml, r'<one:InsertedFile [^>]*>(<one:Position[^>]*/>)?<one:Size[^>]*/><one:Tag ')
41 self.assertIn('<one:OCRText><![CDATA[GLACIER HARBOR\nInvoice 4471]]></one:OCRText>', xml)
42
43 def test_restored_picture_reopens_with_its_recognised_text(self):
44 restored = FIXTURE / 'restored'
45 with TemporaryDirectory() as temporary:
46 read = Path(temporary) / 'read'
47 shutil.copytree(restored / 'cold/read', read)
48 compare(restored / 'candidate', read)
49 ocr = lambda xml: re.findall(r'<one:OCRData.*?</one:OCRData>', xml, re.S)
50 native = ocr(page(FIXTURE / 'native/read', 'Pictures and files'))
51 self.assertEqual(len(native), 1)
52 self.assertEqual(ocr(page(restored / 'cold/read', 'Pictures and files')), native)
53
54
55if __name__ == '__main__':
56 unittest.main()
tools/test_picture_link.py created+27
...@@ -0,0 +1,27 @@
1from pathlib import Path
2import re
3import runpy
4import shutil
5from tempfile import TemporaryDirectory
6import unittest
7
8ROOT = Path(__file__).resolve().parent.parent
9FIXTURE = ROOT / 'corpus/picture-link'
10compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
11
12
13class PictureLinkTest(unittest.TestCase):
14 def test_restored_picture_reopens_with_its_link(self):
15 with TemporaryDirectory() as temporary:
16 read = Path(temporary) / 'read'
17 shutil.copytree(FIXTURE / 'cold/read', read)
18 compare(FIXTURE / 'candidate', read)
19 links = sorted(m for path in (FIXTURE / 'cold/read').glob('page-*.xml')
20 for m in re.findall(r'<one:Image [^>]*hyperlink="([^"]*)"',
21 path.read_text(encoding='utf-8-sig')))
22 self.assertIn('https://example.invalid/image/png', links)
23 self.assertEqual(len(links), 5)
24
25
26if __name__ == '__main__':
27 unittest.main()
tools/test_printout.py+2-2
...@@ -32,9 +32,9 @@ class PrintoutTest(unittest.TestCase):...@@ -32,9 +32,9 @@ class PrintoutTest(unittest.TestCase):
32 native = pictures(FIXTURE / 'native/read')32 native = pictures(FIXTURE / 'native/read')
33 self.assertEqual([(p, n) for p, n, _ in native], [('true', '0'), ('true', '1')])33 self.assertEqual([(p, n) for p, n, _ in native], [('true', '0'), ('true', '1')])
34 # The first page moved half an inch down stays a printout; the second, deleted and34 # The first page moved half an inch down stays a printout; the second, deleted and
35 # restored, is the picture OneNote showed of it.35 # restored, is the printout page it was.
36 cold = pictures(FIXTURE / 'cold/read')36 cold = pictures(FIXTURE / 'cold/read')
37 self.assertEqual(cold, [('true', '0', str(float(native[0][2]) + 36)), (None, None, native[1][2])])37 self.assertEqual(cold, [('true', '0', str(float(native[0][2]) + 36)), ('true', '1', native[1][2])])
3838
3939
40if __name__ == '__main__':40if __name__ == '__main__':
tools/test_recycle_purge.py created+29
...@@ -0,0 +1,29 @@
1"""VM-free gate for corpus/recycle-purge: OneNote 2010 reads a recycle bin Snowbound purged
2as empty, without repairing it."""
3
4import pathlib
5import unittest
6import xml.etree.ElementTree as ElementTree
7
8ROOT = pathlib.Path(__file__).resolve().parents[1] / "corpus" / "recycle-purge"
9ONE = "{http://schemas.microsoft.com/office/onenote/2010/onenote}"
10
11
12class RecyclePurge(unittest.TestCase):
13 def test_cold_read_finds_the_bin_empty(self):
14 notebook = ElementTree.parse(ROOT / "cold" / "hierarchy.xml").getroot()
15 bin_ = next(
16 group
17 for group in notebook.iter(ONE + "SectionGroup")
18 if group.get("isRecycleBin") == "true"
19 )
20 sections = list(bin_.iter(ONE + "Section"))
21 self.assertEqual([section.get("name") for section in sections], ["Deleted Pages"])
22 self.assertEqual(list(sections[0].iter(ONE + "Page")), [])
23 kept = [section.get("name") for section in notebook.findall(ONE + "Section")]
24 self.assertEqual(kept, ["New Section 1"])
25 self.assertFalse((ROOT / "candidate" / "OneNote_RecycleBin" / "Old.one").exists())
26
27
28if __name__ == "__main__":
29 unittest.main()
tools/test_section_color.py created+30
...@@ -0,0 +1,30 @@
1"""VM-free gate for corpus/section-color: OneNote 2010 reads each section the app coloured, None
2included, and the notebook's colour as its own menus set them."""
3
4import pathlib
5import unittest
6import xml.etree.ElementTree as ElementTree
7
8ROOT = pathlib.Path(__file__).resolve().parents[1] / "corpus" / "section-color"
9ONE = "{http://schemas.microsoft.com/office/onenote/2010/onenote}"
10
11
12class SectionColor(unittest.TestCase):
13 def test_cold_read_keeps_every_colour(self):
14 menu = [
15 line.rsplit(" ", 1)
16 for line in (ROOT / "native" / "colors.txt").read_text().splitlines()
17 ]
18 hierarchy = ElementTree.parse(ROOT / "cold" / "read" / "hierarchy.xml").getroot()
19 notebook = next(hierarchy.iter(ONE + "Notebook"))
20 self.assertEqual(notebook.get("color"), "#4DBCCA")
21 sections = {
22 section.get("name"): section.get("color")
23 for section in notebook.iter(ONE + "Section")
24 }
25 self.assertEqual(sections.pop("New Section 1"), "#8AA8E4")
26 self.assertEqual(sections, dict(menu))
27
28
29if __name__ == "__main__":
30 unittest.main()
tools/test_styles.py created+59
...@@ -0,0 +1,59 @@
1from pathlib import Path
2import runpy
3import shutil
4from tempfile import TemporaryDirectory
5import unittest
6import xml.etree.ElementTree as ET
7
8from native_xml import ns
9
10ROOT = Path(__file__).resolve().parent.parent
11FIXTURE = ROOT / 'corpus/styles'
12compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
13GALLERY = {'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'PageTitle', 'cite', 'blockquote', 'code', 'p'}
14
15
16def styles(path):
17 """The page's name and its quick styles by name: font, size, bold, colour."""
18 page = ET.parse(path).getroot()
19 defined = {}
20 for style in page.findall('one:QuickStyleDef', ns):
21 defined.setdefault(style.get('name'), []).append(
22 (style.get('font'), float(style.get('fontSize')), style.get('bold') == 'true', style.get('fontColor')))
23 return page.get('name'), defined
24
25
26class StylesTest(unittest.TestCase):
27 def test_themed_pages_cold_open_as_written(self):
28 with TemporaryDirectory() as temporary:
29 read = Path(temporary) / 'read'
30 shutil.copytree(FIXTURE / 'cold/read', read)
31 compare(FIXTURE / 'candidate', read)
32
33 def test_onenote_lists_each_theme_under_its_names(self):
34 heading = {
35 'OneNote': ('Calibri', 16.0, True, '#17365D'),
36 'Manuscript': ('Georgia', 20.0, False, '#3B2F2A'),
37 'Editorial': ('Georgia', 18.0, True, '#9A3B1F'),
38 'Modern': ('Arial', 16.0, True, '#0E6E6E'),
39 }
40 seen = set()
41 for path in sorted((FIXTURE / 'cold/read').glob('page-*.xml')):
42 name, defined = styles(path)
43 theme = name.removeprefix('Heading 1 in ')
44 seen.add(theme)
45 self.assertEqual(set(defined), GALLERY, theme)
46 self.assertEqual(defined['h1'], [heading[theme]], theme)
47 self.assertEqual(seen, set(heading))
48
49 def test_onenote_s_own_styles_read_under_the_same_names(self):
50 _, applied = styles(FIXTURE / 'onenote/applied.xml')
51 self.assertEqual(set(applied), GALLERY)
52 # OneNote's gallery on a themed page adds its own h2, and Enter its own p.
53 _, edited = styles(FIXTURE / 'onenote-edit/Manuscript.xml')
54 self.assertEqual(sorted(edited['h2']), [('Calibri', 13.0, True, '#366092'), ('Georgia', 16.0, True, '#3B2F2A')])
55 self.assertEqual(edited['p'], [('Calibri', 11.0, False, 'automatic')])
56
57
58if __name__ == '__main__':
59 unittest.main()
tools/test_table_widths.py created+48
...@@ -0,0 +1,48 @@
1from pathlib import Path
2import runpy
3import shutil
4from tempfile import TemporaryDirectory
5import unittest
6import xml.etree.ElementTree as ET
7
8from native_xml import ns
9
10ROOT = Path(__file__).resolve().parent.parent
11FIXTURE = ROOT / 'corpus/table-widths'
12compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
13
14
15def tables(read):
16 """Each page's tables by title: their first cell text and (width, locked) columns."""
17 result = {}
18 for path in sorted(read.glob('page-*.xml')):
19 page = ET.parse(path).getroot()
20 title = page.find('one:Title/one:OE/one:T', ns).text
21 result[title] = [
22 (table.find('.//one:T', ns).text,
23 [(round(float(column.get('width')), 2), column.get('isLocked') == 'true')
24 for column in table.findall('one:Columns/one:Column', ns)])
25 for table in page.iter('{%s}Table' % ns['one'])]
26 return result
27
28
29class TableWidthsTest(unittest.TestCase):
30 def test_typed_and_dragged_widths_reopen_natively(self):
31 with TemporaryDirectory() as temporary:
32 read = Path(temporary) / 'read'
33 shutil.copytree(FIXTURE / 'cold/read', read)
34 compare(FIXTURE / 'candidate', read)
35 cold = tables(FIXTURE / 'cold/read')
36 # OneNote 2010's own widths for the same table typed in it (lab, 2026-09-30).
37 self.assertEqual(cold['Check a table'][1],
38 ('Fruit', [(60.76, False), (37.11, False), (139.09, False)]))
39 self.assertEqual(cold['Edit a tagged table'][0][1], [(180.25, False), (37.11, False)])
40 dragged = cold['Delete and undo'][-1]
41 self.assertEqual(dragged[0], 'Dragged')
42 self.assertEqual(dragged[1][0], (96.0, True))
43 self.assertFalse(dragged[1][1][1])
44 self.assertGreater(dragged[1][1][0], 300)
45
46
47if __name__ == '__main__':
48 unittest.main()