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 @@
44<dict>
55 <key>CADisableMinimumFrameDurationOnPhone</key>
66 <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>
722 <key>LSSupportsOpeningDocumentsInPlace</key>
823 <true/>
924 <key>NSCameraUsageDescription</key>
......@@ -38,5 +53,27 @@
3853 <string>UIInterfaceOrientationLandscapeLeft</string>
3954 <string>UIInterfaceOrientationLandscapeRight</string>
4055 </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>
4178</dict>
4279</plist>
apps/ios/Snowbound/App.swift+64
......@@ -19,6 +19,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
1919 _ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]?
2020 ) -> Bool {
2121 sb_set_coordinator(coordinate)
22 Editing.apply()
2223 ICloud.start()
2324 sb_set_sync_wake { DispatchQueue.main.async { NotificationCenter.default.post(name: Sync.changed, object: nil) } }
2425 #if DEBUG
......@@ -70,6 +71,63 @@ enum Appearance {
7071 }
7172}
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
73131/// Where the reader was, reopened on the next launch as Notes reopens its last note.
74132private struct Place: Codable {
75133 let notebook: String
......@@ -123,6 +181,12 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro
123181 NotificationCenter.default.addObserver(forName: ICloud.changed, object: nil, queue: .main) { [weak self] _ in
124182 self?.notebooks.rescan { self?.restore($0) }
125183 }
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) }
126190 }
127191
128192 /// 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
193193 object: nil)
194194 registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: CanvasView, _) in view.paper() }
195195 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 }
196198 formatBar.onDismiss = { [weak self] in _ = self?.resignFirstResponder() }
197199 }
198200
......@@ -879,6 +881,18 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
879881
880882 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
882896 /// The selection's formatting, from `sb_view_format`.
883897 var format: UInt64 { handle.map(sb_view_format) ?? 0 }
884898
......@@ -934,6 +948,15 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
934948 edit(external: true) { sb_view_apply(handle, command) }
935949 }
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
937960 /// Shows the page as stored; with `discard`, after an edit was refused.
938961 func reload(discard: Bool) {
939962 guard let handle else { return }
apps/ios/Snowbound/FormatBar.swift+21
......@@ -19,6 +19,14 @@ final class FormatBar: UIInputView {
1919 private var toggles: [(UIButton, UInt8)] = []
2020 private var bits: UInt64 = 0
2121 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 ]
2230 /// Asks for a picture from the camera (true) or the photo library.
2331 var onPicture: ((Bool) -> Void)?
2432 var onDismiss: (() -> Void)?
......@@ -33,6 +41,19 @@ final class FormatBar: UIInputView {
3341 addSubview(backdrop)
3442 let row = UIStackView()
3543 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)
3657 for (symbol, label, command) in Self.buttons {
3758 let button = Self.button(symbol, label)
3859 button.addAction(UIAction { [weak self] _ in self?.onApply?(command) }, for: .primaryActionTriggered)
apps/ios/Snowbound/ICloud.swift+14-1
......@@ -54,15 +54,27 @@ enum ICloud {
5454 /// Whether iCloud is signed in with iCloud Drive on, container or not.
5555 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.
5859 static func start() {
5960 sb_set_versions(listVersions, retireVersion)
6061 NotificationCenter.default.addObserver(
6162 forName: .NSUbiquityIdentityDidChange, object: nil, queue: .main
6263 ) { _ in lookUp() }
6364 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()
6473 }
6574
75 /// Reports what iCloud Drive syncs of the container, as files come from other devices.
76 private static let query = NSMetadataQuery()
77
6678 /// Looks the folder up off the main thread, as asking iCloud can take a while.
6779 static func lookUp() {
6880 background({ () -> URL? in
......@@ -77,4 +89,5 @@ enum ICloud {
7789 NotificationCenter.default.post(name: changed, object: nil)
7890 }
7991 }
92
8093}
apps/ios/Snowbound/Ink.swift+7-1
......@@ -240,8 +240,9 @@ final class InkPicker: UIView {
240240 color.accessibilityLabel = "Colour, \(Self.name(shown.color))"
241241 }
242242
243 /// The shapes, then OneNote's Snap To Grid.
243244 private func shapeMenu() -> [UIMenuElement] {
244 Self.shapes.enumerated().map { place, shape in
245 let shapes = Self.shapes.enumerated().map { place, shape in
245246 let kind = UInt8(place)
246247 let action = UIAction(title: shape.1, image: UIImage(systemName: shape.0)) { [weak self] _ in
247248 self?.onPick?(.shape(kind))
......@@ -249,6 +250,11 @@ final class InkPicker: UIView {
249250 action.state = tool == .shape(kind) ? .on : .off
250251 return action
251252 }
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])]
252258 }
253259
254260 /// 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
104104 Author.ask(from: self) {}
105105 })
106106 more.append(Appearance.menu())
107 more.append(Editing.fontMenu())
107108 provide(actions + [UIMenu(options: .displayInline, children: more)])
108109 }
109110 ])
......@@ -535,10 +536,13 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
535536
536537 private func add(_ notebook: Notebook) {
537538 Notebooks.add(notebook)
539 openFirstSection(of: notebook)
540 }
541
542 private func openFirstSection(of notebook: Notebook) {
538543 reload()
539544 notebook.open { [weak self] in
540545 self?.reload()
541 // A notebook just added opens at its first section.
542546 if let tab = notebook.tabs.first(where: \.readable) { self?.onOpen?(tab, notebook) }
543547 }
544548 }
......@@ -549,14 +553,21 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
549553 guard let author = Author.name else {
550554 return Author.ask(from: self) { [weak self] in self?.newNotebook(inCloud: inCloud) }
551555 }
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)
553558 let create = UIAlertAction(title: "Create", style: .default) { [weak self, weak alert] _ in
554559 let name = alert?.textFields?.first?.text?.trimmingCharacters(in: .whitespaces) ?? ""
555560 self?.create(name, by: author, inCloud: inCloud)
556561 }
557 create.isEnabled = false
558562 alert.addTextField { field in
559563 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
560571 field.autocapitalizationType = .words
561572 NotificationCenter.default.addObserver(
562573 forName: UITextField.textDidChangeNotification, object: field, queue: .main
......@@ -634,9 +645,25 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
634645 }
635646
636647 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) {
638654 let accessing = url.startAccessingSecurityScopedResource()
639655 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 }
640667 guard let bookmark = try? url.bookmarkData() else { return }
641668 add(Notebook(name: url.deletingPathExtension().lastPathComponent, source: .files(bookmark: bookmark)))
642669 }
apps/ios/Snowbound/Notebooks.swift+39-6
......@@ -277,7 +277,8 @@ final class Notebook {
277277 }
278278
279279 /// 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.
281282 private func download() {
282283 let folder: URL? =
283284 switch source {
......@@ -288,13 +289,17 @@ final class Notebook {
288289 case .server: nil
289290 }
290291 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])
292294 else { return }
293295 var waiting = false
294 for case let stub as URL in files where stub.lastPathComponent.hasPrefix(".") && stub.pathExtension == "icloud" {
295 let name = String(stub.deletingPathExtension().lastPathComponent.dropFirst())
296 let file = stub.deletingLastPathComponent().appendingPathComponent(name)
297 try? FileManager.default.startDownloadingUbiquitousItem(at: file)
296 for case let file as URL in files {
297 let stub = file.lastPathComponent.hasPrefix(".") && file.pathExtension == "icloud"
298 let status = try? file.resourceValues(forKeys: [.ubiquitousItemDownloadingStatusKey])
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)
298303 waiting = true
299304 }
300305 guard waiting else { return }
......@@ -485,6 +490,13 @@ enum Notebooks {
485490 return added
486491 }
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
488500 /// The notebook folders and section files in `folder`, by name, as Files orders them.
489501 private static func notebooks(in folder: URL) -> [String] {
490502 let listed = try? FileManager.default.contentsOfDirectory(
......@@ -648,6 +660,27 @@ final class Section {
648660 }
649661 }
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
651684 /// Stores every edit and publishes them within `seconds`; call off the main thread.
652685 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
169169 UIAction(title: "Page Background…", image: UIImage(systemName: "paintpalette"), attributes: editable) {
170170 [weak self] _ in self?.showPaper()
171171 },
172 themeMenu(),
172173 UIAction(title: "Tags Summary", image: UIImage(systemName: "tag")) { [weak self] _ in
173174 guard let self else { return }
174175 scene?.showTags(of: section.notebook, from: self)
175176 },
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 ]),
176185 UIMenu(options: .displayInline, children: [
177186 UIAction(title: "New Subpage", image: UIImage(systemName: "text.badge.plus")) { _ in
178187 scene?.newPage(subpage: true)
......@@ -186,6 +195,69 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
186195 ]
187196 }
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
189261 private func showPaper() {
190262 let navigation = UINavigationController(rootViewController: PaperViewController(canvas: canvas))
191263 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
4848brings back the original identities, so internal links to those paragraphs
4949survive 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
5162Input-method composition (marked text) stays inside the editor until it
5263commits, and only then becomes ops. When another client changes the open page,
5364the 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.
6980 New Roman or Courier New, bundled metric-compatible substitutes (Carlito,
7081 Arimo, Tinos, Cousine) stand in, under the stored font name.
7182- **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. Tags
83 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
7385 sit in a column to the left of the text with OneNote's spacing, and a tag's
7486 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
106118grid point, outside any outline. It selects, drags on the grid, deletes and
107119moves 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
109127An outline holding only pictures or files takes a paragraph after them where a
110128click beside them lands, as OneNote 2010 adds one when typing there. It is
111129stored with the first edit that reaches it, and undoing back to it empty takes it
......@@ -114,16 +132,20 @@ out again (`corpus/object-outline`).
114132A file printout's pages are pictures whose stored data is the printout's XPS
115133package. The page draws the PNG OneNote rendered of each
116134(WebPictureContainer14), framed in grey as OneNote frames them, and moves them as
117pictures. Snowbound does not write XPS, so a printout page deleted and brought
118back by undo returns as that picture (`corpus/printout`).
135pictures. A printout page deleted and brought back by undo returns as the page it
136was: its XPS package, the PNG and the properties tying it to the printout, written
137back as read (`corpus/printout`).
119138
120139Pictures go in as OneNote 2010 pastes and inserts them. At a caret in text the
121140paragraph 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. A
123picture takes the size its resolution gives it, or 96 dpi without one. Paste
124takes files first (a picture file as its picture), then text, then a picture:
125Finder offers a copied file's name and icon beside it, and other apps a picture
126of copied text.
141lies on the page at the caret, and the caret moves to the grid row below it.
142From the title it joins the outline where the body starts, or lies two grid rows
143below the page's content when none starts there. A picture takes the size its
144resolution gives it, or 96 dpi without one. Paste takes files first (a picture
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
128150## Recordings
129151
......@@ -176,6 +198,14 @@ tablet pressure on Linux.
176198
177199## 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
179209Note tags on a table sit in the tag column centred on it, and a click checks its
180210box (`corpus/table-tags`). A selection crossing a table's edge deletes as
181211OneNote 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
185215Shift+Enter a caret inside a link, as OneNote's do; Alt+= across paragraphs
186216makes each paragraph's part an equation, and a deletion between two equations
187217joins 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
189241## 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
240292section and numbers the sheets. `draw::pdf` writes the same primitives the screen paints
241293as PDF: text in subset fonts whose ToUnicode maps come from the laid-out text, so it
242294selects 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 print
244portal or the shell's print verb for PDFs; Export as PDF saves it.
295icons as images. On the desktop, Print and Export as PDF first show OneNote's Print Preview
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
246301## Search, dates, conflicts
247302
248303Smaller modules follow the same pattern of reproducing OneNote's behaviour
249304precisely. `search` matches the way OneNote 2010 searches: word prefixes,
250ignoring case and diacritics, title matches first. `date` edits the title's
305ignoring 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
251307date and time fields the way OneNote stores a changed page date. `conflict`
252308shows 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
285285one that cannot be read, is kept beside the file as `Name (Device).one` instead.
286286
287287On 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 is
289asked for and read later; the replica serves it meanwhile. Each publication is an upload and a
288iCloud daemon never swaps a file between the stamp check and the append. A file iCloud evicted
289(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
290295chance to conflict, so edits wait for a 3 second pause in typing (at most 30 seconds,
291296`Section::set_pause`), and publish at once when the app leaves the foreground, the screen locks,
292297the 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
305310renaming a file also rewrites two header fields OneNote checks on open (the
306311parent TOC's identity and a CRC of the file's name). Without them OneNote
307312treats 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
310318What only Snowbound reads lives in the notebook's `.snowbound` folder, which carries the
311319Windows 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
1313section's colour, and frames the page. That colour shades gently down the
1414window and becomes the accent for the whole interface, easing to the next
1515section'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,
1718drawn with soft shadows, concentric corner radii and a dark appearance. Call
1819it half-skeuomorphic: the shapes that made OneNote's notebook metaphor
1920legible, without the 2010 chrome.
......@@ -81,7 +82,9 @@ frame N
8182 *folds* its groups, boxes with a full and a folded form (`Spec::fold`), by
8283 priority. Only then do boxes give up room by their *strictness*, the least
8384 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.
8588- **The toolbar is one row that never overflows.** Both forms of every group
8689 are built each frame and the solver picks, so a group folds in the frame the
8790 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>iVBORw0KGgoAAAANSUhEUgAAAoAAAACgCAIAAAATnEprAAAlRUlEQVR4nO3deVhTx/4/8BMIqyCI
26FBcUl6qIUkFRKyggWFFcwFb0apVK3RW9aN24vS7V1lZLq9Za9bogxV0piEUQtSLiVqkoi4i2oggi
27sqnsa/J7fk+ehyffM5OQhISThPfrL53MmXMyjvmcM2cWnlAoZAAAAKB16bTy+QAAAAABGAAAgBt4
28AgYAAOAAAjAAAAAHEIABAAA4gAAMAADAAQRgAAAADiAAAwAAcAABGAAAgAMIwAAAABxAAAYAAOAA
29AjAAAAAHEIABAAA4gAAMAADAAQRgAAAADiAAAwAAcAABGAAAgAMIwAAAABxAAAYAAOAAAjAAAAAH
30EIABAAA4gAAMAADAAQRgAAAADiAAAwAAcAABGAAAgAMIwAAAABxAAAYAAOAAvxXO8fDhw7i4uHv3
317j1+/Pjly5cVFRV1dXWmpqbm5uZWVlZDhgwZOnTouHHjrK2tW+Fi1FxZWVlMTMz169fT0tKeP3/+
327t272tpaY2Pjzp0729raOjs7+/r6Dhw4kOvLBOBMWVnZpUuXEhMTHz58mJ2d/ebNm8rKSj09PVNT
33UxsbG1tbWxcXF29v7969e+MfCdSdUGUaGhqOHDkyaNAgWS5DV1d30qRJV65ckaXkqKgosoQ5c+ao
347rsIhcIhQ4aQJ+3atWtDQ4NSyn/06JG/v7+BgUGzdTVixIi4uDhZyhw3bhx5+O3bt1nZNm3axCiJ
35gYEBeRlff/217CXo6OgYGhq2b9++R48eTk5Ofn5+GzduTEhIqK+vV0o9S7qekJAQGQ9v164d61hd
36XV1uW1Sr1bDsJ+LxeHw+38jIyNLSsm/fvu7u7kuXLj137lxlZaVQUWlpabNnzzYyMpLlApydnSMi
37IgQCgVpVIIA4VQXgv/76i/r70ix/f/+SkhJ1C8CpqamSLjg6OrqFhdfU1KxZs4bPl683Ytq0aW/f
38vtW+ACxJ9+7dDx8+3MKqVp8ArNwW1Wo13PITWVlZ/fLLLzLGxSaVlZWBgYE8Hk/e040YMSItLU19
39KhBAnEreAe/du/fDDz9MSUlR4NijR4+6uLjk5+cz6uTw4cOSPjp48GBLSs7Pz3d1dQ0JCWloaJDr
40wLNnzw4fPvzFixdM25Cbmztv3ryZM2fKW1HqSXUtSs1ruLCwMDAwcMaMGbW1tbJfmIuLyy+//PL/
41nxjkdOfOnQ8//DA8PJxRPS1rotAKlB+A161bFxgY2NjYqHAJjx8/dnd3LykpYdRDXV3d8ePHJX0a
42Fxf38uVLxUrOy8tzc3NLTk5W7PAnT554eHiUlpYybcapU6cCAwMZDae6FqUpNXzmzJm5c+fKkjMv
43L8/d3V1Kh0Gzqqur58yZs2vXLqZVaEcTBY0MwDt37vz+++9bXs4///yzcOFCRj1ER0dLuRtobGw8
44cuSIAsVWVlZOmjTp6dOnLbm27OzsTz/9lGlLDhw4EB8fz2gyFbUozarhEydOnDx5Unqeurq6qVOn
45Pnv2rOWnW7lyZbOnUxYtaKLQOpQZgBMSElavXk39iMfjTZgwYf/+/RkZGUVFRXV1dYWFhVeuXPn3
46v/9tampKPSQyMvLMmTOMGggNDZWeQfTiR95ig4KCJN3Xjx49+uDBg48fPy4vL6+srMzKytq3b5+9
47vT01c3x8vJpUVKvZsGEDo8lU1KI0roaDg4Ol99YGBQXdvXuX+pG5ufnixYsvXrz47Nmz6urqkpKS
48jIyMvXv3enh4SCpt7ty5WVlZTKvQ9CYKrUSoJDU1Nf369aOeYvjw4cnJyZIOzMvLGzNmDPVABwcH
49zgdh5eXl6ej8n9sUR0fHnj17ss4eHx8vV7HXr1+nfuUOHTpERkZSD2loaPj666+p41D69etHHdXS
50kkFYO3fuFCqJjIOeBAJBY2NjXV1dVVVVUVFRfHz8Z599Rq0lhmEyMzNVfT0qGoSlihbVajUsy4nE
51z1JSUpKenr579+4uXbpQzyJl4kNSUpKka5szZ05hYaGkA2NjY3v06EE90MXFhdsKBBCntCfgXbt2
52PXnyhEz39fVNTEwcOnSopAOtra1jY2PHjx9PfpSamnr16lWGU2FhYQKBQDzF19d3ypQprGyHDh2S
53q9j169eTiZaWlteuXfv444+ph+jq6q5fv/7HH38kP3ry5MnFixcZDcfj8XR0dPT09ERzV7y8vH79
549df9+/dTM0u6g1F/KmpR6lPD4mexsLCwt7dfvnx5ampqt27dyMy3b9+WVE5wcDA1/auvvgoLC3vv
55vfckHejt7X3z5s3+/fuTH926devcuXOMotpIE4VWo5wAXF9fv3v3bjJ96NChERERhoaG0g/X19c/
56duyYlZUV+VF0dDTDqbCwMFaKr68vGSOjo6OLiopkLDMlJYX6n3Pfvn3NTpteuXLl2LFjyXQpg3o0
572qJFiwYPHkymU+/2NIIqWpT61/B777335ZdfkumvXr2i5o+Li7t58yaZvmDBAllmzVlbW8fHx1Nf
58b23bto1RKu1roqBhATgiIoKcOKSvrx8eHi7j9NaOHTuuXbuWTOd2LENiYuI///wjntK9e/fBgweP
59GjWKdQNeV1f366+/ylgsNeeYMWP8/PxkOXzjxo1k4oULF7R18sOwYcPIxLKyMkYDqahFaUQNU7vB
60JP0+UIehWVtb//DDDzKezsbG5rvvviPT//zzz4yMDEaptKmJgkYGYDLxk08+sbOzk70Qf39/CwsL
61JyenGTNmbNiw4ejRo3fu3Ll16xajToNlfH19RUvhTJ48WeE+w5iYGDJx3rx5Mh4+atSoPn36fPDB
62B1OnTg0ODj58+HBSUlJWVpa8S3loCuqNRfv27RkNpKIWpRE1TG2f5Mtv0cQh6v+R5cuXy3VVCxYs
636NixI5nekl5orW+i0JqU8JNdX19/+fJlMl3eeURWVlbqM/eXYZjy8nLyxqLpXd3HH3/M+jF9/Phx
64UlKSq6ur9GJzcnKys7NZiXp6eqIfYhn9/fffTNsgEAhu3LhBpmviyuEqalGaUsPXrl0jE93d3cnE
65u3fvVldXsxJ1dXWljHii0tfXnz179k8//cRKv3z5MnUQhmK0qYmC5j0BP3jwoLy8nJVoZGSk6h8O
66VTt16lRVVZV4irm5uZubm+jPY8eONTExUWANo/v375OJ/fv3NzY2btn1aqdvv/2W+i5NE1uXilqU
67RtRwZmbm1q1bWYmDBw+mLlh7584dMnHw4MGShlJL4eXlRSampKSwxsG1hDY1UdC8APzw4UMy0dHR
68UdN7RMnewgkTJujp6Yn+bGBg4O3tzcoQERHx9u1b6cU+evSITJRxywrtJhAIGhoaKioqCgoKMjIy
69zp496+3tTZ1P2adPHycnJ0bTqKhFqWENi2bNVVdXFxUVJSUlffnll05OTqz+LR6PFxISQj2cGs8U
70W1ueOjyqoqLi+fPnCpSm9U0UWpuwxdatW0cWO3/+fKHKtMI8YGqYPH36tHieEydOkHn27NkjvWTq
71MnVr164VqkArb8bg6+urupXuxYWFhbWkWpR+PbLMA1Zdi2rNGlbiiTZs2CDp61AXBpClHqgsLCzI
720q5du8ZJBQKIU8ITMPXFrbm5OaNda+Xr6+uzJitPnDhRX19f3j5D6twSScuBAemTTz6ZM2eOxtWM
736lqUxtWwrq7u1q1bt2zZIilDYWEhmUgdTiULagAuKChgVEZDmyi0PiUEYOpoezMzMymHrF+/niez
74VltFvUlDQ8OxY8dYiZ6enqxhje3btyfXvUtNTf3rr7+kFE6OLmEYhnz5B1Q+Pj7kP436U2mL0qwa
751tPTCw8Pp84JblJZWSnvT4oU1NHI1FO05SYKmhqAa2pqKOX+38X2NEtsbCx5g0wdpUxdtUr6Iwt1
76nyhtncKrRBYWFnv27ImKipJxP/a206I0q4br6+tnzZrl7u4uaZFnST8pCo9SpH4d2TdDbCNNFDih
77hDBJLo2r6ZPQycEyPB6PnKYp+g0lbzVOnjwp5f7awMCATKyoqFD0YrWclZWVj49PeHj4ixcvAgMD
78NfTGTqUtShNr+Pr16yNHjqQunydpxrDCNUDO0ZD037AtN1HghBIGKlPfX2puAC4sLLxw4QIrcejQ
79odRZfZ07dx4xYgRrtZDy8vJTp05JWliD2iGGAEzVuXPniIiIkSNHMppM1S1KQ2u4oaEhKCjI0NCQ
80XDCA+gT57t07xU5E/S1S1qw/7WiiwBUl3KxR11jPy8tjNFN4eDjZISxllQxqn6GUNYy6du1KJipl
81x1PlUmA3JLkWGBJtNVNfX19SUnL37t0vv/yS7EopKChwd3eXMlpHiVqyGxK3LYrzGqbuGlRbW1te
82Xp6fn//gwYOzZ8/Onj1bV1eXPHbFihXkltjUjRaoI7OaJRQKi4uLyfROnTppXBMF7aOEAEzddSQl
83JYXRTNRFaKWMGluzZg2Z/86dO5LWm33//fdlnEvdFvD5fAsLi2HDhm3dujU5OZm8mWtsbNy0aVNg
84YCC3++Oqc4tSwxrm8Xj6+vomJiZdunRxcHDw8/M7evRoUlIS2f1TXV1N7o5AvaenrmDTrL///pva
85vUQ9RZttoqDBAdjBwYFMfPny5evXryUd8s0338g+R7Y13blzJzMzUylFSRo4Q10Z4J9//pGr0z4g
86IMDHx+fAgQPkHhiay87OLi4ujto3uHfv3tWrVzMaqBValKbUsLOzM3XljdOnT7NGJg4cOJDMdu/e
87PQVOSg3bRkZGvXv3VqA0rWyioNkBuF+/ftTbSeoODRo3WEZhx44do460HDRoEDlJurGxkbq6CFVt
88bW1UVNTvv/++aNEia2trJyenTZs2JScna8ENuL29PXXDY4ZhduzYgRbV8rG73Nawv78/j8djJZaX
89l2dlZYmnUNeQysjIUGAJdOprEUdHR2p/eNtsosAh5QzYo664pMRg1jqqqqpOnz6trNJKS0t/++03
90Mp3P51OrS/a9544dOyb+uJySkrJlyxZXV1fNHfgmbvHixR999BH1o4ULF2rWE3/rtCgNqmEjIyPq
91+13WHC03NzdyQRIFflLevHlDDcDUHbXbZhMFbQjA/v7+ZGJKSopm3Q9GREQoN4ZJ6jOkrpKTkJAg
92y89rVVXVt99+S6Z7e3srvFKBujl48CB1oNObN2+WLl3KaI5Wa1GaUsN1dXWy7HjWrl076mqU+/fv
93lyu8bdu2jTqlWK6dx7S7iYI2BGB3d/d+/fqR6YGBgdSVFyWh/m9pNUp/ZCd3XxcZN26cra0tmb50
946VLp7wtFr8nJ3QwZhpk9ezajLXr27Ll582bqR9HR0ZGRkYyGaLUWpSk1fOnSJepCNOSMLOqcq7dv
953y5cuFDGVy13796l9hU7Ojoqtq+DVjZR4JbS5oxT15YrLCz09PSUMhqrSV1d3apVqzjstc7Ozr5+
96/Tor0dDQ8N27d7JMTREIBOTW4kKhkDp7REdHh7r4e2Fh4ejRo+Pj46lXWFlZGRAQEBYWRn40YMAA
976twVzbVixQpJm8msWLFCdatSaGiL0ogazs3NXbFiBZlubm7et29fVqKvry+ZyDDMhQsXFi5c2Oxm
98gmlpaZMmTaIGe+rmMW2ziYL2BGB/f3/qcOiMjIwRI0bExsZKOrCxsfHo0aMDBw7csWMHh8OIQkND
99ybNPnjyZum4GicfjUXcLDwsLoy4zOW3atEmTJpHpRUVF48ePnzBhwvHjx589e1ZdXV1RUZGamrpt
1002zZbW9vw8HDq2bds2aJl6+/o6uqGhoY2bdUnLjc3V+l712hBi1LDGm5sbBTtSCgapjBkyBByyq+o
101TsghUXw+n9w/WOTQoUOTJk2Ssp9geHj46NGjqX1vQ4YMmT59OqMMWtBEgXtC5bl3756UBd7c3Nx+
102/vnn9PT04uLimpqaZ8+eXb58OSgoqHv37vKuCCH7gGHpevToISqwsbGRehnnzp2T/etnZ2eTIzwZ
103homMjKTmLywsJB9xFODn56cm2xFK2heS+mMky8IXGzdupJ5CX18/KytLqCiFr0eEfPlHbkfYyi2q
1041WpY6XGFx+OJBvBTSXlZa2RkFBAQEBMT8/z585qamtLS0vT09J07d0rZhVdPT+/BgwfUE6lbE4U2
105QpkBWCgUHjhwgFG2EydOqDoAx8XFkZ926NChtrZWrq/v5uZGluPt7S0pf2Zmpiwr8kjRp0+ft2/f
106amsArqur++CDD6hn8fLyEqpxAG7lFtVqNaz0ALxo0SIpl1daWkrtiFbMwYMHJZ1I3ZootBFK7rdc
107sGDBjh07lFWaoaHh/v37Z86cyagY9d3ztGnTqHMhpAgICCAT4+Pjc3Nzqfnt7OwSExMV/omxt7dP
108TEzUmsHPJD09vdDQUOqUzUuXLqnzUBeuWpRm1XCfPn22b98uJUOHDh3i4uJsbGxafq4tW7bMnz+f
109UTbNbaKgDpT/4nDlypXh4eEt32Hezc0tJSVl0aJFjIqVlpaeP3+eTJ81a5a8RU2bNo18PBIIBFIG
110l9na2iYnJyuwffenn356/fp16srS2mTo0KGrVq2ifvTFF19QN1fmHLctSlNq2MHB4caNG83ePr7/
111/vtJSUlSOpabpaen98svv2zYsIFRDU1soqAmVDJyx9/fPzU1VdJc9WY5OzvHxMQkJiba2dkxqkdd
112sqp79+6urq7yFmViYjJ16lQyPTQ0VMq4TTMzs7CwsJs3b3p7ezc7lorH440ZM+aPP/44fvx4hw4d
113mDZg8+bN1FlbOTk51CnRnOO8Ral5DRsZGQUFBSUlJcn4/sXGxubmzZvBwcHUEU/SOTg43Lx5U9Vz
114czWuiYKaUNXQ2V69el2+fPnGjRs+Pj4ydrtZWVktWbIkOTn51q1bEydOZDhdK3/mzJnU8S/N+vzz
115z8nEFy9eSJpc1MTFxSU2NjYnJ+fnn3+ePn26vb29ubk5n883NDTs3Lmzvb39rFmzdu/e/fTp0ytX
116rnh6ejJthqGhYWhoKPXWJCQkhDqwlltq0qI4rGEdHR0+n29kZNS+fXtLS8tu3brZ2tqOHDly7ty5
117oaGheXl5u3btkquTzMDA4Lvvvnv06NGSJUtkPHDw4MFHjx69d+/esGHDGBXTuCYKaoLXCjN/ysrK
118Lly48Oeff6alpWVnZ797966iooLP55uamnbr1q1v375OTk5ubm7Dhg1TeIFWAGgjampqLl26lJiY
119+ODBg2fPnhUVFVVXV+vq6pqYmIgivbOzs7e3N3WXNoA2F4ABAACARatWbwAAANAUCMAAAAAcQAAG
120AADgAAIwAAAABxCAAQAAOIAADAAAwAEEYAAAAA4gAAMAAHAAARgAAIADCMAAAAAcQAAGAADgAAIw
121AAAABxCAAQAAOIAADAAAwAEEYAAAAA4gAAMAAHAAARgAAIADCMAAAAAcQAAGAADgAAIwAAAABxCA
122AQAAOIAADAAAwAEEYAAAAA4gAAMAAHAAARgAAIADCMAAAAAcQAAGAADgAAIwAACAxgbg8ePH8wh3
1237txRSuFaY/Xq1WQt1dTUMG3YmjVrWBUSERHROgWamJjwWmbFihVyXZufn5/44d26dWvJNwUATYcn
124YODMrVu3duzYoc4FKlF2dva5c+e4vgoAUCMIwMCNqqqqgIAAgUCgtgUq17JlyxobG7m+CgBQIwjA
125wI3g4OC///5bnQtUol27dsXFxXF9FQCgXhCAgQMJCQl79uxR5wKV6MyZM6tWreL6KgBA7SAAQ2sr
126Ly+fO3euUChU2wJlYWZmNm/evGaz7dmzZ+bMmWrbMQ4AHEIAbj0//PCDkGBoaMi0MatWrXr+/Lk6
127F9gsPp8fFRX1wQcfSMlTUVHh7++/fPlyRF8AoEIAhlYVHx9/8OBBdSiwoqJCKJuTJ0+yjt26dauH
128h4eUwuPi4uzt7Y8dO6bAhQFAG8Fn1EZ5eXl4ePj58+dTU1NLS0vNzMysra09PT1nzJgxfPhwbq+t
129qqoqLi4uNjb2/v37L168KCsrMzExsbKy6tGjh5eX18SJE/v376+6s9++fTsuLi4hISE/P7+wsLCu
130rs7MzKxLly6Ojo6enp5Tp041MTGRvbS8vLyoqKiYmJjs7OxXr17xeLxOnTqJqnry5MlDhgxR3Rd5
131+/atLN22HBZIevbs2aJFi8RTvLy81qxZQ80sEAji4+NDQkISEhJUelUAoA2EyjBu3Diy5Nu3b7Oy
132sX7IGIb573//K/ooLCzMwsJC0kV+/PHHeXl5rNJWr15N5jx58mSzV2tnZ8c6qnPnzg0NDdTMlZWV
133W7ZskXJtIhMmTEhPT5d+XupInOrqaimHJCQkNHvz0a5duy1bttTU1DT7xcvKytasWaOnpyelNA8P
134j/v37wtVw9/fX/p3YRjm7NmzHBbIUl9fP2LECPHSTE1Nc3JyJOVPTk5mZGZtba3whQGAFuA+AAsE
135gqVLlzb7a9WrV6/c3Fzx0h49ekRmmzRpkvRLTUlJIY9au3YtNfPdu3f79Okj4++pjo7O1q1blRWA
136BQLBunXrZDw1wzDDhw8vKiqScvanT5++//77Mn6RPXv2CJWNXIbC1ta2JfFS6QWStm3bxipNes1I
137CsDTp0/38vJiJSIAA7Rx3AfgoKAgRjaenp6sAl1cXFh59PT0iouLpVzqF198QZb86NEjMufFixeN
138jY0ZOc2dO1fSw7RcAXj27NnyntrBwaGqqopaWlZWVufOneUqTfrNhLyKi4s7deokXr6+vv7NmzcV
139jpdKL5CUk5PDagAODg6NjY1yBWA+n79582aBQODr68v6CAEYoI3jOAAPGDCAkcfVq1fFCzx8+DCZ
140Z9++fZKus7GxsWvXrqz8zs7OZM709HS5XqyKW7duXQsD8E8//aTYqRcvXkyWVlNTM2jQIHmL4vF4
141CQkJQiWZPn06q/xvvvmmvLxc4Xip9AJJU6ZMYRXVbIWwArC9vX1ycrLoIwRgAFCvANzExsbm0KFD
142L1++rK6uTktLk/RujxVgysvLyTDp6uoq6TqvXLlClnngwAFWtvr6ent7ezKngYHBunXr0tPTq6qq
143CgsLIyMjWS8Im0RFRSkcgB8/fmxgYEDmdHd3j4uLKy0trampSUtLCwwM1NFhD2LX1dXNzMxkFUgd
144MdS9e/fQ0NBXr17V1NRkZGSsWLFCV1eXladXr16VlZXCFjt9+jSr5GHDhtXX1yscL5VeoCxNpdm3
145G+IB2MbG5uDBg/X19U0fIQADgDoGYEdHx5KSElbmJUuWkDnt7e1Z2ebOncvKw+PxJA2TCQgIYGU2
146NjZ+9+4dK9vPP/9MntrS0vLBgwesnI2NjcuWLSMz29jY1NbWKhaAFyxYQGZbtGiRQCBg5dy+fTuZ
147c968eeJ5SktLyY50Ozu7169fs0r77bffeDweK2dYWJiwZQoKCjp27ChepoGBgeguQbF4qfQCqZyd
148nVnl3Lt3r9mjUlJSxo4de+rUKfJfHwEYANQuABsaGubn55NllpSUGBkZsTK3a9eOlY362m/btm1k
149gdXV1e3bt2fl9Pf3Z2VraGjo2bMnKxuPx0tKSqJ+d4FA4O3tTV7D//73PwUCMPVb9+/fv66ujnpq
150sm/ZzMxMvMzvv/+ePKmkWELe9Li5uQlbhgw8ISEhoo8Ui5dKL5BErtvs4+Oj0LeXdtl4BwzQxnG/
151EMfnn3/epUsXMt3CwmLo0KGsxMrKStYGui4uLuQc3OPHj5MF/v7772VlZeTZWSlXrlwhl1WaNWvW
152qFGjqNfP4/H27NlD9gbv37+fkd/ly5erq6tZicuXL6fOHeLxeH5+fj179hw3blxQUNDevXuvXr2a
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
18KKRNURRZytZqRr9zzl2+79779Ty+9w943+ccZ7fPLvGv+N1js9jk8r7HfDFPzCRvktfIDOGEA64/
19rYD8oUCFVyhPGuAHtqRYUJq0IHFAOFBh7C4Hu/4B//4NfgO/bwGbN4JNm34FC8D7pWD+XDBv3hww
208w3w5uvgtVfBjBmvAAf+CvrL6f+HfgXo30L/RurdAP2l9M+lfs4c+t+g/3X6X6U+JiB/egH54wJ/
21xQ74bUNkAR9a8F5kQeKAaOAvUr5T7CB20Aay5WexTkBPSt8Rb4uXyUvkBfK8cCqkNxL9O3ZIbyQH
221klvxATkDwVC/p3btcAPrE8RmJ0U0IAX4wJ/+mDBdoLAeg8E1q5da/f8BGZ7IDBr1iy75y0DeqIB
23U6dOtXueM5wgsM1QYKvhB0wOaE8MmBzAbkyfPl2BadOmmd2A3Qv8QRBAAXoGfiHRwLskEkDB9OGA
245H5gm/Rbt8oeDcgeDtgAg/qUAQ7YliJQVoZ7lhjJgZIS/FNcHAkUFeGfwsJC50efH8T34guPMgE5
25eNajRBSLSWQiKSImB5GA608rIH8osMZbsDppgB9YmmLBlKQFiQPCgTXGj6vBD6vA99+Bz8EXS0HZ
26YrBkyWfgGfDsFFDyNCgungwmTQATx4OicaCw8EHgwL+G/tX0r6JfAfqX0r+YejdA/xT6n6Z+8mT6
27J9A/nv5x1McE5E8vIH/KQGRAzAI+9Ex0QeKAUOBb8g34mnxFPiWfgI/FIvIUeRI8IR4nD5D7yX1i
28NHECfzRAvxuQXwH63YD8MQH5owEN0II9DWiAuyAxsHJl8NCKFb5/4cLIAg547LHIAj00dmx8YCXh
29QysEBywkuOhDY9GiDwD9ABc9YjwsqCcYMMoYPfpe4HwrvbHckH+ZIb0Bu0G9Ib0BexAoKCiQf8yY
30MdCPGkW9BaD+kiCAAvQMfESigUeJH3gIWAAF04cDkvuB5dIvWyZ7NCB7EKBegQLqUwY4YHmKQH4+
317ulnJAdycxP8iYGcHPyTnZ3tjPQZJoaK2zzyBeSgtUeuaCGakpNIDjE5iARcf1oB+UOBEd6C4UkD
32/ED/FAtaJi1IHBAOjDBGDgfDhoCh94BbwW39Qf4toF+/m0Er0LolyD0btGjRHDRtAk46EeScALKz
33jwcO/CPoH07/EPoVoL8//bdQ7wbob0n/2dQ3b05/E/pPpP8E6mMC8qcXkD9lIDIgZgEfahVdkDgg
34FLib3AXuJHeQm8iN4AZxPTmLnAnOEKeT48ix5BjRkDiBPxqg3w3IrwD9bkD+mID80YAGaMGeBjTA
35XZAYGDw4eGjQIN/ft29kAQc0axZZoIcaN44PDCZ8aJDggL4EF11rXCPoB7joVOMUQT3BgAZGw4ZH
36A+du6Y2BhvwDDOkN2IPANdIbsAeBrKws+Rs1agR9gwbUWwDq2wkCKEDPwHXED/Qhpj6N+IGTgQVQ
37MH04ILkfGCj9gAGyBwHp+/SRPQhQr0AW9SkDHDAwRaBHj/hA3boJ/sRAjRr4p3r16s7VPleIy8Vl
38Hj3EJS5HedQVR4oDyQGkBjE5iARcf1oB+VMF3AG9Qwt6plhQP2mBBuwfF7jKB4XeBI2eHgh0797d
39HroY1PdAoE6dOnbPEQb0RAOqVatm9+xnOEGgl6FAnuEHTA5oTwyYHMBuZGZmKpCRkWF2A3YvcCVB
40AAXoGbiURAP1SCSAgunDAcn9QC/p8/JkjwZkDwdsgEF9ONDN83flgkigffuki2p7/lq1Ei/6v0A3
4148quoFcXkHcRuABc2h6cL84jtUG9WuBwcRjJ3Bdk7AP2FnsRB/5u9Helvwv9CtDvBuRXgH43IP9h
429O9LvxuQPxqQP72A/DGBzp2DAZ06+f527SILOKBmzcgCDahaNT7QmfChToID2hFcdI7RVtAPcNEh
43xsGCeoIBlQx7h3ST3uhoyN/BkN6APQi0ld6APQhUqVJF/sqVK0NfqZL0jgP1hQQBFKBn4FziB9oQ
44Ux9K/MBBwAIomD4ckNwPdJS+QwfZg4D0bdrIHgSoV6AK9SkDHNAxzQD9sYH/AHAD8Cry1R2MAAAA
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.
1111`candidate/` is the output of `printouts_show_onenotes_rendering_and_move_as_pictures`
1212in `crates/canvas/tests/printouts.rs`: one printout page moved down, the other deleted
1313and 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 OneNote
15showed of it. `cold/` is a fresh OneNote 2010 read: the moved page is still a printout,
16the restored one a picture. `tools/test_printout.py` checks this without a VM.
14The undone page is written back as it was read: its XPS package as the PictureContainer,
15under the file-data type OneNote gives XPS packages (0x8003A), the PNG beside it as the
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.
1721Regenerate with `SNOWBOUND_PRINTOUT_EXPORT` set to a new absolute directory while
1822running the test, then cold-open it with
1923`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 @@
11{"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}
33{"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{"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 @@
11{
22 "powershell": "5.1.14409.1005",
33 "schema": "xs2010",
4 "hostname": "ONE-M6-B402FE00",
4 "hostname": "ONE-M6-02DA9A86",
55 "cold": true,
66 "onenote": "14.0.4763.1000"
77}
corpus/printout/cold/read/hierarchy.xml+1-1
......@@ -1,2 +1,2 @@
11<?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 @@
66 "h": 600,
77 "error": null,
88 "win": {
9 "title": "Pasted shape - Microsoft OneNote",
9 "title": "Printout - Microsoft OneNote",
1010 "class": "Framework::CFrame",
1111 "dialog": false
1212 }
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 @@
11<?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>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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>
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
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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
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" 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>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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/page-001.navigation.json+1-1
......@@ -6,7 +6,7 @@
66 "h": 600,
77 "error": null,
88 "win": {
9 "title": "Printout - Microsoft OneNote",
9 "title": "Pasted shape - Microsoft OneNote",
1010 "class": "Framework::CFrame",
1111 "dialog": false
1212 }
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 @@
11<?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
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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
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>
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>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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>
corpus/printout/cold/read/payloads.json+2-2
......@@ -2,9 +2,9 @@
22 {
33 "sha256": "e15f2722a8d11f9e68ac5eff54320f7c4ef5540fb6eb858223e31b983fa1bed7",
44 "name": "two-pages.docx",
5 "object": "{DB7C4F71-B073-0121-2D07-84DA732F3A82}{23}{B0}",
5 "object": "{5B8B92BB-9FEB-06D8-085F-BB1AEF8C3FD6}{23}{B0}",
66 "kind": "InsertedFile",
7 "page": "{DC91B969-8F8E-0E38-22C6-17772EFD8803}{1}{B0}",
7 "page": "{5C6664A3-A016-09C1-079E-28B7B25E8D57}{1}{B0}",
88 "bytes": 12589
99 }
1010]
\ No newline at end of file
corpus/printout/cold/run.json+1-1
......@@ -1,5 +1,5 @@
11{
2 "notebook": "/private/tmp/unsup-lab/printout-candidate",
2 "notebook": "/private/tmp/printlab/candidate",
33 "expected_pages": 2,
44 "author": null,
55 "author_timeout_seconds": 600,
corpus/printout/cold/source.json+5-5
......@@ -2,13 +2,13 @@
22 {
33 "path": "Open Notebook.onetoc2",
44 "bytes": 1612,
5 "sha256": "29bbfd869154aea1720f98cf7c7cc40387313b00c8dfdb61d159f968312367b4",
6 "mtime_ns": 1790744275764608328
5 "sha256": "fe8a117e418bb9aea7565bd92f01e03f87d763ba60bb5df24ab85b428dbd4f08",
6 "mtime_ns": 1790820802979688144
77 },
88 {
99 "path": "Printouts.one",
10 "bytes": 268208,
11 "sha256": "5b48da81b1b374da9af23b4dd664b01b100c34be71aef721907f4365afe5577e",
12 "mtime_ns": 1790744275764329165
10 "bytes": 443488,
11 "sha256": "2be9306af1295605fd3deab3dfd16de2bfdeeecd6b696b48b4a6104b8d997c06",
12 "mtime_ns": 1790820802979090020
1313 }
1414]
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>> {
3333 .file_stem()
3434 .and_then(|stem| stem.to_str())
3535 .unwrap_or_default();
36 let paper = if a4 { print::A4 } else { print::LETTER };
37 let pdf = print::pdf(pages, &mut TextEngine::default(), paper, name)?;
36 let setup = print::Setup {
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 )?;
3846 fs::write(destination, pdf)?;
3947 Ok(())
4048}
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
468468 _ => {}
469469 }
470470 }
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 }
471490 let mut editor = match CanvasEditor::from_page(page.clone(), engine) {
472491 Ok(editor) => editor,
473492 Err(error) => {
crates/canvas/src/editor.rs+396-47
......@@ -36,7 +36,8 @@ mod recording;
3636mod space;
3737mod table;
3838pub 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,
4041};
4142pub use ink::{FAVORITES, Pen};
4243pub use link::{Link, shown_urls};
......@@ -81,6 +82,47 @@ pub struct Selection {
8182 pub affinities: [Affinity; 2],
8283}
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
84126/// What OneNote 2010's Ctrl+A selects beyond text: the focused outline as an object, then every
85127/// body outline on the page.
86128#[derive(Clone, Copy, Debug, PartialEq, Eq)]
......@@ -102,7 +144,7 @@ pub struct CanvasEditor {
102144 pub(crate) objects: Vec<page::Content>,
103145 date: Option<PageDate>,
104146 outlines: Vec<TextOutline>,
105 definitions: BTreeMap<ExGuid, Definition>,
147 pub(crate) definitions: BTreeMap<ExGuid, Definition>,
106148 header: PageHeader,
107149 active: Focus,
108150 undo: Vec<History>,
......@@ -130,6 +172,12 @@ pub struct CanvasEditor {
130172 /// and whether it is stored: the first op that reaches it stores it, and undoing back to it
131173 /// empty and alone removes it again, as OneNote 2010's undo does.
132174 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>,
133181}
134182
135183/// Imported page state the editable content does not carry.
......@@ -847,6 +895,11 @@ enum History {
847895 index: usize,
848896 ink: Option<Box<onestore::page::Ink>>,
849897 },
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 },
850903 /// Gives a drawing back the strokes it had.
851904 Strokes {
852905 ink: ExGuid,
......@@ -912,6 +965,8 @@ impl CanvasEditor {
912965 recording: None,
913966 ink_drag: None,
914967 provisional: BTreeMap::new(),
968 default_font: DefaultFont::default(),
969 styles: BTreeMap::new(),
915970 })
916971 }
917972
......@@ -1110,6 +1165,7 @@ impl CanvasEditor {
11101165 History::Date(_) => date_changed,
11111166 History::Paper { .. } => false,
11121167 History::Image { .. }
1168 | History::Tags { .. }
11131169 | History::Picture { .. }
11141170 | History::File { .. }
11151171 | History::Ink { .. }
......@@ -1153,6 +1209,8 @@ impl CanvasEditor {
11531209 fresh.preferred_x = self.preferred_x;
11541210 fresh.ops = std::mem::replace(&mut self.ops, Ok(Vec::new()));
11551211 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);
11561214 *self = fresh;
11571215 Ok(true)
11581216 }
......@@ -1230,6 +1288,12 @@ impl CanvasEditor {
12301288 // OneNote stores it, holds only those its paragraphs reference.
12311289 let mut referenced = BTreeSet::new();
12321290 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));
12331297 let outlines = match object {
12341298 PageObject::Outline(outline) => std::slice::from_ref(outline),
12351299 PageObject::Title(title) => title.outlines.as_slice(),
......@@ -1327,6 +1391,11 @@ impl CanvasEditor {
13271391 self.header.color
13281392 }
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
13301399 /// The page's rule lines (View, Rule Lines).
13311400 pub fn rule_lines(&self) -> Option<onestore::page::RuleLines> {
13321401 self.header.rule_lines
......@@ -1408,16 +1477,8 @@ impl CanvasEditor {
14081477 page::Content::Image(image) if !image.background => {
14091478 let x = image.layout.x.unwrap_or(0.0);
14101479 let y = image.layout.y.unwrap_or(0.0);
1411 Some((
1412 image.id,
1413 &image.layout,
1414 [
1415 x,
1416 y,
1417 x + image.layout.max_width?,
1418 y + image.layout.max_height?,
1419 ],
1420 ))
1480 let [width, height] = crate::outline::image_size(image)?;
1481 Some((image.id, &image.layout, [x, y, x + width, y + height]))
14211482 }
14221483 page::Content::ReadOnly(object)
14231484 if !matches!(object.source, onestore::page::PageObject::Title(_)) =>
......@@ -1508,6 +1569,8 @@ impl CanvasEditor {
15081569 recording: None,
15091570 ink_drag: None,
15101571 provisional: BTreeMap::new(),
1572 default_font: DefaultFont::default(),
1573 styles: BTreeMap::new(),
15111574 })
15121575 }
15131576
......@@ -1709,7 +1772,7 @@ impl CanvasEditor {
17091772 position: [f32; 2],
17101773 width: f32,
17111774 ) -> Result<(), EditorError> {
1712 let document = TextDocument::new(vec![Paragraph::new(String::new(), Default::default())])?;
1775 let document = TextDocument::from_nodes(vec![self.body_paragraph()?])?;
17131776 let outline = TextOutline::new(engine, document, width, position)?;
17141777 self.finish_composition();
17151778 self.active = Focus::Caret {
......@@ -1736,6 +1799,34 @@ impl CanvasEditor {
17361799 })
17371800 }
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
17391830 /// An empty paragraph in `base`'s style, whose runs keep `base`'s language as OneNote's do.
17401831 fn blank_paragraph(&self, base: &PageParagraph) -> Result<PageParagraph, EditError> {
17411832 let mut format = self.style_format(base.style)?;
......@@ -1844,10 +1935,7 @@ impl CanvasEditor {
18441935 position: [f32; 2],
18451936 width: f32,
18461937 ) -> Result<onestore::ExGuid, EditorError> {
1847 let document = TextDocument::new(vec![Paragraph::new(
1848 String::new(),
1849 onestore::document::Format::default(),
1850 )])?;
1938 let document = TextDocument::from_nodes(vec![self.body_paragraph()?])?;
18511939 let outline = TextOutline::new(engine, document, width, position)?;
18521940 let id = outline.id;
18531941 self.finish_composition();
......@@ -1935,7 +2023,7 @@ impl CanvasEditor {
19352023 let layout = &image.layout;
19362024 return Some((
19372025 [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)?,
19392027 ));
19402028 }
19412029 let (outline, ..) = self.outline_picture(id)?;
......@@ -1963,6 +2051,18 @@ impl CanvasEditor {
19632051 })
19642052 }
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
19662066 /// The file a paragraph holds or the page shows, which the host opens and saves.
19672067 pub fn attachment(&self, id: ExGuid) -> Option<&onestore::page::Attachment> {
19682068 if let Some((file, _)) = self.page_file(id) {
......@@ -2024,6 +2124,36 @@ impl CanvasEditor {
20242124 })
20252125 }
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
20272157 fn image_mut(&mut self, id: ExGuid) -> Option<&mut onestore::page::Image> {
20282158 self.objects.iter_mut().find_map(|object| match object {
20292159 page::Content::Image(image) if image.id == id && !image.background => Some(image),
......@@ -2079,7 +2209,7 @@ impl CanvasEditor {
20792209 let image = self.image(id).ok_or(EditError::InvalidRange)?;
20802210 let mut layout = image.layout.clone();
20812211 [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) {
20832213 [layout.max_width, layout.max_height] = size.map(Some);
20842214 layout.width_set_by_user = Some(true);
20852215 }
......@@ -2180,12 +2310,39 @@ impl CanvasEditor {
21802310
21812311 /// Puts `image` at the caret as OneNote 2010 pastes and inserts a picture: in an outline
21822312 /// 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).
21842317 pub fn insert_picture(
21852318 &mut self,
21862319 engine: &mut TextEngine,
21872320 mut image: onestore::page::Image,
21882321 ) -> 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 }
21892346 let Focus::Caret { outline, .. } = &self.active else {
21902347 return self.insert_in_flow(engine, ParagraphContent::Image(image));
21912348 };
......@@ -2198,9 +2355,123 @@ impl CanvasEditor {
21982355 self.add_picture(index, image);
21992356 self.undo.push(History::Picture { index, image: None });
22002357 self.redo.clear();
2201 let margin = self.margin_origin()[1];
2202 let row = ((y + height - margin) / 18.0).floor() + 1.0;
2203 self.place_caret(engine, [x, margin + row * 18.0], DEFAULT_OUTLINE_WIDTH)
2358 let row = ((y + height - margin[1]) / 18.0).floor() + 1.0;
2359 self.place_caret(engine, [x, margin[1] + 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)
22042475 }
22052476
22062477 /// Attaches `file` at the caret as OneNote 2010 does (`corpus/attachment-insert`): the
......@@ -2316,19 +2587,7 @@ impl CanvasEditor {
23162587 })
23172588 .ok_or(EditError::InvalidRange)?;
23182589 self.finish_composition();
2319 let mut 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 }
2590 let image = self.remove_picture(index);
23322591 self.undo.push(History::Picture {
23332592 index,
23342593 image: Some(Box::new(image)),
......@@ -3401,8 +3660,13 @@ impl CanvasEditor {
34013660 tail.tags = std::mem::take(&mut head.tags);
34023661 tail.text_mut().unwrap().tags = std::mem::take(&mut head.text_mut().unwrap().tags);
34033662 }
3404 let style = |id: Option<ExGuid>| match &self.definitions.get(&id?)?.kind {
3405 Kind::Style { name } => name.as_deref(),
3663 // Enter at a paragraph's end gives the new one its style's NextStyle, as OneNote 2010
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()),
34063670 _ => None,
34073671 };
34083672 if empty(head) && empty(tail) {
......@@ -3411,16 +3675,9 @@ impl CanvasEditor {
34113675 tail.lists.clear();
34123676 }
34133677 } else if empty(tail)
3414 && matches!(
3415 style(head.style),
3416 Some("h1" | "h2" | "h3" | "h4" | "h5" | "h6")
3417 )
3678 && let Some(following) = following
34183679 {
3419 tail.style = self
3420 .definitions
3421 .keys()
3422 .copied()
3423 .find(|id| style(Some(*id)) == Some("p"));
3680 tail.style = self.style_named(&following)?;
34243681 // The heading's run formatting carries over under the body style (`c9-h1-*`).
34253682 let run = format.over(&self.style_format(head.style)?);
34263683 tail.text_mut().unwrap().text =
......@@ -3730,6 +3987,26 @@ impl CanvasEditor {
37303987 !self.redo.is_empty()
37313988 }
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
37334010 pub fn undo(&mut self, engine: &mut TextEngine) -> Result<bool, EditorError> {
37344011 if self.composition.is_some() {
37354012 return self.cancel_composition(engine);
......@@ -3842,6 +4119,7 @@ impl CanvasEditor {
38424119 .iter()
38434120 .all(|id| self.outlines.iter().any(|item| item.id == **id)),
38444121 History::Image { image, .. } => self.image(*image).is_some(),
4122 History::Tags { object, .. } => self.object_tags_mut(*object).is_some(),
38454123 History::Picture {
38464124 index,
38474125 image: Some(_),
......@@ -4073,6 +4351,10 @@ impl CanvasEditor {
40734351 layout: previous,
40744352 }
40754353 }
4354 History::Tags { object, tags } => History::Tags {
4355 object,
4356 tags: self.swap_object_tags(object, tags),
4357 },
40764358 History::Image { image, layout } => {
40774359 let previous =
40784360 std::mem::replace(&mut self.image_mut(image).unwrap().layout, layout);
......@@ -4262,6 +4544,7 @@ impl CanvasEditor {
42624544 .replace(range.clone(), replacement)?;
42634545 self.own_lists(&mut edit)?;
42644546 self.link_notes(&mut edit)?;
4547 self.fit_columns(engine, &mut edit)?;
42654548 let inverse = self.apply(
42664549 engine,
42674550 edit,
......@@ -4361,6 +4644,7 @@ impl CanvasEditor {
43614644 ) -> Result<(), EditorError> {
43624645 self.own_lists(&mut edit)?;
43634646 self.link_notes(&mut edit)?;
4647 self.fit_columns(engine, &mut edit)?;
43644648 let inverse = self.apply(engine, edit, selection, true, None)?;
43654649 self.record_change(inverse);
43664650 Ok(())
......@@ -6152,6 +6436,9 @@ mod tests {
61526436 alt: None,
61536437 background,
61546438 printout: None,
6439 tags: Vec::new(),
6440 link: None,
6441 text: None,
61556442 });
61566443 let (id, background_id) = (picture.id, background.id);
61576444 let mut editor = CanvasEditor::from_page(
......@@ -6260,6 +6547,9 @@ mod tests {
62606547 alt: None,
62616548 background,
62626549 printout: None,
6550 tags: Vec::new(),
6551 link: None,
6552 text: None,
62636553 };
62646554 let [old, picture] = [image(true, b"old art"), image(false, b"picture")];
62656555 let mut editor = CanvasEditor::from_page(
......@@ -6349,6 +6639,9 @@ mod tests {
63496639 alt: None,
63506640 background: false,
63516641 printout: None,
6642 tags: Vec::new(),
6643 link: None,
6644 text: None,
63526645 };
63536646 let id = picture.id;
63546647 let objects = vec![
......@@ -6414,6 +6707,9 @@ mod tests {
64146707 alt: None,
64156708 background: false,
64166709 printout: None,
6710 tags: Vec::new(),
6711 link: None,
6712 text: None,
64176713 };
64186714 picture.content = ParagraphContent::Image(image.clone());
64196715 source.paragraphs.insert(1, picture);
......@@ -6562,6 +6858,7 @@ mod tests {
65626858 bytes: Some(std::sync::Arc::from(b"payload".as_slice())),
65636859 preview: None,
65646860 recording: None,
6861 tags: Vec::new(),
65656862 };
65666863 let at = |paragraph, offset| Selection {
65676864 positions: [TextPosition { paragraph, offset }; 2],
......@@ -6651,6 +6948,9 @@ mod tests {
66516948 alt: None,
66526949 background: false,
66536950 printout: None,
6951 tags: Vec::new(),
6952 link: None,
6953 text: None,
66546954 });
66556955 let (mixed_id, picture_id) = (mixed.id, picture_only.id);
66566956 let editor = CanvasEditor::from_page(
......@@ -6736,6 +7036,9 @@ mod tests {
67367036 alt: None,
67377037 background,
67387038 printout: None,
7039 tags: Vec::new(),
7040 link: None,
7041 text: None,
67397042 });
67407043 let image_ids = images.each_ref().map(|image| image.id);
67417044 let image_bytes = images
......@@ -6990,6 +7293,7 @@ mod tests {
69907293 Definition {
69917294 kind: onestore::document::Kind::Style {
69927295 name: Some("p".into()),
7296 next: None,
69937297 },
69947298 format: base.clone(),
69957299 },
......@@ -7057,6 +7361,7 @@ mod tests {
70577361 Definition {
70587362 kind: onestore::document::Kind::Style {
70597363 name: Some("p".into()),
7364 next: None,
70607365 },
70617366 format: base.clone(),
70627367 },
......@@ -7258,6 +7563,47 @@ mod tests {
72587563 assert_eq!(editor.outlines[0].document, source);
72597564 }
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
72617607 #[test]
72627608 fn deleting_through_provisional_paragraphs_removes_the_outline_and_restores_draft_on_undo() {
72637609 let mut engine = TextEngine::default();
......@@ -9314,6 +9660,9 @@ mod tests {
93149660 alt: None,
93159661 background: false,
93169662 printout: None,
9663 tags: Vec::new(),
9664 link: None,
9665 text: None,
93179666 });
93189667 if objects {
93199668 let selection = [TextPosition {
crates/canvas/src/editor/evidence.rs+2-1
......@@ -47,6 +47,7 @@ fn open(engine: &mut TextEngine, summary: &str) -> CanvasEditor {
4747 Definition {
4848 kind: Kind::Style {
4949 name: Some(name.into()),
50 next: (name == "h1").then(|| "p".into()),
5051 },
5152 format: calibri(if name == "p" { 11.0 } else { 16.0 }, name != "p"),
5253 },
......@@ -182,7 +183,7 @@ fn render(editor: &CanvasEditor) -> String {
182183 let style = node
183184 .style
184185 .map(|style| match &editor.definitions[&style].kind {
185 Kind::Style { name } => {
186 Kind::Style { name, .. } => {
186187 format!("qs{}", usize::from(name.as_deref() == Some("h1")))
187188 }
188189 kind => panic!("{kind:?}"),
crates/canvas/src/editor/format.rs+401-46
......@@ -43,6 +43,10 @@ pub enum Formatting {
4343 /// Format Painter: gives the selection the character formatting and alignment of text
4444 /// picked up with [`CanvasEditor::painted_format`], as OneNote's does.
4545 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),
4650}
4751
4852#[derive(Clone, Copy, Debug, PartialEq, Eq)]
......@@ -164,6 +168,57 @@ impl ListStyle {
164168 }
165169}
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
167222/// A tag of the user's list as the Customize Tags dialog edits it: what its definition
168223/// stores besides its place in the list, which the page stores as its action type.
169224#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
......@@ -293,6 +348,8 @@ pub struct FormatState {
293348 pub list: Option<ListStyle>,
294349 /// Tags every selected paragraph has, with their action types.
295350 pub tags: Vec<(NoteTag, u16)>,
351 /// The stored name of the paragraph style every selected paragraph shares.
352 pub style: Option<String>,
296353}
297354
298355impl Toggle {
......@@ -477,6 +534,35 @@ fn common<T: PartialEq>(mut values: impl Iterator<Item = Option<T>>) -> Option<T
477534 .then_some(first)
478535}
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
480566/// Seconds since 1980, as note tags date themselves.
481567fn time32() -> Option<u32> {
482568 let now = std::time::SystemTime::now()
......@@ -629,6 +715,12 @@ impl CanvasEditor {
629715 shared.sort_by_key(|(_, action_type)| *action_type);
630716 shared
631717 }),
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 })),
632724 })
633725 }
634726
......@@ -765,36 +857,8 @@ impl CanvasEditor {
765857 let ParagraphContent::Text(text) = &mut node.content else {
766858 unreachable!()
767859 };
768 // An element holds one tag of each action type.
769 for tags in [&mut node.tags, &mut text.tags] {
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 }
860 let added = (!remove).then_some((id, tag.shape, created));
861 self.retag([&mut node.tags, &mut text.tags], &definition.kind, added);
798862 });
799863 }
800864 Formatting::RemoveTags => leaves_mut(&mut replacement, &mut |node| {
......@@ -804,6 +868,39 @@ impl CanvasEditor {
804868 }
805869 }),
806870 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 }
807904 Formatting::Toggle(_)
808905 | Formatting::Font(_)
809906 | Formatting::FontSize(_)
......@@ -906,6 +1003,43 @@ impl CanvasEditor {
9061003 }
9071004}
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
9091043impl CanvasEditor {
9101044 /// A toolbar command on OneNote 2010's page selection: each outline takes it as if selected
9111045 /// alone, but a toggle turns on everywhere unless every outline already has it.
......@@ -991,6 +1125,167 @@ impl CanvasEditor {
9911125 .find(|(_, _, node)| node.id == id)
9921126 .ok_or(EditError::InvalidRange)?;
9931127 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>) {
9941289 let checkable = |tag: &Tag| {
9951290 matches!(
9961291 self.tag_kind(tag),
......@@ -998,11 +1293,6 @@ impl CanvasEditor {
9981293 if crate::outline::checkable(*shape)
9991294 )
10001295 };
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();
10061296 let checked = tags
10071297 .iter()
10081298 .filter(|tag| checkable(tag))
......@@ -1014,17 +1304,6 @@ impl CanvasEditor {
10141304 tag.completed = completed;
10151305 }
10161306 }
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 )
10281307 }
10291308
10301309 /// Checks the check boxes of the `covered` paragraphs, or clears them once all are
......@@ -1344,6 +1623,80 @@ mod tests {
13441623 );
13451624 }
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
13471700 #[test]
13481701 fn clearing_and_removing_attributes_return_to_the_paragraph_style() {
13491702 let mut engine = TextEngine::default();
......@@ -1377,6 +1730,7 @@ mod tests {
13771730 Definition {
13781731 kind: Kind::Style {
13791732 name: Some("h1".into()),
1733 next: None,
13801734 },
13811735 format: heading.clone(),
13821736 },
......@@ -2199,6 +2553,7 @@ mod tests {
21992553 (NoteTag::defaults()[0].clone(), 0),
22002554 (NoteTag::defaults()[2].clone(), 2)
22012555 ],
2556 style: state.style.clone(),
22022557 }
22032558 );
22042559 // 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 {
4646 | PageOp::Outline { object: id, .. }
4747 | PageOp::Paragraph { paragraph: id, .. }
4848 | PageOp::Style { paragraph: id, .. }
49 | PageOp::Unstyle { paragraph: id }
4950 | PageOp::Media { paragraph: id, .. }
5051 | PageOp::List { paragraph: id, .. }
5152 | PageOp::Tags { target: id, .. } => own(id),
......@@ -58,6 +59,8 @@ pub(super) fn references(node: &PageParagraph) -> impl Iterator<Item = ExGuid> +
5859 let content_tags = match &node.content {
5960 ParagraphContent::Text(text) => text.tags.as_slice(),
6061 ParagraphContent::Table(table) => table.tags.as_slice(),
62 ParagraphContent::Image(image) => image.tags.as_slice(),
63 ParagraphContent::Attachment(file) => file.tags.as_slice(),
6164 _ => &[],
6265 };
6366 node.lists.iter().copied().chain(node.style).chain(
......@@ -283,6 +286,26 @@ pub(super) fn layout_ops(id: ExGuid, old: &Layout, new: &Layout) -> Lowered {
283286}
284287
285288/// 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
286309pub(super) fn picture_layout(image: &onestore::page::Image) -> Vec<PageOp> {
287310 vec![PageOp::Picture {
288311 picture: image.id,
crates/canvas/src/editor/page.rs+21-15
......@@ -100,6 +100,22 @@ impl Content {
100100 Some((source, [x + x0, y + y0, x + x1, y + y1]))
101101 }
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
103119 pub(super) fn layout_mut(
104120 &mut self,
105121 ) -> Option<(onestore::ExGuid, &mut onestore::document::Layout)> {
......@@ -354,27 +370,17 @@ pub(crate) fn build(
354370 }
355371 }
356372 PageObject::Image(source) => {
357 if source.bytes.is_none()
358 || source.layout.max_width.is_none()
359 || source.layout.max_height.is_none()
360 {
373 // A picture without a layout size shows at its own, as COM inserts them.
374 let Some(size) =
375 crate::outline::image_size(source).filter(|_| source.bytes.is_some())
376 else {
361377 objects.push(Content::unavailable(object, engine)?);
362378 continue;
363 }
379 };
364380 let origin = [
365381 source.layout.x.unwrap_or(0.0),
366382 source.layout.y.unwrap_or(0.0),
367383 ];
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 ];
378384 if origin.iter().any(|v| !v.is_finite())
379385 || size.iter().any(|v| !v.is_finite() || *v <= 0.0)
380386 || !(origin[0] + size[0]).is_finite()
crates/canvas/src/editor/recording.rs+2-1
......@@ -22,6 +22,7 @@ fn cite() -> Definition {
2222 Definition {
2323 kind: Kind::Style {
2424 name: Some("cite".into()),
25 next: None,
2526 },
2627 format: Format {
2728 bold: Some(false),
......@@ -107,7 +108,7 @@ impl CanvasEditor {
107108 vec![Paragraph::new(String::new(), format); parts],
108109 )?;
109110 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")
111112 }) {
112113 Some((style, _)) => *style,
113114 None => {
crates/canvas/src/editor/table.rs+522-5
......@@ -1,6 +1,6 @@
11use super::*;
22use onestore::page::text::new_id;
3use onestore::page::{ParagraphContent, TableCell, TableColumn, TableRow};
3use onestore::page::{ParagraphContent, Table, TableCell, TableColumn, TableRow};
44
55struct CellLocation<'a> {
66 container: Option<ExGuid>,
......@@ -35,6 +35,18 @@ fn locate(document: &TextDocument, id: ExGuid) -> Option<CellLocation<'_>> {
3535 None
3636}
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
3850/// A new cell like `source`, one empty paragraph in its first text's format; a cell holding
3951/// no text, only pictures or files, gives a plain one.
4052fn empty_cell(source: &TableCell) -> Result<TableCell, EditError> {
......@@ -85,7 +97,270 @@ fn cell_range(document: &TextDocument, cell: &TableCell) -> Result<Range<TextPos
8597 })
8698}
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
88168impl 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 }
89364 /// Insert, Table: an empty table of `rows` by `columns` at the caret with the caret in
90365 /// its first cell, as OneNote 2010 inserts one: in place of an empty paragraph, before
91366 /// or after the caret's paragraph at its start or end, and between its halves otherwise.
......@@ -137,7 +412,7 @@ impl CanvasEditor {
137412 id: new_id()?,
138413 columns: vec![
139414 TableColumn {
140 width: 37.11,
415 width: COLUMN_WIDTH,
141416 locked: false
142417 };
143418 columns
......@@ -260,7 +535,7 @@ impl CanvasEditor {
260535 id: new_id()?,
261536 columns: vec![
262537 TableColumn {
263 width: 37.11,
538 width: COLUMN_WIDTH,
264539 locked: false
265540 };
266541 2
......@@ -341,7 +616,7 @@ impl CanvasEditor {
341616 };
342617 table.rows[0].cells.push(target);
343618 table.columns.push(TableColumn {
344 width: 37.11,
619 width: COLUMN_WIDTH,
345620 locked: false,
346621 });
347622 TextPosition {
......@@ -713,7 +988,9 @@ mod tests {
713988 };
714989 assert_eq!(after.rows.len(), 3);
715990 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]);
717994 for cell in &after.rows[2].cells {
718995 assert_eq!(cell.paragraphs.len(), 1);
719996 assert!(cell.paragraphs[0].text().unwrap().text.text().is_empty());
......@@ -912,4 +1189,244 @@ mod tests {
9121189 );
9131190 assert_eq!(editor.active_outline().shaped.paragraphs[0].origin[0], 27.0);
9141191 }
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 }
9151432}
crates/canvas/src/gpu/page.rs+98-24
......@@ -24,6 +24,8 @@ use std::{
2424/// Retained drawing data in the source page's coordinate system.
2525pub struct PageScene {
2626 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>,
2729 /// Pictures and file icons, by the identity of the object that shows them.
2830 pictures: std::collections::BTreeMap<onestore::ExGuid, Picture>,
2931 /// Background pictures from OneNote's page templates, painted from their recreations.
......@@ -80,6 +82,8 @@ pub enum SceneHit<T> {
8082 ReadOnly(usize),
8183 Image(onestore::ExGuid),
8284 File(onestore::ExGuid),
85 /// A check box tag on a picture or file on the page.
86 Check(onestore::ExGuid),
8387}
8488
8589fn outline_origin(
......@@ -116,11 +120,12 @@ pub(crate) fn append_ink(
116120 let size = stroke.width.max(stroke.height);
117121 let segment = |from: [f32; 2], to: [f32; 2], [t0, t1]: [f32; 2]| {
118122 if !highlighter {
119 if round && t0 != t1 {
123 if t0 != t1 {
120124 return Primitive::Taper {
121125 from,
122126 to,
123127 widths: [size * t0, size * t1],
128 round,
124129 color,
125130 };
126131 }
......@@ -253,6 +258,7 @@ impl PageScene {
253258 pictures.mark_unavailable(&mut objects, engine)?;
254259 Ok(Self {
255260 reference: Some(objects),
261 definitions: std::mem::take(&mut page.definitions),
256262 ..pictures
257263 })
258264 }
......@@ -398,6 +404,7 @@ impl PageScene {
398404 }
399405 Ok(Self {
400406 reference: None,
407 definitions: Default::default(),
401408 pictures,
402409 backgrounds,
403410 })
......@@ -438,6 +445,17 @@ impl PageScene {
438445 }
439446 }
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
441459 /// The raster a picture or file icon shows, once decoded.
442460 pub fn image(&self, id: onestore::ExGuid) -> Option<&draw::RasterImage> {
443461 self.pictures.get(&id)?.image()
......@@ -463,9 +481,7 @@ impl PageScene {
463481 source.layout.x.unwrap_or(0.0),
464482 source.layout.y.unwrap_or(0.0),
465483 ];
466 if let (Some(width), Some(height)) =
467 (source.layout.max_width, source.layout.max_height)
468 {
484 if let Some([width, height]) = crate::outline::image_size(source) {
469485 rects.push((source.id, [x, y, x + width, y + height]));
470486 }
471487 }
......@@ -581,6 +597,38 @@ impl PageScene {
581597 .ok_or(SceneError::MissingOutline)
582598 }
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
584632 pub fn read_only<'a>(
585633 &'a self,
586634 editor: Option<&'a CanvasEditor>,
......@@ -650,15 +698,11 @@ impl PageScene {
650698 true,
651699 ))
652700 }
653 Content::Image(source) => Some((
654 [
655 source.layout.x.unwrap_or(0.0),
656 source.layout.y.unwrap_or(0.0),
657 source.layout.x.unwrap_or(0.0) + source.layout.max_width?,
658 source.layout.y.unwrap_or(0.0) + source.layout.max_height?,
659 ],
660 false,
661 )),
701 Content::Image(source) => {
702 let [x, y] = [source.layout.x, source.layout.y].map(|v| v.unwrap_or(0.0));
703 let [width, height] = crate::outline::image_size(source)?;
704 Some(([x, y, x + width, y + height], false))
705 }
662706 Content::File { .. } => Some((object.file()?.1, true)),
663707 Content::Ink(ink) => Some((crate::editor::page::ink_bounds(ink)?, true)),
664708 Content::ReadOnly(object) => Some((object.rect(), true)),
......@@ -716,6 +760,14 @@ impl PageScene {
716760 editor: Option<&CanvasEditor>,
717761 mut outline: impl FnMut(onestore::ExGuid) -> Option<T>,
718762 ) -> 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 }
719771 let mut readonly = self.read_only(editor).count();
720772 for object in self.objects(editor).ok()?.iter().rev() {
721773 match object {
......@@ -746,8 +798,7 @@ impl PageScene {
746798 source.layout.x.unwrap_or(0.0),
747799 source.layout.y.unwrap_or(0.0),
748800 ];
749 if let (Some(width), Some(height)) =
750 (source.layout.max_width, source.layout.max_height)
801 if let Some([width, height]) = crate::outline::image_size(source)
751802 && (x..=x + width).contains(&point[0])
752803 && (y..=y + height).contains(&point[1])
753804 {
......@@ -824,11 +875,7 @@ impl PageScene {
824875 ];
825876 let size = match self.backgrounds.get(&source.id) {
826877 Some(art) => Some(art.size),
827 None => source
828 .layout
829 .max_width
830 .zip(source.layout.max_height)
831 .map(|(width, height)| [width, height]),
878 None => crate::outline::image_size(source),
832879 };
833880 if let Some([width, height]) = size {
834881 cover([x, y, x + width, y + height], !source.background);
......@@ -975,10 +1022,8 @@ impl PageScene {
9751022 .pictures
9761023 .get(&source.id)
9771024 .ok_or(SceneError::MissingImage)?;
978 let size = [
979 source.layout.max_width.ok_or(SceneError::MissingImage)?,
980 source.layout.max_height.ok_or(SceneError::MissingImage)?,
981 ];
1025 let size = crate::outline::image_size(source)
1026 .ok_or(SceneError::MissingImage)?;
9821027 if picture.failed() {
9831028 append_placeholder(
9841029 [
......@@ -1059,6 +1104,16 @@ impl PageScene {
10591104 | Content::File { .. } => unreachable!(),
10601105 }
10611106 }
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 }
10621117 Ok(())
10631118 }
10641119}
......@@ -1547,6 +1602,7 @@ mod tests {
15471602 onestore::page::Definition {
15481603 kind: onestore::document::Kind::Style {
15491604 name: Some("p".into()),
1605 next: None,
15501606 },
15511607 format: onestore::document::Format {
15521608 font_size: Some(base_size),
......@@ -1720,6 +1776,9 @@ mod tests {
17201776 alt: None,
17211777 background,
17221778 printout: None,
1779 tags: Vec::new(),
1780 link: None,
1781 text: None,
17231782 }));
17241783 }
17251784 let (mut scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap();
......@@ -2082,6 +2141,9 @@ mod tests {
20822141 alt: Some("missing diagram".into()),
20832142 background: false,
20842143 printout: None,
2144 tags: Vec::new(),
2145 link: None,
2146 text: None,
20852147 }),
20862148 PageObject::Unsupported(unknown.clone()),
20872149 ],
......@@ -2252,6 +2314,9 @@ mod tests {
22522314 alt: None,
22532315 background: false,
22542316 printout: None,
2317 tags: Vec::new(),
2318 link: None,
2319 text: None,
22552320 }),
22562321 outline(50.0, "last"),
22572322 ],
......@@ -2545,6 +2610,9 @@ mod tests {
25452610 alt: None,
25462611 background: false,
25472612 printout: None,
2613 tags: Vec::new(),
2614 link: None,
2615 text: None,
25482616 })],
25492617 };
25502618 let mut engine = TextEngine::default();
......@@ -2610,6 +2678,9 @@ mod tests {
26102678 alt: None,
26112679 background,
26122680 printout: None,
2681 tags: Vec::new(),
2682 link: None,
2683 text: None,
26132684 };
26142685 let [background, picture] = [image(0.0, true), image(50.0, false)];
26152686 let id = picture.id;
......@@ -2681,6 +2752,9 @@ mod tests {
26812752 alt: None,
26822753 background: false,
26832754 printout: None,
2755 tags: Vec::new(),
2756 link: None,
2757 text: None,
26842758 })
26852759 .collect();
26862760 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 {
429429}
430430
431431impl 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
432439 /// Whether both recreate the same template picture at the same size.
433440 pub fn same(&self, other: &Self) -> bool {
434441 self.svg == other.svg && self.size == other.size
......@@ -757,6 +764,9 @@ mod tests {
757764 alt: None,
758765 background: true,
759766 printout: None,
767 tags: Vec::new(),
768 link: None,
769 text: None,
760770 }
761771 }
762772
crates/canvas/src/gpu/profile.rs+3
......@@ -247,6 +247,9 @@ fn frame_cost() {
247247 alt: None,
248248 background: false,
249249 printout: None,
250 tags: Vec::new(),
251 link: None,
252 text: None,
250253 });
251254 let cell = || TableCell {
252255 id: new_id().unwrap(),
crates/canvas/src/interaction/mod.rs+210-41
......@@ -16,7 +16,10 @@ mod tests;
1616use crate::gpu::{Paper, Viewport, page::PageScene};
1717use crate::{
1818 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 },
2023 layout::TextEngine,
2124};
2225use draw::{
......@@ -177,6 +180,15 @@ enum Drag {
177180 outline: Option<Box<TextOutline>>,
178181 grab: f32,
179182 },
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 },
180192 Outline {
181193 id: onestore::ExGuid,
182194 grab: [f32; 2],
......@@ -346,9 +358,14 @@ impl PageView {
346358 view
347359 }
348360
349 /// Shows a page reloaded from storage in place of the edited one.
350 pub fn replace(&mut self, editor: CanvasEditor, scene: Option<(PageScene, [f32; 2])>) {
351 self.editor = editor;
361 /// Shows a page reloaded from storage in place of the edited one, whose editor it returns.
362 pub fn replace(
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);
352369 self.scene = scene;
353370 self.drag = None;
354371 self.space = false;
......@@ -356,6 +373,7 @@ impl PageView {
356373 self.found.clear();
357374 self.played = None;
358375 self.leave_ink();
376 left
359377 }
360378
361379 /// Shows the stored page after a change made elsewhere in place of the one shown,
......@@ -376,23 +394,36 @@ impl PageView {
376394
377395 /// Shows another page as OneNote opens one, keeping the zoom: at `place` if the page was
378396 /// 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.
380398 pub fn open(
381399 &mut self,
382400 editor: CanvasEditor,
383401 scene: Option<(PageScene, [f32; 2])>,
384402 place: Option<Place>,
385 ) {
386 self.replace(editor, scene);
403 ) -> CanvasEditor {
404 let left = self.replace(editor, scene);
387405 self.reach = None;
388 let Some(place) = place else {
389 return self.place_opened();
390 };
391 self.viewport.origin = place.origin;
392 self.scroll().clamp(&mut self.viewport);
393 if self.editor.focus_outline(place.outline).is_ok() {
394 let _ = self.editor.select(place.selection);
406 match place {
407 Some(place) => {
408 self.viewport.origin = place.origin;
409 self.scroll().clamp(&mut self.viewport);
410 if self.editor.focus_outline(place.outline).is_ok() {
411 let _ = self.editor.select(place.selection);
412 }
413 }
414 None => self.place_opened(),
395415 }
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(())
396427 }
397428
398429 /// Where the page is left, for `open` to return to.
......@@ -907,10 +938,16 @@ impl PageView {
907938 /// Everything to draw this frame.
908939 pub fn primitives(&self, colors: TextColors) -> Result<Vec<Primitive<'_>>> {
909940 let preview = match &self.drag {
910 Some(Drag::Resize {
911 outline: Some(outline),
912 ..
913 }) => Some(PointerFeedback::Resize(outline)),
941 Some(
942 Drag::Resize {
943 outline: Some(outline),
944 ..
945 }
946 | Drag::Column {
947 preview: Some((_, outline)),
948 ..
949 },
950 ) => Some(PointerFeedback::Resize(outline)),
914951 _ => self
915952 .drag_delta()
916953 .map(|(id, delta)| PointerFeedback::Move(id, delta))
......@@ -958,6 +995,7 @@ impl PageView {
958995 Some(
959996 Drag::Outline { .. }
960997 | Drag::Resize { .. }
998 | Drag::Column { .. }
961999 | Drag::Image { .. }
9621000 | Drag::Space { .. }
9631001 )
......@@ -1038,8 +1076,14 @@ impl PageView {
10381076 let hit = self.hit_test(point);
10391077 match (&self.drag, hit) {
10401078 (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 }
10411084 (None, Some(Hit::Image { handle, .. })) => handle_cursor(handle),
10421085 (Some(Drag::Resize { .. }), _) | (None, Some(Hit::Resize { .. })) => Cursor::EwResize,
1086 (Some(Drag::Column { .. }), _) | (None, Some(Hit::Column { .. })) => Cursor::ColResize,
10431087 (Some(Drag::Outline { .. }), _) | (None, Some(Hit::Handle { .. })) => Cursor::Move,
10441088 (None, Some(Hit::Date(_))) => Cursor::Pointer,
10451089 (None, Some(Hit::Text { id, point }))
......@@ -1047,7 +1091,10 @@ impl PageView {
10471091 {
10481092 Cursor::Pointer
10491093 }
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,
10511098 _ => Cursor::Text,
10521099 }
10531100 }
......@@ -1155,6 +1202,23 @@ impl PageView {
11551202 }
11561203 Ok(Response::default())
11571204 }
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 }
11581222 None => Ok(Response::redraw()),
11591223 }
11601224 }
......@@ -1195,6 +1259,12 @@ impl PageView {
11951259 .click_check(&mut self.engine, outline, paragraph)?;
11961260 return self.changed();
11971261 }
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 }
11981268 Some(Hit::File(id)) => {
11991269 self.set_object_focus(Some(ObjectFocus::File(id)));
12001270 self.drag = None;
......@@ -1213,6 +1283,17 @@ impl PageView {
12131283 });
12141284 }
12151285 }
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 }
12161297 Some(Hit::Image { id, handle }) => {
12171298 self.set_object_focus(Some(ObjectFocus::Image(id)));
12181299 self.drag = Some(Drag::Image {
......@@ -1242,6 +1323,22 @@ impl PageView {
12421323 grab,
12431324 });
12441325 }
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 }
12451342 Some(Hit::Text { id, point })
12461343 if unit == SelectionUnit::Grapheme
12471344 && !self.modifiers.shift
......@@ -1326,13 +1423,24 @@ impl PageView {
13261423 self.editor
13271424 .place_image(&mut self.engine, id, origin, size)?;
13281425 }
1329 if let Some(Drag::Resize {
1330 outline: Some(outline),
1331 ..
1332 }) = self.drag.take()
1333 {
1334 self.editor
1335 .resize(&mut self.engine, outline.bounds().width() as f32)?;
1426 match self.drag.take() {
1427 Some(Drag::Resize {
1428 outline: Some(outline),
1429 ..
1430 }) => {
1431 self.editor
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 _ => {}
13361444 }
13371445 if let Some(id) = clicked {
13381446 // A click on an outline's handle selects it as OneNote does.
......@@ -1421,6 +1529,11 @@ impl PageView {
14211529 /// A toolbar command, ignored like other edits while an input method composes or an object
14221530 /// holds focus.
14231531 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 }
14241537 if !self.accepts_text() || self.editor.marked_range().is_some() {
14251538 return Ok(Response::default());
14261539 }
......@@ -1692,25 +1805,44 @@ impl PageView {
16921805 if !self.accepts_text() || self.editor.marked_range().is_some() {
16931806 return Ok(Response::default());
16941807 }
1695 let image = onestore::page::Image {
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 };
1808 let image = crate::editor::picture(bytes, size)?;
17091809 self.editor.insert_picture(&mut self.engine, image)?;
17101810 self.follow_pictures()?;
17111811 self.edited()
17121812 }
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
17141846 /// Record Audio: see [`CanvasEditor::start_recording`]; none where text cannot go.
17151847 pub fn start_recording(&mut self, label: &str) -> Result<(Option<[u8; 16]>, Response)> {
17161848 if !self.accepts_text() || self.editor.marked_range().is_some() {
......@@ -1765,6 +1897,24 @@ impl PageView {
17651897 position: [f32; 2],
17661898 file: onestore::page::Attachment,
17671899 ) -> 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<()> {
17681918 let point = self.viewport.document_point(position);
17691919 self.set_object_focus(None);
17701920 match self.hit_test(point) {
......@@ -1779,7 +1929,7 @@ impl PageView {
17791929 None => self.place_caret(point)?,
17801930 _ => {}
17811931 }
1782 self.insert_attachment(file)
1932 Ok(())
17831933 }
17841934
17851935 /// Professional, or Linear with `linear`, on the equation at the caret.
......@@ -1906,6 +2056,7 @@ impl PageView {
19062056 Some(
19072057 Drag::Outline { .. }
19082058 | Drag::Resize { .. }
2059 | Drag::Column { .. }
19092060 | Drag::Image { .. }
19102061 | Drag::Space { .. }
19112062 )
......@@ -2086,6 +2237,13 @@ pub enum Hit {
20862237 id: onestore::ExGuid,
20872238 grab: f32,
20882239 },
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 },
20892247 Text {
20902248 id: onestore::ExGuid,
20912249 point: [f32; 2],
......@@ -2107,6 +2265,8 @@ pub enum Hit {
21072265 },
21082266 /// A file in an outline's flow or on the page, its icon or name.
21092267 File(onestore::ExGuid),
2268 /// A check box tag on a picture or file on the page.
2269 ObjectCheck(onestore::ExGuid),
21102270}
21112271
21122272/// What a document point lands on; `pixel` is document points per device pixel.
......@@ -2231,6 +2391,14 @@ fn page_hit(
22312391 }
22322392 }
22332393 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 }
22342402 if let Some(picture) = outline.shaped().objects.iter().find(|object| {
22352403 matches!(object.kind, crate::outline::ObjectKind::Picture)
22362404 && (object.rect[0]..=object.rect[2]).contains(&inner[0])
......@@ -2285,6 +2453,7 @@ fn page_hit(
22852453 crate::gpu::page::SceneHit::ReadOnly(index) => Some(Hit::ReadOnly(index)),
22862454 crate::gpu::page::SceneHit::Image(id) => Some(Hit::Image { id, handle: [0, 0] }),
22872455 crate::gpu::page::SceneHit::File(id) => Some(Hit::File(id)),
2456 crate::gpu::page::SceneHit::Check(id) => Some(Hit::ObjectCheck(id)),
22882457 }
22892458 };
22902459 [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() {
708708 alt: None,
709709 background: false,
710710 printout: None,
711 tags: Vec::new(),
712 link: None,
713 text: None,
711714 });
712715 source.paragraphs.insert(1, picture);
713716 let editor = CanvasEditor::from_page(
......@@ -882,6 +885,13 @@ fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() {
882885 .unwrap();
883886 editor.tab(&mut engine, false).unwrap();
884887 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 }
885895 editor.tab(&mut engine, true).unwrap();
886896 let mut primitives = Vec::new();
887897 append_outline(
......@@ -1545,6 +1555,9 @@ fn picture_view() -> (PageView, onestore::ExGuid) {
15451555 alt: None,
15461556 background: false,
15471557 printout: None,
1558 tags: Vec::new(),
1559 link: None,
1560 text: None,
15481561 });
15491562 source.paragraphs.insert(1, picture);
15501563 let editor = CanvasEditor::from_page(
......@@ -2768,6 +2781,7 @@ fn an_attached_file_selects_opens_on_a_double_click_and_deletes() {
27682781 bytes: Some(Arc::from(b"notes".as_slice())),
27692782 preview: None,
27702783 recording: None,
2784 tags: Vec::new(),
27712785 };
27722786 view.editor
27732787 .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() {
28442858 bytes: Some(Arc::from(b"float".as_slice())),
28452859 preview: None,
28462860 recording: None,
2861 tags: Vec::new(),
28472862 };
28482863 let _ = view.drop_attachment([400.0, 400.0], file.clone()).unwrap();
28492864 assert_eq!(view.editor.outlines().len(), 1);
......@@ -3154,3 +3169,177 @@ fn the_spelling_pane_walks_marked_words_from_the_caret() {
31543169 spelling.learn("qwrt");
31553170 assert_eq!(next(&mut view), None);
31563171}
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 {
6262 id: u64,
6363 pub(crate) shaped: Arc<Layout<TextBrush>>,
6464 /// The paragraph text laid out, which a PDF's text layer maps glyphs back to.
65 #[cfg(feature = "gpu")]
6566 pub(crate) text: Arc<str>,
6667 lines: Vec<LineBox>,
6768 /// Where each inline space lies: its index in the spaces laid out, its x, and its line.
......@@ -520,6 +521,7 @@ impl TextEngine {
520521 Ok(TextLayout {
521522 id,
522523 shaped: Arc::new(shaped),
524 #[cfg(feature = "gpu")]
523525 text: text.into(),
524526 lines,
525527 spaces: placed,
crates/canvas/src/outline.rs+72-5
......@@ -1,5 +1,5 @@
11use crate::{
2 document::{DocumentEdit, edited_nodes, leaves},
2 document::{DocumentEdit, descendants, edited_nodes, leaves},
33 layout::{InlineSpace, LayoutError, TextEngine, TextLayout},
44};
55use onestore::page::text::{Paragraph, TextProjection};
......@@ -529,6 +529,51 @@ impl ParagraphTag {
529529 }
530530}
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
532577/// How much further than its list spacing OneNote 2010 sets a marker's advance from its text.
533578const MARKER_OFFSET: f32 = 3.9;
534579/// Points an equation's linear text lays out at, holding the caret without showing.
......@@ -1519,7 +1564,12 @@ impl OutlineLayout {
15191564 unreachable!("text and tables are not objects")
15201565 }
15211566 };
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)?;
15231573 let flow = object.place(y, height);
15241574 result.objects.push(object);
15251575 (space, height, flow, extent)
......@@ -1580,8 +1630,9 @@ impl OutlineLayout {
15801630 }
15811631
15821632 /// 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 or
1584 /// showing or hiding other nodes lays out every node; renumbering lays out the renumbered.
1633 /// holding its cell, and works out where the nodes after them move. Resizing columns of
1634 /// other nodes or showing or hiding them lays out every node; renumbering lays out the
1635 /// renumbered.
15851636 #[allow(clippy::too_many_arguments)]
15861637 pub(crate) fn relayout(
15871638 &self,
......@@ -1598,7 +1649,15 @@ impl OutlineLayout {
15981649 &[f32],
15991650 ) -> Result<ParagraphLayout, LayoutError>,
16001651 ) -> 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 {
16021661 range
16031662 } else {
16041663 0..nodes.len()
......@@ -1869,6 +1928,13 @@ impl OutlineLayout {
18691928 table_width(&self.tables)
18701929 }
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
18721938 fn table(
18731939 table: &Table,
18741940 depth: usize,
......@@ -2747,6 +2813,7 @@ mod tests {
27472813 bytes: None,
27482814 preview: None,
27492815 recording: None,
2816 tags: Vec::new(),
27502817 });
27512818 let mut ink = paragraph(3, "", 1, None);
27522819 ink.content = ParagraphContent::Ink(Ink {
crates/canvas/src/print.rs+133-51
......@@ -5,8 +5,8 @@
55//! when its content reaches past the paper's right edge ("Scale content to paper width").
66//! A page longer than a sheet continues on the next, which starts at the first line of text
77//! 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 and
9//! numbers the sheet, counting from the first page printed, in Times New Roman.
8//! paper's width; the page colour does not. Each sheet's footer names the section, numbers
9//! the sheet counting from the first page printed, or both, in Times New Roman.
1010
1111use crate::gpu::{Paper, page::PageScene};
1212use crate::layout::{LayoutError, TextEngine};
......@@ -40,15 +40,17 @@ struct Pagination {
4040
4141impl Pagination {
4242 /// 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`.
4445 fn new(
4546 bounds: [f32; 4],
4647 rows: &[[f32; 2]],
4748 margin_origin: [f32; 2],
4849 paper: [f32; 2],
50 fit_width: bool,
4951 ) -> Self {
5052 let left = margin_origin[0] - LEFT;
51 let scale = if bounds[2].is_finite() {
53 let scale = if fit_width && bounds[2].is_finite() {
5254 (paper[0] / (bounds[2] - left + RIGHT)).min(1.0)
5355 } else {
5456 1.0
......@@ -113,29 +115,54 @@ impl fmt::Display for PrintError {
113115
114116impl std::error::Error for PrintError {}
115117
116/// `pages` in order as a PDF on `paper` (points), each on as many sheets as it takes, the
117/// footers naming `section`.
118/// What each sheet's footer says, as OneNote's Print Preview offers it.
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.
118141pub fn pdf(
119 pages: Vec<Page>,
142 sections: Vec<(String, Vec<Page>)>,
120143 engine: &mut TextEngine,
121 paper: [f32; 2],
122 section: &str,
144 setup: &Setup,
123145) -> Result<Vec<u8>, PrintError> {
124 let title = pages
125 .first()
146 let paper = setup.paper;
147 let title = sections
148 .iter()
149 .flat_map(|(_, pages)| pages.first())
150 .next()
126151 .map(|page| page.title.clone())
127152 .unwrap_or_default();
128 let mut printed = Vec::with_capacity(pages.len());
129 for page in pages {
130 let (margin_origin, rules) = (page.margin_origin, page.rule_lines);
131 let mut scene = PageScene::new(page, engine).map_err(PrintError::Scene)?;
132 scene.settle(None, DENSITY, Paper::WHITE);
133 let (bounds, rows) = scene.printed_extent().map_err(PrintError::Scene)?;
134 let pagination = Pagination::new(bounds, &rows, margin_origin, paper);
135 printed.push((scene, pagination, margin_origin, rules));
153 let mut printed = Vec::new();
154 for (section, pages) in sections {
155 for page in pages {
156 let (margin_origin, rules) = (page.margin_origin, page.rule_lines);
157 let mut scene = PageScene::new(page, engine).map_err(PrintError::Scene)?;
158 scene.settle(None, DENSITY, Paper::WHITE);
159 let (bounds, rows) = scene.printed_extent().map_err(PrintError::Scene)?;
160 let pagination = Pagination::new(bounds, &rows, margin_origin, paper, setup.fit_width);
161 printed.push((scene, pagination, margin_origin, rules, section.clone()));
162 }
136163 }
137164 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 {
139166 let mut primitives = Vec::new();
140167 scene
141168 .append_primitives(&mut primitives, [0.0; 2], Paper::WHITE)
......@@ -161,14 +188,17 @@ pub fn pdf(
161188 ..Default::default()
162189 };
163190 let mut footers = Vec::new();
164 for (sheet, _) in printed
191 for (sheet, section) in printed
165192 .iter()
166 .flat_map(|(_, pagination, ..)| pagination.sheets())
193 .flat_map(|(_, pagination, .., section)| pagination.sheets().map(move |_| section))
167194 .enumerate()
168195 {
169 let text = match section {
170 "" => format!("Page {}", sheet + 1),
171 section => format!("{section} Page {}", sheet + 1),
196 let number = sheet + 1;
197 let text = match (setup.footer, section.as_str()) {
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}"),
172202 };
173203 let layout = engine
174204 .layout(&Paragraph::new(text, format.clone()), f32::MAX)
......@@ -193,37 +223,33 @@ pub fn pdf(
193223 let scale = pagination.scale;
194224 for ([top, bottom], rules) in pagination.sheets().zip(rules) {
195225 let origin = [-pagination.left * scale, MARGIN - top * scale];
196 // Rule lines run to the bottom margin, the page's content to where the next
197 // sheet takes it up.
198 let page = |primitives, bottom: f32| Layer {
226 let layer = |origin, clip, primitives| Layer {
199227 scale,
200228 origin,
201 clip: Some([0.0, MARGIN, paper[0], MARGIN + (bottom - top) * scale]),
229 clip,
202230 backdrop: None,
203231 round: None,
204232 motion: None,
205233 primitives,
206234 };
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)];
207242 // OneNote shrinks the footer with the page, towards the paper's bottom left.
208 let footer = Layer {
209 scale,
210 origin: [
243 if let Some(footer) = footer.next() {
244 let at = [
211245 paper[0] / 2.0 * scale,
212246 paper[1] - 2.0 - (1.0 - scale) * 16.0,
213 ],
214 clip: None,
215 backdrop: None,
216 round: None,
217 motion: None,
218 primitives: footer.next().expect("Each sheet has a footer"),
219 };
247 ];
248 layers.push(layer(at, None, footer));
249 }
220250 sheets.push(Sheet {
221251 size: paper,
222 layers: vec![
223 page(rules, top + pagination.band),
224 page(primitives, bottom),
225 footer,
226 ],
252 layers,
227253 });
228254 }
229255 }
......@@ -236,6 +262,13 @@ mod tests {
236262 use onestore::{RevisionIndex, Store, document::Document};
237263 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
239272 fn section(path: &str) -> Vec<Page> {
240273 let bytes = std::fs::read(format!("{}/../../{path}", env!("CARGO_MANIFEST_DIR"))).unwrap();
241274 let store = Store::parse(&bytes).unwrap();
......@@ -456,7 +489,13 @@ mod tests {
456489 let rows: Vec<[f32; 2]> = (0..60)
457490 .map(|line| [90.0 + line as f32 * 20.0, 110.0 + line as f32 * 20.0])
458491 .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 );
460499 assert_eq!(pagination.scale, 1.0);
461500 // 14.4 + 720 cuts the line from 730 to 750, which starts the next sheet.
462501 assert_eq!(pagination.tops, [14.4, 730.0]);
......@@ -471,6 +510,7 @@ mod tests {
471510 &[[20.0, 1000.0]],
472511 [36.0, 14.4],
473512 LETTER,
513 true,
474514 );
475515 assert_eq!(pagination.tops, [14.4, 734.4]);
476516 }
......@@ -490,6 +530,7 @@ mod tests {
490530 &[],
491531 [36.0, 14.4],
492532 [612.36, 790.92],
533 true,
493534 )
494535 .scale;
495536 assert!(
......@@ -504,10 +545,9 @@ mod tests {
504545 #[test]
505546 fn a_section_prints_on_onenotes_sheets() {
506547 let pdf = pdf(
507 section("corpus/print/native/Print.one"),
548 vec![("Print".into(), section("corpus/print/native/Print.one"))],
508549 &mut TextEngine::default(),
509 LETTER,
510 "Print",
550 &ONENOTE,
511551 )
512552 .unwrap();
513553 let sheets = sheets(&pdf);
......@@ -538,10 +578,12 @@ mod tests {
538578 #[test]
539579 fn ruled_pages_and_art_print_on_onenotes_sheets() {
540580 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 )],
542585 &mut TextEngine::default(),
543 LETTER,
544 "Rules",
586 &ONENOTE,
545587 )
546588 .unwrap();
547589 let sheets = sheets(&pdf);
......@@ -550,4 +592,44 @@ mod tests {
550592 assert!(sheets[9].1.contains("Rules Page 10"));
551593 assert!(!sheets[9].1.contains("VeryLargeGrid"));
552594 }
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 }
553635}
crates/canvas/src/search.rs+39-1
......@@ -203,10 +203,48 @@ fn text_paragraphs<'a>(page: &'a Page, mut visit: impl FnMut(&'a PageParagraph,
203203 }
204204}
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.
207239pub fn page_text(page: &Page) -> String {
208240 let mut out = Vec::new();
209241 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 );
210248 out.retain(|line| !line.trim().is_empty());
211249 out.join("\n")
212250}
crates/canvas/src/template.rs+3
......@@ -47,6 +47,9 @@ impl Template {
4747 alt: None,
4848 background: true,
4949 printout: None,
50 tags: Vec::new(),
51 link: None,
52 text: None,
5053 })
5154 })
5255 .collect()
crates/canvas/tests/attachment_insert.rs+1
......@@ -58,6 +58,7 @@ fn file(name: &str, bytes: Vec<u8>, preview: Option<Arc<[u8]>>) -> Attachment {
5858 bytes: Some(bytes.into()),
5959 preview,
6060 recording: None,
61 tags: Vec::new(),
6162 }
6263}
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() {
874874
875875 // Enter in "Type equation here." leaves the placeholder's paragraph empty above a new one.
876876 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 );
878882 editor.enter(engine, false).unwrap();
879883 save(&mut editor);
880884 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 );
882889 assert_eq!(caret(&editor).paragraph, placeholder.paragraph + 1);
883890
884891 // 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() {
926933 .utf16_offset(texts(&editor)[end].len())
927934 .unwrap();
928935 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 ] {
930940 typed(&mut editor, engine, before);
931941 editor.insert_equation(engine).unwrap();
932942 typed(&mut editor, engine, linear);
crates/canvas/tests/ops_differential.rs+196-8
......@@ -31,7 +31,7 @@ const ART: [u8; 67] = [
3131#[path = "../../onestore/tests/support/sweep.rs"]
3232mod sweep;
3333
34const SECTIONS: [&str; 19] = [
34const SECTIONS: [&str; 20] = [
3535 "corpus/outline-edit/before/notebook/synthetic.one",
3636 "corpus/paragraph-edit/before/notebook/synthetic.one",
3737 "corpus/outline-edit/tree/before/notebook/synthetic.one",
......@@ -51,6 +51,7 @@ const SECTIONS: [&str; 19] = [
5151 "corpus/math-edit/native-enter/notebook/links.one",
5252 "corpus/paragraph-format/cold/notebook/synthetic.one",
5353 "corpus/canvas/baseline-anchors.one",
54 "corpus/styles/onenote/Styles.one",
5455];
5556
5657#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
......@@ -130,6 +131,15 @@ enum Kind {
130131 /// Lassoed drawings moved or deleted.
131132 LassoMove,
132133 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,
133143 Undo,
134144 Redo,
135145}
......@@ -267,6 +277,74 @@ const INK_PLANS: [&[Kind]; 6] = [
267277 &[Kind::LassoMove, Kind::LassoDelete, Kind::Undo, Kind::Undo],
268278];
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
270348/// Page colour, rule lines and art undone and redone across each other.
271349const PAPER_PLANS: [&[Kind]; 3] = [
272350 &[Kind::Art, Kind::Art, Kind::Undo, Kind::Undo, Kind::Redo],
......@@ -327,6 +405,7 @@ fn perform(
327405 Kind::Undo
328406 | Kind::Redo
329407 | Kind::TypeOn
408 | Kind::ColumnResize
330409 | Kind::PlaceImage
331410 | Kind::RemoveImage
332411 | Kind::CreateOutline
......@@ -468,6 +547,29 @@ fn perform(
468547 let id = outline.id;
469548 editor.move_outline(id, [x + 36.0, y + 18.0]).is_ok()
470549 }
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 }
471573 Kind::ResizeOutline => {
472574 let width = editor.active_outline().wrap_width();
473575 editor.resize(engine, width + 72.0).is_ok()
......@@ -602,6 +704,9 @@ fn perform(
602704 alt: None,
603705 background: true,
604706 printout: None,
707 tags: Vec::new(),
708 link: None,
709 text: None,
605710 };
606711 let shown = editor.page().unwrap().objects.iter().any(|object| {
607712 matches!(object, onestore::page::PageObject::Image(image) if image.background)
......@@ -630,6 +735,7 @@ fn perform(
630735 kind: 1,
631736 duration_ms: Some(1000),
632737 }),
738 tags: Vec::new(),
633739 };
634740 editor.insert(engine, "noted").is_ok()
635741 && editor.finish_recording(engine, file).is_ok()
......@@ -645,6 +751,7 @@ fn perform(
645751 bytes: Some(std::sync::Arc::from(b"notes".as_slice())),
646752 preview: Some(std::sync::Arc::from(ART.as_slice())),
647753 recording: None,
754 tags: Vec::new(),
648755 };
649756 place(editor, middle) && editor.insert_attachment(engine, file).is_ok()
650757 }
......@@ -658,6 +765,7 @@ fn perform(
658765 bytes: Some(std::sync::Arc::from(b"float".as_slice())),
659766 preview: Some(std::sync::Arc::from(ART.as_slice())),
660767 recording: None,
768 tags: Vec::new(),
661769 };
662770 let x = 36.0 + 18.0 * random.below(20) as f32;
663771 editor.place_caret(engine, [x, 1440.0], 240.0).is_ok()
......@@ -778,6 +886,17 @@ fn perform(
778886 _ => editor.delete_ink(&[ink.id]).is_ok(),
779887 }
780888 }
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"),
781900 Kind::Undo => editor.undo(engine).unwrap_or(false),
782901 Kind::Redo => editor.redo(engine).unwrap_or(false),
783902 }
......@@ -843,6 +962,66 @@ fn comparable(page: &Page) -> serde_json::Value {
843962}
844963
845964impl 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
8461025 /// Stores the editor's step as its ops, checks the stored page against what the model
8471026 /// oracle predicts from them and against the sealed image reread, and, unless it is the
8481027 /// editor's page, compares with that page lowered whole onto the section as it was
......@@ -1071,6 +1250,8 @@ fn sweep(
10711250 .chain(RECORD_PLANS.map(<[Kind]>::to_vec))
10721251 .chain(FLOATING_PLANS.map(<[Kind]>::to_vec))
10731252 .chain(TYPING_PLANS.map(<[Kind]>::to_vec))
1253 .chain(STYLE_PLANS.map(<[Kind]>::to_vec))
1254 .chain(TABLE_PLANS.map(<[Kind]>::to_vec))
10741255 .collect::<Vec<Vec<Kind>>>();
10751256 for plan in plans {
10761257 let arena = Arena::default();
......@@ -1082,13 +1263,20 @@ fn sweep(
10821263 let mut editor = CanvasEditor::from_page(page.clone(), engine).unwrap();
10831264 for kind in &plan {
10841265 let model = editor.page().unwrap();
1085 if !perform(&mut editor, engine, *kind, random) {
1086 continue;
1087 }
1088 if std::env::var_os("OPS_SWEEP_DEBUG").is_some() {
1089 eprintln!("STEP {kind:?} of {plan:?} on {title:?}");
1090 }
1091 let outcome = stored.step(&mut editor, &model);
1266 let outcome = if *kind == Kind::Theme {
1267 match stored.theme(&mut editor, engine, random.below(2) == 0) {
1268 Some(outcome) => outcome,
1269 None => continue,
1270 }
1271 } else {
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 };
10921280 *tally.outcomes.entry((*kind, outcome.clone())).or_default() += 1;
10931281 if let Outcome::Differs(reason) = &outcome {
10941282 tally
crates/canvas/tests/picture_paste.rs+142-3
......@@ -6,13 +6,13 @@
66
77use canvas::{
88 document::TextPosition,
9 editor::{CanvasEditor, Selection},
9 editor::{CanvasEditor, Piece, Selection},
1010 layout::TextEngine,
1111};
1212use onestore::{
13 Arena, Section, Store,
13 Arena, PageCreation, Section, Store,
1414 document::Layout,
15 op::{Edit, Op},
15 op::{Edit, Op, SectionOp},
1616 page::{Image, PageObject, ParagraphContent},
1717};
1818use parley::Affinity;
......@@ -35,6 +35,9 @@ fn picture(size: [f32; 2]) -> Image {
3535 alt: None,
3636 background: false,
3737 printout: None,
38 tags: Vec::new(),
39 link: None,
40 text: None,
3841 }
3942}
4043
......@@ -123,6 +126,72 @@ fn pasted_pictures_split_the_text_or_lie_on_the_page_as_onenote_places_them() {
123126 store(&mut editor);
124127 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
126195 // At the end, an empty paragraph follows for the caret; at an empty paragraph's start
127196 // the picture takes its place.
128197 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() {
194263 .unwrap();
195264 }
196265}
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 @@
11//! A file printout, whose pictures OneNote 2010 stores as pages of an XPS package with a PNG
22//! rendering of each beside it (WebPictureContainer14), shows that rendering and edits like
33//! 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
66use canvas::{document::TextPosition, editor::CanvasEditor, layout::TextEngine};
77use onestore::{
......@@ -100,9 +100,16 @@ fn printouts_show_onenotes_rendering_and_move_as_pictures() {
100100 let moved = after.iter().find(|picture| picture.id == moved).unwrap();
101101 assert_eq!([moved.layout.x, moved.layout.y], origin.map(Some));
102102 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();
103109 let restored = after.iter().find(|picture| picture.id == restored).unwrap();
104 assert_eq!(restored.printout, None);
105 assert_eq!(restored.bytes, printed[0].display);
110 assert_eq!(restored.printout, original.printout);
111 assert_eq!(restored.bytes, original.bytes);
112 assert_eq!(restored.display, original.display);
106113 if let Some(directory) = std::env::var_os("SNOWBOUND_PRINTOUT_EXPORT") {
107114 let directory = std::path::PathBuf::from(directory);
108115 std::fs::create_dir(&directory).unwrap();
......@@ -119,3 +126,48 @@ fn printouts_show_onenotes_rendering_and_move_as_pictures() {
119126 .unwrap();
120127 }
121128}
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() {
8989 kind: 1,
9090 duration_ms: Some(1000),
9191 }),
92 tags: Vec::new(),
9293 };
9394 editor.finish_recording(&mut engine, file).unwrap();
9495 assert_eq!(editor.recording(), None);
......@@ -161,6 +162,7 @@ fn a_recording_from_the_title_goes_to_the_body_s_start() {
161162 kind: 1,
162163 duration_ms: None,
163164 }),
165 tags: Vec::new(),
164166 };
165167 editor.finish_recording(&mut engine, file).unwrap();
166168 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> {
448448 round: bool,
449449 color: [f32; 4],
450450 },
451 /// A round 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.
451 /// 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. A square
453 /// pen tip (not `round`) squares the ends in a PDF; the screen rounds them either way.
453454 Taper {
454455 from: [f32; 2],
455456 to: [f32; 2],
456457 widths: [f32; 2],
458 round: bool,
457459 color: [f32; 4],
458460 },
459461 /// A highlighter's stroke between two points, `width` units across with square ends:
......@@ -897,6 +899,7 @@ impl Renderer {
897899 to,
898900 widths,
899901 color,
902 ..
900903 } => self.segment(space, *from, *to, *widths, true, *color)?,
901904 Primitive::Highlight {
902905 from,
crates/draw/src/render/pdf.rs+38
......@@ -521,7 +521,45 @@ impl Document {
521521 from,
522522 to,
523523 widths,
524 round: false,
524525 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 ..
525563 } => {
526564 content.save_state();
527565 self.paint(content, used, *color, false);
crates/mobile/Cargo.toml+1-1
......@@ -9,7 +9,7 @@ publish = false
99crate-type = ["staticlib"]
1010
1111[dependencies]
12canvas = { path = "../canvas", features = ["interaction"] }
12canvas = { path = "../canvas", features = ["interaction", "pdf"] }
1313draw = { path = "../draw" }
1414notebook = { path = "../notebook", features = ["smb"] }
1515onestore = { path = "../onestore" }
crates/mobile/include/snowbound.h+8
......@@ -52,6 +52,8 @@ char *sb_section_pages(const Section *section);
5252uint32_t sb_section_poll(const Section *section, uint8_t *status);
5353char *sb_section_new_page(const Section *section, const char *parent, const char *date, const char *time);
5454bool 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);
5557bool sb_section_flush(const Section *section, double seconds);
5658void sb_section_wake(const Section *section);
5759void sb_section_free(Section *section);
......@@ -70,6 +72,8 @@ bool sb_view_block(const View *view, float x, float y, float rect[4]);
7072void sb_view_set_transform(View *view, float zoom, float x, float y);
7173uint8_t sb_view_target(const View *view, float x, float y);
7274void 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);
7377bool sb_view_press(View *view, float x, float y);
7478bool sb_view_drag(View *view, float x, float y);
7579bool sb_view_release(View *view);
......@@ -77,6 +81,8 @@ bool sb_view_undo(View *view, bool redo);
7781bool sb_view_can_undo(const View *view, bool redo);
7882uint64_t sb_view_format(const View *view);
7983bool 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);
8086char *sb_view_correction(const View *view);
8187bool sb_view_correct(View *view, uint8_t command, const char *text);
8288char *sb_view_title(const View *view);
......@@ -92,6 +98,8 @@ char *sb_view_paper(const View *view);
9298bool sb_view_set_paper(View *view, int16_t red, uint8_t green, uint8_t blue, int8_t ruled);
9399bool sb_view_set_art(View *view, const char *name);
94100char *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);
95103int8_t sb_view_date_request(View *view, int64_t *seconds);
96104bool sb_view_change_date(View *view, int64_t seconds, const char *date, const char *time);
97105bool 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 {
122122}
123123
124124impl 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
125145 fn new(space: ExGuid, page: Page, pixels: [u32; 2], scale: f32) -> Result<Self> {
126146 let mut engine = ENGINES
127147 .lock()
......@@ -139,6 +159,7 @@ impl Canvas {
139159 );
140160 page.touch = true;
141161 page.host_viewport = true;
162 (page.snap_to_grid, page.editor.default_font) = options();
142163 Ok(Self {
143164 page,
144165 space,
......@@ -235,7 +256,7 @@ impl Canvas {
235256 return Target::Grip;
236257 }
237258 match self.page.hit(device) {
238 Some(Hit::Handle { .. } | Hit::Resize { .. }) => Target::Grip,
259 Some(Hit::Handle { .. } | Hit::Resize { .. } | Hit::Column { .. }) => Target::Grip,
239260 Some(Hit::Image { id, .. })
240261 if self.page.object_focus() == Some(ObjectFocus::Image(id)) =>
241262 {
......@@ -612,6 +633,40 @@ fn gpu() -> std::sync::MutexGuard<'static, Option<Gpu>> {
612633/// rather than enumerating the system's fonts again.
613634static 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
615670pub struct View {
616671 /// Taken apart on drop, which returns its text engine to `ENGINES`.
617672 canvas: ManuallyDrop<Canvas>,
......@@ -668,9 +723,24 @@ impl View {
668723 .get_default_config(&gpu.adapter, pixels[0], pixels[1])
669724 .ok_or("No supported surface configuration")?;
670725 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 }
672743 let frame = Arc::<Frame>::default();
673 #[cfg_attr(not(target_os = "ios"), expect(unused_mut))]
674744 let mut canvas = Canvas::new(space, page, pixels, scale)?;
675745 #[cfg(target_os = "ios")]
676746 {
......@@ -679,6 +749,7 @@ impl View {
679749 Waker::from(frame.clone()),
680750 ));
681751 }
752 canvas.page.editor.styles = sheet;
682753 Ok(Self {
683754 canvas: ManuallyDrop::new(canvas),
684755 surface,
......@@ -967,6 +1038,37 @@ pub extern "C" fn sb_view_apply(view: &mut View, command: u8) -> bool {
9671038 view.stored(result)
9681039}
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
9701072/// The marked word at the caret, or selected, as JSON `{"word", "suggestions",
9711073/// "repeated"}`; null where there is none.
9721074#[unsafe(no_mangle)]
......@@ -1241,6 +1343,33 @@ pub extern "C" fn sb_view_page_text(view: &View) -> *mut c_char {
12411343 owned(view.canvas.page_text())
12421344}
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
12441373/// Puts the picture file `bytes` (JPEG or PNG), `width` × `height` points, at the caret:
12451374/// splitting its paragraph, or on the page at a caret on blank page.
12461375///
crates/mobile/src/library.rs+127-4
......@@ -706,12 +706,62 @@ pub(crate) fn filetime() -> u64 {
706706}
707707
708708/// 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.
710710fn 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)?)
712715}
713716
714717impl 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
715765 /// The section at catalog `path` of `library`, its edits naming `author`.
716766 pub(crate) fn open(
717767 library: Arc<Library>,
......@@ -862,8 +912,7 @@ impl Section {
862912 /// nothing waits to be published.
863913 pub(crate) fn flush(&self, limit: Duration) -> bool {
864914 let section = &self.shared.section;
865 // The section thread answers in order, so an answer means earlier edits are stored.
866 if report(section.pages().map_err(Into::into)).is_none() {
915 if report(section.written().map_err(Into::into)).is_none() {
867916 return false;
868917 }
869918 let deadline = Instant::now() + limit;
......@@ -1598,6 +1647,80 @@ pub unsafe extern "C" fn sb_section_new_page(
15981647 report(page).map_or(std::ptr::null_mut(), |space| owned(space.to_string()))
15991648}
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
16011724/// Deletes page `id` to the notebook's recycle bin, or a conflict page for good; a section
16021725/// left without pages gets one titled `date` and `time`. Writes to the notebook, so call it
16031726/// 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() {
10881088 std::fs::remove_dir_all(&root).unwrap();
10891089 assert!(eventually(|| replicas(&directory) == 0));
10901090}
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
5656a replica named by the file's location and the section's document identity, so the same
5757file reopens the same queue after a relaunch and a copy of it elsewhere has its own
5858(`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.
5962A section is `Send + Sync`: `apply(author, edit)` returns at once and reports a refusal as `Event::Rejected`; `events()` drains
6063remote changes (`Changed(spaces)`), publication outcomes, unreachable files and
6164failures; `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
294297operations, which touch only a TOC entry OneNote gave the copy's name. Of several groups
295298with one TOC identity, the one its parent's TOC names, else the newest, lists; the others
296299list 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.
298302Retain the last accepted catalog if discovery fails; a connection failure does not mean
299303files were deleted.
300304
......@@ -307,16 +311,18 @@ directly from the core document model.
307311## Notebook structure
308312
309313`Notebook::create`, `create_section`, `create_group`, `rename`, `move_entry`,
310`set_section_color`, `reorder` and `delete` change a notebook the way OneNote does: the
314`set_section_color`, `set_color`, `reorder` and `delete` change a notebook the way OneNote does: the
311315table of contents (`Open Notebook.onetoc2`, created when a folder has none) gains,
312316renames, reorders or loses entries; a new notebook holds "New Section 1", and a new
313317section 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 deleted
318new-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
315320section moves into `OneNote_RecycleBin`, a group with its own TOC, and a deleted group's
316321sections move there too before its folders go. `recycle_pages` keeps copies of pages a
317322section is about to delete in `OneNote_RecycleBin/OneNote_DeletedPages.one`, with their
318323identities, titles, dates and creation times, as OneNote does
319(`corpus/notebook-management`). A bin or deleted-pages file its TOC does not list is
324(`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
320326placed and listed again, a bin whose TOC OneNote named otherwise keeps it, and an
321327unavailable bin refuses the delete (`corpus/recycle-bin-repair`). An entry a TOC still
322328lists for a file gone from its folder gives way to the file an edit gives its name.
......@@ -368,14 +374,16 @@ notebook.
368374`sidecar` keeps Snowbound-only data in the notebook's `.snowbound` folder, which
369375OneNote 2010 skips because it has the Windows hidden attribute: `Storage::hide` sets it
370376through `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 passes
372to the share; Linux keeps no such attribute. Discovery skips dot names, and a notebook
377dot name hidden without storing it), `SetFileAttributesW` on Windows, or macOS's
378`UF_HIDDEN`, which an smbfs mount passes to the share; Linux keeps no such attribute. Discovery skips dot names, and a notebook
373379without the folder is whole.
374380
375381```text
376382.snowbound/
377383├ 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 rewritten
384├ 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
379387```
380388
381389`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,
387395over entries that name no picture, and `tag_art_file` returns a picture once its bytes
388396match 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
390409## SMB (feature `smb`)
391410
392411`notebook::smb` provides blocking SMB access for OneNote sections and
crates/notebook/src/discover.rs+24-14
......@@ -93,6 +93,8 @@ pub struct Toc {
9393 pub file_id: [u8; 16],
9494 /// Stale or unavailable TOC references; these are not inferred active sections.
9595 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>,
9698}
9799
98100#[derive(Debug, Clone, Serialize, Deserialize)]
......@@ -108,8 +110,8 @@ pub enum EntryKind {
108110 File,
109111 Directory,
110112 Other,
111 /// A notebook file kept elsewhere and not yet on this device, as iOS lists one that
112 /// iCloud Drive evicted.
113 /// A notebook file kept elsewhere and not yet on this device, as iCloud Drive's evicted
114 /// files list: a `.Name.icloud` placeholder, or on macOS 14 and later a dataless file.
113115 Evicted,
114116}
115117
......@@ -164,7 +166,10 @@ enum Held {
164166 document: [u8; 16],
165167 },
166168 Locked,
167 Toc(Vec<TocReference>),
169 Toc {
170 unresolved: Vec<TocReference>,
171 color: Option<u32>,
172 },
168173}
169174
170175impl Cache {
......@@ -380,9 +385,14 @@ fn scan(
380385 } else {
381386 continue;
382387 };
383 // Without an evicted TOC the folder lists in path order; nothing writes a second one,
384 // as its placeholder blocks creating it.
385 if entry.kind == EntryKind::Evicted {
388 let reused = cached
389 .get(&child)
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() {
386396 if expected == FileType::Section {
387397 result.unavailable.push(Unavailable {
388398 path: child,
......@@ -393,15 +403,11 @@ fn scan(
393403 }
394404 continue;
395405 }
396 if entry.kind != EntryKind::File
406 if !matches!(entry.kind, EntryKind::File | EntryKind::Evicted)
397407 || (expected == FileType::TableOfContents && result.toc.is_some())
398408 {
399409 return Err(Error::Entry { path: child });
400410 }
401 let reused = cached
402 .get(&child)
403 .filter(|known| known.listed == entry.listed)
404 .and_then(|known| Some((known.file_id()?, known)));
405411 let (file_id, held) = match reused {
406412 Some((file_id, known)) => {
407413 found.read.insert(child.clone(), known.clone());
......@@ -522,11 +528,12 @@ fn scan(
522528 state: SectionState::Locked,
523529 copy: false,
524530 }),
525 Ok(Held::Toc(unresolved)) => {
531 Ok(Held::Toc { unresolved, color }) => {
526532 result.toc = Some(Toc {
527533 filename: entry.name.clone(),
528534 file_id,
529535 unresolved,
536 color,
530537 })
531538 }
532539 Err(error) if expected == FileType::Section => result.sections.push(Section {
......@@ -751,7 +758,7 @@ fn held(store: &Store, expected: FileType) -> Result<Held, onestore::Error> {
751758 });
752759 }
753760 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 {
755762 return Err(onestore::Error {
756763 offset: 0,
757764 message: "Missing notebook TOC root",
......@@ -784,7 +791,10 @@ fn held(store: &Store, expected: FileType) -> Result<Held, onestore::Error> {
784791 order: *order,
785792 });
786793 }
787 Ok(Held::Toc(unresolved))
794 Ok(Held::Toc {
795 unresolved,
796 color: *color,
797 })
788798}
789799
790800/// 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> {
1212 (lower.ends_with(".one") || lower.ends_with(".onetoc2")).then_some(real)
1313}
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
1531/// Where iOS lists the file at `path` while iCloud Drive keeps it elsewhere.
1632pub(crate) fn placeholder(path: &Path) -> Option<PathBuf> {
1733 let name = path.file_name()?.to_str()?;
......@@ -62,6 +78,8 @@ impl Source for Local {
6278 name,
6379 kind: if kind.is_dir() {
6480 EntryKind::Directory
81 } else if kind.is_file() && dataless(&metadata) {
82 EntryKind::Evicted
6583 } else if kind.is_file() {
6684 EntryKind::File
6785 } else {
crates/notebook/src/lib.rs+7-1
......@@ -313,10 +313,16 @@ impl Replica {
313313 /// The section image the queued edits leave, the unsealed ones sealed as one more
314314 /// revision whose identities differ per call: O(section).
315315 pub(crate) fn snapshot(&self) -> Result<Vec<u8>> {
316 self.ask(|reply| working::Request::Flush { reply })?;
316 self.written()?;
317317 working::image(&*self.lock()?)
318318 }
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
320326 /// The section file's identity, which internal links name as `section-id`.
321327 pub fn identity(&self) -> Result<[u8; 16]> {
322328 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> {
153153 | PageOp::Equation { text, .. }
154154 | PageOp::Split { text, .. } => vec![*text],
155155 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(),
157157 PageOp::Join { left, right } => vec![*left, *right],
158158 PageOp::Insert { container, .. } => vec![*container],
159159 PageOp::Move { object, .. }
......@@ -164,6 +164,7 @@ fn named(op: &PageOp) -> Vec<ExGuid> {
164164 PageOp::Level { paragraph, .. }
165165 | PageOp::Paragraph { paragraph, .. }
166166 | PageOp::Style { paragraph, .. }
167 | PageOp::Unstyle { paragraph }
167168 | PageOp::Media { paragraph, .. }
168169 | PageOp::List { paragraph, .. } => vec![*paragraph],
169170 PageOp::Tags { target, .. } => vec![*target],
......@@ -770,11 +771,19 @@ impl Diff {
770771 }
771772 PageOp::Paragraph { paragraph, .. }
772773 | PageOp::Style { paragraph, .. }
774 | PageOp::Unstyle { paragraph }
773775 | PageOp::Media { paragraph, .. }
774776 | PageOp::List { paragraph, .. } => {
775777 require(self.kept(*paragraph) && self.new_has(*paragraph))?
776778 }
777779 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 }
778787 PageOp::Picture {
779788 picture: object, ..
780789 }
......@@ -1079,6 +1088,8 @@ impl Node {
10791088struct Index {
10801089 nodes: BTreeMap<ExGuid, Node>,
10811090 page: Vec<ExGuid>,
1091 /// The paragraph styles the page's paragraphs use.
1092 styles: BTreeSet<ExGuid>,
10821093}
10831094
10841095const PAGE: ExGuid = ExGuid {
......@@ -1088,7 +1099,17 @@ const PAGE: ExGuid = ExGuid {
10881099
10891100impl Index {
10901101 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 };
10921113 for object in &page.objects {
10931114 index.page.push(object.id());
10941115 match object {
crates/notebook/src/queue.rs+3
......@@ -282,6 +282,9 @@ mod tests {
282282 alt: None,
283283 background: false,
284284 printout: None,
285 tags: Vec::new(),
286 link: None,
287 text: None,
285288 })
286289 };
287290 let space = ExGuid {
crates/notebook/src/session.rs+134
......@@ -332,6 +332,8 @@ const LIMITS: discover::Limits = discover::Limits {
332332
333333const TOC: &str = "Open Notebook.onetoc2";
334334const 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
336338/// A notebook's files and the cache directory holding its section replicas.
337339pub struct Notebook {
......@@ -488,6 +490,20 @@ impl Notebook {
488490 crate::sidecar::map(&*self.storage, name, shape, bytes, extension)
489491 }
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
491507 /// Rereads the notebook and reports what changed since the last catalog, keyed by
492508 /// file identity so a renamed or moved section stays the same section. A failed read
493509 /// keeps the previous catalog: an unreachable notebook is not an empty one.
......@@ -742,6 +758,13 @@ impl Notebook {
742758 Ok(())
743759 }
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
745768 /// Orders a folder's sections and groups; entries left out follow in their current order.
746769 pub fn reorder(&mut self, folder: &str, paths: &[&str]) -> Result<()> {
747770 let mut identities = Vec::new();
......@@ -932,6 +955,102 @@ impl Notebook {
932955 self.refresh().map(drop)
933956 }
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
9351054 /// Moves a section or section group into the folder at catalog path `folder`, last, as
9361055 /// OneNote moves one dragged onto a group: the file or folder moves and each TOC's
9371056 /// entry follows it. Returns its new catalog path.
......@@ -1222,6 +1341,16 @@ impl Notebook {
12221341 if !file.starts_with(root) {
12231342 return Err(io::Error::from(io::ErrorKind::PermissionDenied).into());
12241343 }
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 }
12251354 let replica = |identity: &[u8; 16], _: &[u8]| {
12261355 std::fs::create_dir_all(&replicas)?;
12271356 Ok(replica_file(&replicas, identity))
......@@ -1722,6 +1851,11 @@ impl Section {
17221851 self.replica.pending()
17231852 }
17241853
1854 /// See [`Replica::written`].
1855 pub fn written(&self) -> Result<()> {
1856 self.replica.written()
1857 }
1858
17251859 /// The conflict pages of each page that has them, as the local edits leave them
17261860 /// (`onestore::Section::conflicts`); `page` reads one, `delete_pages` removes it.
17271861 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 {
162162 format!("{path}.{unique:016x}.tmp")
163163}
164164
165pub mod themes;
166
165167#[cfg(test)]
166168mod tests;
crates/notebook/src/sidecar/tests.rs+43-2
......@@ -265,8 +265,8 @@ fn unreadable_mappings_and_pictures_are_passed_over() {
265265 assert!(map(&folder, "Tag", 13, b"x", "gif").is_err());
266266}
267267
268/// On this computer the folder takes the attribute macOS keeps, and the notebook's catalog
269/// never lists it.
268/// On this computer the folder takes the hidden attribute Windows or macOS keeps, and the
269/// notebook's catalog never lists it.
270270#[test]
271271fn a_notebook_folder_keeps_the_art_hidden() {
272272 let temporary = tempfile::tempdir().unwrap();
......@@ -288,7 +288,48 @@ fn a_notebook_folder_keeps_the_art_hidden() {
288288 let flags = FileFlag::from_bits_retain(stat(&folder).unwrap().st_flags);
289289 assert!(flags.contains(FileFlag::UF_HIDDEN));
290290 }
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 }
291297 notebook.refresh().unwrap();
292298 assert!(notebook.catalog().groups.is_empty());
293299 assert_eq!(notebook.catalog().sections.len(), 1);
294300}
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() {
197197 assert!(!root.join("Open Notebook.onetoc2").exists());
198198}
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
200240#[test]
201241fn locked_and_unreadable_sections_retain_their_storage_identity() {
202242 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
336336 assert_eq!(names.len(), 2, "{names:?}");
337337 }
338338}
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) {
634634 bytes: Some(std::sync::Arc::from(&b"Floating bytes"[..])),
635635 preview: None,
636636 recording: None,
637 tags: Vec::new(),
637638 };
638639 let image = remote_with(source, space, |page| {
639640 page.objects.push(PageObject::Attachment(file))
crates/onestore/README.md+1
......@@ -56,6 +56,7 @@ harness also accepts `--client-profile release`.
5656| `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 |
5757| `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 |
5858| `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 |
5960| `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 |
6061| `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 |
6162| `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> {
527527 },
528528 Style {
529529 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>,
530533 },
531534 List {
532535 font: Option<String>,
......@@ -1279,6 +1282,7 @@ impl<'a> Element<'a> {
12791282 }
12801283 0x12004d => Kind::Style {
12811284 name: f.text(0x1c00345a)?,
1285 next: f.text(0x1c00348a)?,
12821286 },
12831287 0x60012 => {
12841288 let format = f
crates/onestore/src/formatting.rs+25-2
......@@ -108,6 +108,28 @@ pub(crate) fn format_changes(
108108 range: Range<u32>,
109109 attributes: &[TextAttribute],
110110 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,
111133) -> Result<Changes, Error> {
112134 let cleared: Vec<u32> = cleared.iter().map(|property| property.id()).collect();
113135 if (attributes.is_empty() && cleared.is_empty()) || range.start > range.end {
......@@ -163,8 +185,9 @@ pub(crate) fn format_changes(
163185 };
164186 if selected {
165187 let format = &resolved[i].format;
166 if [format.math, format.embedded_object].contains(&Some(true))
167 || resolved[i].text.contains('\u{fffc}')
188 if !fields
189 && ([format.math, format.embedded_object].contains(&Some(true))
190 || resolved[i].text.contains('\u{fffc}'))
168191 {
169192 return Err(invalid(
170193 "This format range contains a field or embedded data",
crates/onestore/src/op/apply.rs+112-5
......@@ -10,8 +10,8 @@ use super::{
1010 table::{self, Structure},
1111};
1212use crate::{
13 Error, ExGuid, Insertion, OutlineEdit, ParagraphJoin, ParagraphSplit, Section, TextAttribute,
14 TreeEdit,
13 Error, ExGuid, Insertion, ObjectData, OutlineEdit, ParagraphJoin, ParagraphSplit, Section,
14 TextAttribute, TreeEdit,
1515 active::{ActivePage, Changes},
1616 document::Kind,
1717 page::{
......@@ -517,6 +517,16 @@ impl<'a> Writer<'_, 'a> {
517517 }
518518
519519 /// 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
520530 fn target(&mut self, id: ExGuid) -> Result<(), Failure> {
521531 let page = self.page()?;
522532 if page.live.is_reachable(id) && page.view.nodes.contains_key(&id) {
......@@ -786,7 +796,89 @@ impl<'a> Writer<'_, 'a> {
786796 } => {
787797 self.target(*paragraph)?;
788798 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 })
790882 }
791883 PageOp::Media { paragraph, media } => {
792884 self.target(*paragraph)?;
......@@ -1179,10 +1271,22 @@ impl<'a> Writer<'_, 'a> {
11791271 let Some(bytes) = &image.bytes else {
11801272 return Err(OpError::Unsupported("A new picture needs its payload").into());
11811273 };
1274 if !image.tags.is_empty() {
1275 return Err(OpError::Unsupported("Set a picture's tags with their own op").into());
1276 }
11821277 let (file, payload) = (fresh()?, crate::write::fresh_guid()?);
11831278 let bytes: &[u8] = bytes;
1184 self.write_with(&[(payload, bytes)], |page| {
1185 content::picture_changes(page, image, image.id, file, payload, holder)
1279 let mut payloads = vec![(payload, bytes)];
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)
11861290 })
11871291 }
11881292
......@@ -1194,6 +1298,9 @@ impl<'a> Writer<'_, 'a> {
11941298 let Some(bytes) = &attachment.bytes else {
11951299 return Err(OpError::Unsupported("A new attachment needs its payload").into());
11961300 };
1301 if !attachment.tags.is_empty() {
1302 return Err(OpError::Unsupported("Set a file's tags with their own op").into());
1303 }
11971304 let ids = AttachmentIds {
11981305 object: attachment.id,
11991306 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<(),
132132 || stored.bytes != image.bytes
133133 || stored.size != image.size
134134 || stored.background != image.background
135 || stored.link != image.link
136 || stored.text != image.text
135137 {
136138 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",
138140 ));
139141 }
140142 Ok(())
......@@ -201,13 +203,14 @@ fn icon_size(
201203
202204/// What OneNote stores for an inserted picture: a file-data object declaring payload
203205/// `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.
205209pub(crate) fn picture_changes(
206210 active: &ActivePage<'_>,
207211 image: &Image,
208 id: ExGuid,
209 file: ExGuid,
210 payload: [u8; 16],
212 (id, file, payload): (ExGuid, ExGuid, [u8; 16]),
213 shown: Option<(ExGuid, [u8; 16])>,
211214 holder: Option<ExGuid>,
212215) -> Result<Changes, Error> {
213216 let Some(bytes) = &image.bytes else {
......@@ -220,8 +223,19 @@ pub(crate) fn picture_changes(
220223 [b'B', b'M', ..] => ".bmp",
221224 // OneNote keeps an imported TIFF as it is (`corpus/m6/native-features-01`).
222225 [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",
223228 _ => return Err(invalid("Choose a PNG, JPEG, GIF, BMP or TIFF picture")),
224229 };
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 }
225239 let modified = crate::create::current_timestamps()?.0.to_le_bytes();
226240 let mut values: Values = vec![(0x14001d7a, modified.to_vec())];
227241 if let Some([width, height]) = image.size {
......@@ -235,6 +249,18 @@ pub(crate) fn picture_changes(
235249 if let Some(alt) = &image.alt {
236250 values.push((0x1c001e58, crate::create::string(alt)));
237251 }
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 }
238264 if image.background {
239265 values.push((0x08001d13 | (1 << 31), Vec::new()));
240266 }
......@@ -244,14 +270,21 @@ pub(crate) fn picture_changes(
244270 0x08001d85 | (u32::from(image.printout.is_some()) << 31),
245271 Vec::new(),
246272 ));
247 if let Some(page) = image.printout {
248 values.push((0x14001df9, page.to_le_bytes().to_vec()));
273 if let Some(printout) = &image.printout {
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 }
249281 }
250282 let mut changed = BTreeMap::new();
251 changed.insert(
252 file,
253 PropertyObject::file(file, &payload_reference(payload), extension)?,
254 );
283 let mut container = PropertyObject::file(file, &payload_reference(payload), extension)?;
284 if extension == ".xps" {
285 container.jcid = crate::write::PRINTOUT_FILE_JCID;
286 }
287 changed.insert(file, container);
255288 let mut picture = PropertyObject {
256289 jcid: 0x60011,
257290 bytes: crate::create::properties(&values)?,
......@@ -260,6 +293,14 @@ pub(crate) fn picture_changes(
260293 picture.reference(id)?;
261294 let container = picture.reference(file)?;
262295 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 }
263304 changed.insert(id, picture);
264305 hold(active, &mut changed, id, holder, &modified)?;
265306 Ok(changed)
crates/onestore/src/op/lower.rs+71-30
......@@ -316,7 +316,9 @@ pub(crate) fn validate(
316316 Some(None) => paragraph.style,
317317 None => previous.style,
318318 };
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 {
320322 return Err(invalid(
321323 "Paragraph styles and paragraph formatting cannot be edited",
322324 ));
......@@ -743,6 +745,8 @@ fn bare(paragraph: &PageParagraph) -> PageParagraph {
743745 stripped.media = Default::default();
744746 match &mut stripped.content {
745747 ParagraphContent::Text(text) => text.tags.clear(),
748 ParagraphContent::Image(image) => image.tags.clear(),
749 ParagraphContent::Attachment(file) => file.tags.clear(),
746750 ParagraphContent::Table(table) => {
747751 table.tags.clear();
748752 for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) {
......@@ -991,16 +995,18 @@ impl Lowering {
991995 };
992996 let stored = pictures(old.page);
993997 for (id, image) in pictures(new.page) {
994 if let Some(previous) = stored.get(&id)
995 && *previous != image
996 {
997 super::content::picture_fixed_fields(previous, &image)?;
998 let Some(previous) = stored.get(&id) else {
999 continue;
1000 };
1001 super::content::picture_fixed_fields(previous, &image)?;
1002 if (&previous.layout, &previous.alt) != (&image.layout, &image.alt) {
9981003 self.emit(PageOp::Picture {
9991004 picture: id,
10001005 layout: image.layout.clone(),
10011006 alt: image.alt.clone(),
10021007 })?;
10031008 }
1009 self.set_tags(new.page, id, &image.tags, &previous.tags)?;
10041010 }
10051011 let files = |page: &Page| -> BTreeMap<ExGuid, crate::page::Attachment> {
10061012 page.objects
......@@ -1049,6 +1055,7 @@ impl Lowering {
10491055 size: file.size,
10501056 })?;
10511057 }
1058 self.set_tags(new.page, id, &file.tags, &previous.tags)?;
10521059 }
10531060 let inks = |page: &Page| -> BTreeMap<ExGuid, crate::page::Ink> {
10541061 page.objects
......@@ -1098,7 +1105,7 @@ impl Lowering {
10981105 if new.title_outlines.contains(&id) {
10991106 return Err(invalid("Title outlines cannot be added"));
11001107 }
1101 let object = match object {
1108 let added = match object {
11021109 PageObject::Outline(outline) => {
11031110 if outline
11041111 .paragraphs
......@@ -1115,9 +1122,17 @@ impl Lowering {
11151122 ..(*outline).clone()
11161123 })
11171124 }
1118 PageObject::Image(_) | PageObject::Attachment(_) | PageObject::Ink(_) => {
1119 (*object).clone()
1125 PageObject::Image(image) => PageObject::Image(crate::page::Image {
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 })
11201134 }
1135 PageObject::Ink(_) => (*object).clone(),
11211136 PageObject::Title(_) | PageObject::Unsupported(_) => {
11221137 return Err(invalid(
11231138 "Titles and unsupported objects cannot be edited through the page model",
......@@ -1125,9 +1140,15 @@ impl Lowering {
11251140 }
11261141 };
11271142 self.emit(PageOp::Add {
1128 object,
1143 object: added,
11291144 before: next,
11301145 })?;
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, &[])?;
11311152 } else if !kept.contains(&id) {
11321153 self.emit(PageOp::Move {
11331154 object: id,
......@@ -1694,11 +1715,14 @@ impl Lowering {
16941715
16951716 fn styles(&mut self, after: &Page, new: &View<'_>) -> Result<(), Error> {
16961717 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"))?;
16971720 let Some(style) = paragraph.style else {
1721 if current.style.is_some() {
1722 self.emit(PageOp::Unstyle { paragraph: *id })?;
1723 }
16981724 continue;
16991725 };
1700 let current = model::paragraph(&self.current, *id)
1701 .ok_or_else(|| invalid("A paragraph is missing after text edits"))?;
17021726 if current.style == Some(style) {
17031727 continue;
17041728 }
......@@ -1771,33 +1795,50 @@ impl Lowering {
17711795 (ParagraphContent::Table(table), ParagraphContent::Table(stored)) => {
17721796 targets.push((table.id, &table.tags, stored.tags.clone()))
17731797 }
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 }
17741804 _ => {}
17751805 }
17761806 for (target, tags, stored) in targets {
1777 if same_tags(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 })?;
1807 self.set_tags(after, target, tags, &stored)?;
17961808 }
17971809 }
17981810 Ok(())
17991811 }
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
18011842 fn media(&mut self, new: &View<'_>) -> Result<(), Error> {
18021843 for (id, paragraph) in &new.paragraphs {
18031844 let current = model::paragraph(&self.current, *id)
crates/onestore/src/op/mod.rs+52
......@@ -151,6 +151,20 @@ pub enum PageOp {
151151 style: ExGuid,
152152 definition: Definition,
153153 },
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 },
154168 /// Links a paragraph's text or file to a moment in recordings on the page, or with
155169 /// empty `media` unlinks it.
156170 Media {
......@@ -270,6 +284,44 @@ pub enum SectionOp {
270284 },
271285}
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
273325impl SectionOp {
274326 /// `RestoreVersion` of `page`'s `version`, its new objects under a fresh identity.
275327 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> {
614614 let tags = paragraph.tags.iter().chain(match &paragraph.content {
615615 ParagraphContent::Text(text) => text.tags.as_slice(),
616616 ParagraphContent::Table(table) => table.tags.as_slice(),
617 ParagraphContent::Image(image) => image.tags.as_slice(),
618 ParagraphContent::Attachment(file) => file.tags.as_slice(),
617619 _ => &[],
618620 });
619621 referenced.extend(tags.filter_map(|tag| tag.definition));
620622 }
621623 }
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 }
622632 page.definitions.retain(|id, _| referenced.contains(id));
623633 Ok(())
624634}
......@@ -1000,54 +1010,59 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> {
10001010 let paragraph = paragraph_mut(page, *paragraph)?;
10011011 paragraph.style = Some(style);
10021012 if let Some(text) = paragraph.text_mut() {
1003 let mut previous = 0;
1004 let runs: Vec<(String, Format)> = text
1005 .text
1006 .spans()
1007 .iter()
1008 .map(|span| {
1009 // What the old style gave, the new one gives instead.
1010 let mut format = span.format.clone();
1011 macro_rules! restyle {
1012 ($($field:ident),*) => {$(
1013 if old.$field.is_some() && format.$field == old.$field {
1014 format.$field = new.$field.clone();
1015 } else if format.$field.is_none() {
1016 format.$field = new.$field.clone();
1017 }
1018 )*};
1013 text.text = restyled(&text.text, &old, &new);
1014 }
1015 }
1016 PageOp::Unstyle { paragraph: id } => {
1017 let Some(style) = paragraph_mut(page, *id)?.style.take() else {
1018 return Ok(());
1019 };
1020 let old = page
1021 .definitions
1022 .get(&style)
1023 .ok_or_else(|| invalid("The page has no such paragraph style"))?
1024 .format
1025 .clone();
1026 if let Some(text) = paragraph_mut(page, *id)?.text_mut() {
1027 text.text = restyled(&text.text, &old, &Format::default());
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);
10191063 }
1020 restyle!(
1021 bold,
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);
1064 }
1065 }
10511066 }
10521067 }
10531068 PageOp::Media { paragraph, media } => {
......@@ -1088,8 +1103,12 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> {
10881103 paragraph.tags = tags;
10891104 } else if let Ok(text) = text_mut(page, *target) {
10901105 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;
10911110 } else {
1092 table_mut(page, *target)?.tags = tags;
1111 attachment_mut(page, *target)?.tags = tags;
10931112 }
10941113 }
10951114 PageOp::Add { object, before } => {
......@@ -1271,6 +1290,52 @@ fn drop_emptied(page: &mut Page) {
12711290
12721291/// The identity a read-only definition is stored under: an equal one the page holds, as
12731292/// 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
12741339fn define(page: &mut Page, id: ExGuid, definition: &crate::page::Definition) -> ExGuid {
12751340 if page.definitions.contains_key(&id) {
12761341 return id;
crates/onestore/src/op/properties.rs+166-21
......@@ -2,7 +2,7 @@
22
33use super::Values;
44use crate::{
5 Error, ExGuid, PropertySets, Value,
5 Error, ExGuid, ObjectData, PropertySets, Value,
66 active::{ActivePage, Changes},
77 document::{Kind, Tag},
88 page::{Definition, MediaIndex},
......@@ -293,26 +293,7 @@ pub(crate) fn style_changes(
293293 if !raw.objects.contains_key(&style) {
294294 let definition = definition
295295 .ok_or_else(|| invalid("A paragraph references a missing style definition"))?;
296 let Kind::Style { name } = &definition.kind else {
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 }
296 let values = style_object_values(definition)?;
316297 let mut node = PropertyObject {
317298 jcid: 0x12004d,
318299 bytes: crate::create::properties(&values)?,
......@@ -328,6 +309,170 @@ pub(crate) fn style_changes(
328309 Ok(changed)
329310}
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
331476/// The text-object properties of paragraph formatting, in the order they are written.
332477pub(crate) fn paragraph_values(
333478 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 {
631631 };
632632 let style = (family == Family::Style).then(|| {
633633 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")
635635 .then_some(*id)
636636 });
637637 existing.unwrap_or_else(|| {
......@@ -641,6 +641,7 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {
641641 Definition {
642642 kind: Kind::Style {
643643 name: Some("Heading 2".into()),
644 next: None,
644645 },
645646 format: Format {
646647 font: Some("Calibri".into()),
......@@ -1103,6 +1104,9 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {
11031104 alt: Some("dot".into()),
11041105 background: false,
11051106 printout: None,
1107 tags: Vec::new(),
1108 link: None,
1109 text: None,
11061110 }),
11071111 Family::Attachment => ParagraphContent::Attachment(Attachment {
11081112 id: id(),
......@@ -1113,6 +1117,7 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {
11131117 bytes: Some(b"attached bytes".as_slice().into()),
11141118 preview: None,
11151119 recording: None,
1120 tags: Vec::new(),
11161121 }),
11171122 _ => {
11181123 let Some(equation) = equation else {
......@@ -1158,6 +1163,9 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool {
11581163 alt: None,
11591164 background: false,
11601165 printout: None,
1166 tags: Vec::new(),
1167 link: None,
1168 text: None,
11611169 })
11621170 } else {
11631171 PageObject::Ink(Ink {
......@@ -1816,6 +1824,7 @@ fn random_object_op(page: &Page, rng: &mut Rng) -> Option<PageOp> {
18161824 definition: Definition {
18171825 kind: Kind::Style {
18181826 name: Some("Heading 3".into()),
1827 next: None,
18191828 },
18201829 format: Format {
18211830 bold: Some(true),
......@@ -3122,3 +3131,192 @@ fn deleted_objects_added_back_store_as_they_were() {
31223131 assert_eq!(shape(&mut section, object), stored, "{title}");
31233132 }
31243133}
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 {
285285 /// Set when the file is audio or video OneNote plays; notes on the page refer to it by
286286 /// identity (`PageParagraph::media`).
287287 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>,
288292}
289293
290294/// A recording's identity, whether it is audio (1) or video (2), and its length.
......@@ -324,6 +328,7 @@ impl PartialEq for Attachment {
324328 && self.size == other.size
325329 && self.layout == other.layout
326330 && self.recording == other.recording
331 && self.tags == other.tags
327332 }
328333}
329334
......@@ -376,6 +381,7 @@ impl Attachment {
376381 .unwrap_or(1),
377382 duration_ms: *recording_duration,
378383 }),
384 tags: node.tags.clone(),
379385 })
380386 }
381387}
......@@ -435,12 +441,52 @@ pub struct Image {
435441 pub display: Option<Arc<[u8]>>,
436442 pub alt: Option<String>,
437443 pub background: bool,
438 /// The page of a file printout the picture shows, zero-based (DisplayedPageNumber);
439 /// OneNote frames printout pages.
444 /// Set when the picture is a page of a file printout, which OneNote frames.
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.
440456 #[serde(default)]
441 pub printout: Option<u32>,
457 pub text: Option<Recognized>,
442458}
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
444490impl PartialEq for Image {
445491 fn eq(&self, other: &Self) -> bool {
446492 self.id == other.id
......@@ -449,6 +495,9 @@ impl PartialEq for Image {
449495 && self.alt == other.alt
450496 && self.background == other.background
451497 && self.printout == other.printout
498 && self.tags == other.tags
499 && self.link == other.link
500 && self.text == other.text
452501 }
453502}
454503
......@@ -466,6 +515,8 @@ impl Image {
466515 printout,
467516 picture_width,
468517 picture_height,
518 link,
519 filename,
469520 ..
470521 } = &node.kind
471522 else {
......@@ -498,8 +549,8 @@ impl Image {
498549 display: payload(web)?,
499550 alt: alt.clone(),
500551 background: background.unwrap_or(false),
501 printout: printout.unwrap_or(false).then(|| {
502 fields
552 printout: printout.unwrap_or(false).then(|| Printout {
553 page: fields
503554 .iter()
504555 .find_map(|field| match field.value {
505556 FieldValue::Bytes(&[a, b, c, d]) if field.id == 0x14001df9 => {
......@@ -507,8 +558,50 @@ impl Image {
507558 }
508559 _ => None,
509560 })
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(),
511572 }),
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 }),
512605 })
513606 }
514607}
......@@ -870,14 +963,14 @@ impl Page {
870963 page.objects.push(PageObject::Outline(outline));
871964 }
872965 }
873 Kind::Image { .. } => {
874 page.objects
875 .push(PageObject::Image(Image::read(revision, id, node)?));
876 }
877 Kind::Attachment { .. } => {
878 page.objects.push(PageObject::Attachment(Attachment::read(
879 revision, id, node,
880 )?));
966 Kind::Image { .. } | Kind::Attachment { .. } => {
967 for tag in node.tags.iter().filter_map(|tag| tag.definition) {
968 define(revision, tag, false, &mut page.definitions)?;
969 }
970 page.objects.push(match node.kind {
971 Kind::Image { .. } => PageObject::Image(Image::read(revision, id, node)?),
972 _ => PageObject::Attachment(Attachment::read(revision, id, node)?),
973 });
881974 }
882975 Kind::Ink { .. } => {
883976 page.objects
......@@ -990,6 +1083,55 @@ pub(crate) fn text_of(
9901083 })
9911084}
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
9931135fn read_paragraphs(
9941136 revision: &Revision<'_>,
9951137 container: &Element<'_>,
......@@ -1137,67 +1279,28 @@ fn read_paragraphs(
11371279 .chain(match &content {
11381280 ParagraphContent::Text(text) => text.tags.as_slice(),
11391281 ParagraphContent::Table(table) => table.tags.as_slice(),
1140 ParagraphContent::Image(_)
1141 | ParagraphContent::Attachment(_)
1142 | ParagraphContent::Ink(_)
1143 | ParagraphContent::Unsupported(_) => &[],
1282 ParagraphContent::Image(image) => image.tags.as_slice(),
1283 ParagraphContent::Attachment(file) => file.tags.as_slice(),
1284 ParagraphContent::Ink(_) | ParagraphContent::Unsupported(_) => &[],
11441285 })
11451286 .filter_map(|tag| tag.definition.as_ref())
11461287 .map(|id| (id, false)),
11471288 ) {
1148 let definition = revision
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 );
1289 define(revision, *id, is_list, definitions)?;
11901290 }
11911291 if let Some(id) = base_style {
11921292 let definition = revision
11931293 .nodes
11941294 .get(&id)
11951295 .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 {
11971297 return Err(invalid("Canvas paragraph style has the wrong type"));
11981298 };
11991299 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 },
12011304 format: definition.format.clone(),
12021305 });
12031306 }
......@@ -1445,6 +1548,7 @@ mod tests {
14451548 });
14461549 let mut style = element(Kind::Style {
14471550 name: Some("Body".into()),
1551 next: None,
14481552 });
14491553 style.format.font_size = Some(12.0);
14501554 let mut metadata = element(Kind::Metadata {
......@@ -1705,7 +1809,7 @@ mod tests {
17051809 assert_eq!(paragraph.parent, None);
17061810 assert_eq!(paragraph.style, Some(id(5)));
17071811 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"));
17091813 assert_eq!(page.definitions[&id(5)].format.font_size, Some(12.0));
17101814 let text = &paragraph.text().unwrap().text;
17111815 assert_eq!(text.text(), "ab");
crates/onestore/src/pages.rs+34-3
......@@ -506,6 +506,12 @@ pub struct PageCreation {
506506 /// The page identity and creation time (FILETIME) a page keeps from elsewhere.
507507 #[serde(default, skip_serializing_if = "Option::is_none")]
508508 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>,
509515}
510516
511517impl PageCreation {
......@@ -522,6 +528,8 @@ impl PageCreation {
522528 created: current_timestamps()?.0,
523529 date: None,
524530 kept: None,
531 title_font: None,
532 title_color: None,
525533 };
526534 page.validate()?;
527535 Ok(page)
......@@ -538,6 +546,16 @@ impl PageCreation {
538546 Ok(self)
539547 }
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
541559 /// Keeps `identity`, the page identity internal links name, and `created`, as OneNote
542560 /// 2010 keeps both for a page it moves to the notebook's recycle bin.
543561 pub fn keeping(mut self, identity: [u8; 16], created: u64) -> Result<Self, Error> {
......@@ -626,6 +644,10 @@ impl PageCreation {
626644 .iter()
627645 .chain(self.date.iter().flatten())
628646 .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'))
629651 {
630652 return Err(invalid(
631653 "Use a new page identity, an existing page anchor, and ordinary single-line title text",
......@@ -924,11 +946,20 @@ impl PageCreation {
924946 (
925947 18,
926948 0x12004d,
927 vec![
949 [
928950 (0x1c00345a, string("PageTitle")),
929 (0x1c001c0a, string("Calibri")),
951 (
952 0x1c001c0a,
953 string(self.title_font.as_deref().unwrap_or("Calibri")),
954 ),
930955 (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(),
932963 ),
933964 (19, 0x12004d, vec![(0x14001c3b, reference(0x409))]),
934965 ] {
crates/onestore/src/write.rs+7-2
......@@ -419,9 +419,11 @@ pub(crate) fn replacement(id: ExGuid, object: &crate::Object<'_>) -> Result<Prop
419419}
420420
421421/// 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`).
423424pub(crate) const FILE_DATA_JCID: u32 = 0x80039;
424425pub(crate) const EMBEDDED_FILE_JCID: u32 = 0x80036;
426pub(crate) const PRINTOUT_FILE_JCID: u32 = 0x8003a;
425427
426428/// The reference and extension of a file-data declaration built by `PropertyObject::file`.
427429fn file_declaration(bytes: &[u8]) -> Result<(&[u8], &[u8])> {
......@@ -470,7 +472,10 @@ pub(crate) fn declared(
470472}
471473
472474fn is_file_declaration(jcid: u32) -> bool {
473 jcid == FILE_DATA_JCID || jcid == EMBEDDED_FILE_JCID
475 matches!(
476 jcid,
477 FILE_DATA_JCID | EMBEDDED_FILE_JCID | PRINTOUT_FILE_JCID
478 )
474479}
475480
476481#[derive(Clone)]
crates/onestore/tests/page_attachments.rs+3
......@@ -116,6 +116,7 @@ fn insert_attachment(section: &str, preview: Option<Arc<[u8]>>) -> Vec<u8> {
116116 bytes: Some(Arc::from(PAYLOAD)),
117117 preview: preview.clone(),
118118 recording: None,
119 tags: Vec::new(),
119120 });
120121 body_paragraphs(&mut after).push(holder.clone());
121122 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() {
187188 bytes: Some(Arc::from(PAYLOAD)),
188189 preview: None,
189190 recording: None,
191 tags: Vec::new(),
190192 });
191193 body_paragraphs(&mut after).push(holder);
192194 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() {
302304 bytes: Some(Arc::from(PAYLOAD)),
303305 preview: attachments(&native)[0].preview.clone(),
304306 recording: None,
307 tags: Vec::new(),
305308 };
306309 let steps: [&dyn Fn(&mut Page); 4] = [
307310 &|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() {
119119 alt: None,
120120 background: false,
121121 printout: None,
122 tags: Vec::new(),
123 link: None,
124 text: None,
122125 };
123126 holder.content = ParagraphContent::Image(image.clone());
124127 body_paragraphs(&mut after).push(holder.clone());
......@@ -299,6 +302,9 @@ fn a_page_level_picture_is_inserted_moved_and_removed() {
299302 alt: Some("Placed in Rust".into()),
300303 background: false,
301304 printout: None,
305 tags: Vec::new(),
306 link: None,
307 text: None,
302308 };
303309 after.objects.push(PageObject::Image(image.clone()));
304310 let written = ops::saved(&source, space, &after).unwrap();
......@@ -366,6 +372,9 @@ fn pictures_need_a_recognised_payload_and_stored_ones_stay_fixed() {
366372 alt: None,
367373 background: false,
368374 printout: None,
375 tags: Vec::new(),
376 link: None,
377 text: None,
369378 });
370379 body_paragraphs(&mut after).push(holder);
371380 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() {
179179 bytes: Some(std::sync::Arc::from(&b"A payload that stays sealed"[..])),
180180 preview: None,
181181 recording: None,
182 tags: Vec::new(),
182183 });
183184 paragraphs.insert(at + 1, file);
184185 let text = paragraphs[at].text_mut().unwrap();
crates/snowbound/Cargo.toml+4
......@@ -54,8 +54,12 @@ windows-sys = { version = "0.61", features = [
5454 "Win32_Security_Credentials",
5555 "Win32_Storage_FileSystem",
5656 "Win32_System_Com",
57 "Win32_System_Console",
58 "Win32_System_Diagnostics_Debug",
5759 "Win32_System_IO",
60 "Win32_System_Kernel",
5861 "Win32_System_LibraryLoader",
62 "Win32_System_Pipes",
5963 "Win32_System_Registry",
6064 "Win32_System_Threading",
6165 "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
2121puts the latter elsewhere.
2222
2323**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,
25uninstalls it again; your notebooks stay where they are.
24to the apps that open `.one` and `.onetoc2` files, and copies it to
25`~/.local/bin/snowbound`. Options, next to the update settings, uninstalls it
26again; your notebooks stay where they are.
2627
2728The window's title bar is your desktop's own: KDE's on KDE, and on GNOME and
2829other 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
33Name=Snowbound
44GenericName=Note Taking
55Comment=Type and draw notes in OneNote notebooks
6Exec=snowbound
6Exec=snowbound %F
77Icon=net.paperclover.snowbound
88Terminal=false
99Categories=Office;
10MimeType=application/onenote;
1011StartupWMClass=net.paperclover.snowbound
crates/snowbound/src/art.rs+1
......@@ -41,6 +41,7 @@ pub const SERVER: &[&str] = art!("icons/server");
4141pub const SIDEBAR_COLLAPSE: &[&str] = art!("icons/sidebar-collapse");
4242pub const SIDEBAR_EXPAND: &[&str] = art!("icons/sidebar-expand");
4343pub const STRIKETHROUGH: &[&str] = art!("icons/strikethrough");
44pub const STYLES: &[&str] = art!("icons/styles");
4445pub const SUBSCRIPT: &[&str] = art!("icons/subscript");
4546pub const SUPERSCRIPT: &[&str] = art!("icons/superscript");
4647pub const TABLE: &[&str] = art!("icons/table");
crates/snowbound/src/attachment.rs+2-11
......@@ -35,19 +35,10 @@ impl State {
3535 preview: platform::file_icon(path).map(Into::into),
3636 recording: crate::recording::attached(name, &bytes),
3737 bytes: Some(bytes.into()),
38 tags: Vec::new(),
3839 };
3940 let response = match at {
40 Some(point) => {
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 }
41 Some(point) => self.view.drop_attachment(self.page_point(point), file)?,
5142 None => self.view.insert_attachment(file)?,
5243 };
5344 self.respond(response);
crates/snowbound/src/commands.rs+448-64
......@@ -7,7 +7,11 @@ use canvas::editor::{Alignment, FormatState, Formatting, ListStyle, Pen, Toggle}
77use canvas::interaction::{Request, ink::Tool};
88use draw::edit::{Key, Modifiers, NamedKey, Platform};
99use 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
1216#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1317pub enum Id {
......@@ -24,7 +28,6 @@ pub enum Id {
2428 CopyPageLink,
2529 ShowNotebook,
2630 ExportPdf,
27 ExportSectionPdf,
2831 Print,
2932 Undo,
3033 Redo,
......@@ -84,6 +87,12 @@ pub enum Id {
8487 Indent,
8588 Outdent,
8689 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),
8796 /// The tag at this place in the user's tag list.
8897 Tag(usize),
8998 CustomizeTags,
......@@ -240,7 +249,6 @@ pub const COMMANDS: &[Command] = &[
240249 NONE,
241250 ),
242251 row!(Id::ExportPdf, "Export as PDF…", NONE, NONE),
243 row!(Id::ExportSectionPdf, "Export Section as PDF…", NONE, NONE),
244252 // Go to takes OneNote's Ctrl+P, and Pause its Ctrl+Alt+P.
245253 row!(
246254 Id::Print,
......@@ -307,7 +315,8 @@ pub const COMMANDS: &[Command] = &[
307315 &[cmd('0')],
308316 &[cmd('0').option()]
309317 ),
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('\\')]),
311320 row!(Id::PageList, "Page List", NONE, NONE),
312321 row!(Id::PagesMatchTheme, "Pages Match UI Theme", NONE, NONE),
313322 row!(Id::HideSpelling, "Hide Spelling Errors", NONE, NONE),
......@@ -473,6 +482,67 @@ pub const COMMANDS: &[Command] = &[
473482 &[cmd('n').shift()],
474483 &[cmd('n').shift()]
475484 ),
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 ),
476546 row!(Id::CustomizeTags, "Customize Tags…", NONE, NONE),
477547 row!(
478548 Id::RemoveTags,
......@@ -499,6 +569,7 @@ pub fn command(id: Id) -> &'static Command {
499569}
500570
501571impl Command {
572 /// The table's chords on `platform`, which the user's may replace.
502573 pub fn chords(&self, platform: Platform) -> &'static [Chord] {
503574 match platform {
504575 Platform::MacOs => self.mac,
......@@ -555,35 +626,118 @@ pub fn index(choice: &Choice) -> usize {
555626 .expect("Menus offer listed choices")
556627}
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
558720/// The command `key` with `modifiers` runs here.
559721pub fn find(key: &Key, modifiers: Modifiers) -> Option<Id> {
560 ran(
561 pressed(key, modifiers, Platform::CURRENT)?,
562 Platform::CURRENT,
563 )
722 keymap().ran(pressed(key, modifiers)?, Platform::CURRENT)
564723}
565724
566/// The command `chord` runs on `platform`.
567fn ran(chord: Chord, platform: Platform) -> Option<Id> {
568 COMMANDS
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 })
725/// Command `id`'s chords here.
726pub fn chords(id: Id) -> Vec<Chord> {
727 keymap().chords(id, Platform::CURRENT).to_vec()
577728}
578729
579/// The chord applying the tag at `place` in the list: the first nine take Ctrl+1 to Ctrl+9,
580/// as OneNote's do.
730/// The chord applying the tag at `place` in the list here.
581731pub fn tag_chord(place: usize) -> Option<Chord> {
582 let digit = u8::try_from(place).ok().filter(|place| *place < 9)?;
583 Some(cmd(char::from(b'1' + digit)))
732 keymap().tag_chord(place, Platform::CURRENT)
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)
584738}
585739
586fn pressed(key: &Key, modifiers: Modifiers, platform: Platform) -> Option<Chord> {
740fn chord_on(key: &Key, modifiers: Modifiers, platform: Platform) -> Option<Chord> {
587741 let (key, shifted) = match key {
588742 Key::Named(named) => (Press::Named(*named), false),
589743 Key::Character(text) => {
......@@ -630,6 +784,49 @@ const SHIFTED: [(char, char); 21] = [
630784 ('(', '9'),
631785];
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
633830impl Chord {
634831 /// The key AppKit matches and shows: a shifted symbol stands for Shift with its key.
635832 pub fn equivalent(self) -> (String, bool) {
......@@ -641,26 +838,27 @@ impl Chord {
641838 })
642839 }
643840 Press::Char(base) => (base.to_string(), self.shift),
644 Press::Named(NamedKey::Space) => (" ".to_owned(), self.shift),
645 Press::Named(NamedKey::ArrowLeft) => ('\u{f702}'.to_string(), self.shift),
646 Press::Named(NamedKey::ArrowRight) => ('\u{f703}'.to_string(), self.shift),
647 // AppKit's NSF7FunctionKey.
648 Press::Named(NamedKey::F7) => ('\u{f70a}'.to_string(), self.shift),
649 Press::Named(named) => unreachable!("No command takes {named:?}"),
841 Press::Named(named) => (
842 NAMED
843 .iter()
844 .find(|(listed, ..)| *listed == named)
845 .map_or_else(String::new, |(_, key, ..)| key.to_string()),
846 self.shift,
847 ),
650848 }
651849 }
652850
653851 /// As a menu shows it on `platform`: ⌃⌥⇧⌘K on macOS, Ctrl+Alt+Shift+K elsewhere.
654852 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 };
655859 if platform == Platform::MacOs {
656860 let (key, shift) = self.equivalent();
657 let key = match self.key {
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 };
861 let key = named(|(.., mac, _)| mac).map_or_else(|| key.to_uppercase(), str::to_owned);
664862 [
665863 (self.control, "⌃"),
666864 (self.option, "⌥"),
......@@ -675,9 +873,9 @@ impl Chord {
675873 } else {
676874 let key = match self.key {
677875 Press::Char(key) => key.to_uppercase().to_string(),
678 Press::Named(NamedKey::ArrowLeft) => "Left".to_owned(),
679 Press::Named(NamedKey::ArrowRight) => "Right".to_owned(),
680 Press::Named(named) => format!("{named:?}"),
876 Press::Named(key) => {
877 named(|(.., pc)| pc).map_or_else(|| format!("{key:?}"), str::to_owned)
878 }
681879 };
682880 [
683881 (self.command, "Ctrl+"),
......@@ -691,13 +889,92 @@ impl Chord {
691889 .collect()
692890 }
693891 }
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 }
694971}
695972
696973/// The chord a menu or hint shows for `id` here, empty where it has none.
697974pub fn shortcut(id: Id) -> String {
698975 let chord = match id {
699976 Id::Tag(place) => tag_chord(place),
700 id => command(id).chords(Platform::CURRENT).first().copied(),
977 id => chords(id).first().copied(),
701978 };
702979 chord.map_or_else(String::new, |chord| chord.label(Platform::CURRENT))
703980}
......@@ -722,11 +999,20 @@ impl State {
722999 self.window.request_redraw();
7231000 }
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.
7261004 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 }
7271009 let format = self.format_state();
1010 let palette = self.ui.popup_open(crate::palette::id());
7281011 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 })
7301016 .collect()
7311017 }
7321018
......@@ -734,6 +1020,8 @@ impl State {
7341020 let session = self.session.as_ref();
7351021 let welcome = session.is_none() && !self.temporary && self.sectionless.is_none();
7361022 let modal = self.options.is_some()
1023 || self.themes.is_some()
1024 || self.printing.open()
7371025 || self.link.is_some()
7381026 || self.tag_list.is_some()
7391027 || self.server.is_some();
......@@ -742,6 +1030,14 @@ impl State {
7421030 // Picked drawings leave the text selection behind them unseen, so it takes no edits.
7431031 let typing = self.view.accepts_text() && self.view.ink_selection().is_empty();
7441032 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 );
7451041 // Edit commands act on a focused field instead of the page.
7461042 let field = self.ui.focused_field().is_some();
7471043 let [anchor, focus] = self.view.editor.selection().positions;
......@@ -793,7 +1089,6 @@ impl State {
7931089 | Id::Find
7941090 | Id::Search
7951091 | Id::ExportPdf
796 | Id::ExportSectionPdf
7971092 | Id::Print => enabled(!modal && session.is_some()),
7981093 Id::PageVersions => session.filter(|_| !modal).map_or(
7991094 Status {
......@@ -809,8 +1104,7 @@ impl State {
8091104 enabled: !welcome && !modal,
8101105 checked: Some(self.full_page),
8111106 },
812 Id::Undo => enabled(writable && !field && self.view.editor.can_undo()),
813 Id::Redo => enabled(writable && !field && self.view.editor.can_redo()),
1107 Id::Undo | Id::Redo => enabled(writable && !field && self.can_step(id == Id::Redo)),
8141108 Id::Cut => enabled(writable && selected),
8151109 Id::Copy => enabled(selected),
8161110 Id::Paste => enabled(text && !field),
......@@ -903,13 +1197,25 @@ impl State {
9031197 | Id::Indent
9041198 | Id::Outdent
9051199 | 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),
9071210 Id::Toggle(toggle) => checked(format.toggles.contains(&toggle)),
9081211 Id::Bullets => checked(format.bullets),
9091212 Id::Numbering => checked(format.numbering),
9101213 Id::Align(alignment) => checked(format.alignment == Some(alignment)),
9111214 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 },
9131219 None => Status::default(),
9141220 },
9151221 }
......@@ -1032,19 +1338,11 @@ impl State {
10321338 platform::reveal(&self.notebook().ok_or("No notebook is open")?.location);
10331339 return Ok(());
10341340 }
1035 Id::ExportPdf | Id::ExportSectionPdf | Id::Print => {
1036 let scope = if id == Id::ExportSectionPdf {
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);
1341 Id::ExportPdf | Id::Print => {
1342 self.open_print(id == Id::ExportPdf);
10461343 return Ok(());
10471344 }
1345 Id::Undo | Id::Redo => return self.step(id == Id::Redo),
10481346 Id::Cut | Id::Copy => {
10491347 let response = self.view.copy(id == Id::Cut)?;
10501348 self.respond(response);
......@@ -1133,7 +1431,7 @@ impl State {
11331431 else {
11341432 return Ok(());
11351433 };
1136 return self.insert_picture(std::fs::read(path)?);
1434 return self.insert_picture(std::fs::read(path)?, None);
11371435 }
11381436 Id::Attachment => {
11391437 let Some(path) = platform::pick_file("Attach File", &[]) else {
......@@ -1227,7 +1525,27 @@ impl State {
12271525 Id::Align(alignment) => return format(self, Formatting::Align(alignment)),
12281526 Id::Indent => return format(self, Formatting::Indent),
12291527 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 }
12311549 Id::Tag(place) => {
12321550 let tag = self.tags[place].clone();
12331551 format(self, Formatting::Tag(tag, place as u16))?;
......@@ -1315,8 +1633,8 @@ mod tests {
13151633 control,
13161634 command,
13171635 };
1318 ran(
1319 pressed(&Key::Character(key.into()), modifiers, platform)?,
1636 Keymap::default().ran(
1637 chord_on(&Key::Character(key.into()), modifiers, platform)?,
13201638 platform,
13211639 )
13221640 };
......@@ -1371,7 +1689,7 @@ mod tests {
13711689 find(gtk, "y", false, false, true, true),
13721690 cfg!(windows).then_some(Id::Redo)
13731691 );
1374 let indent = pressed(
1692 let indent = chord_on(
13751693 &Key::Named(NamedKey::ArrowRight),
13761694 Modifiers {
13771695 shift: true,
......@@ -1408,6 +1726,72 @@ mod tests {
14081726 assert_eq!(label(Id::Date, Platform::Windows), "Alt+Shift+D");
14091727 }
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 }
14111795 /// Each toolbar button chooses a command or a list's entry, so it runs, enables and
14121796 /// checks as the menu bar and keyboard do.
14131797 #[test]
crates/snowbound/src/conflict_render.rs+1-1
......@@ -65,7 +65,7 @@ fn layout(ui: &mut Ui, session: &Session, menu: bool) -> Vec<Command> {
6565 &section,
6666 session,
6767 &HashSet::new(),
68 0.0,
68 (0.0, false),
6969 None,
7070 None,
7171 );
crates/snowbound/src/desktop_linux.rs+37-14
......@@ -36,6 +36,8 @@ use winit::{
3636/// Wayland's app ID and X11's WM_CLASS, which name the desktop entry and the icon.
3737pub const APP_ID: &str = "net.paperclover.snowbound";
3838const 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");
3941/// 512 pixels square; the smaller sizes are scaled from it.
4042const ICON: &[u8] = include_bytes!("../linux/snowbound.png");
4143const SIZES: [u32; 8] = [16, 24, 32, 48, 64, 128, 256, 512];
......@@ -75,6 +77,10 @@ fn entry(data: &Path) -> PathBuf {
7577 data.join(format!("applications/{APP_ID}.desktop"))
7678}
7779
80fn mime_types(data: &Path) -> PathBuf {
81 data.join(format!("mime/packages/{APP_ID}.xml"))
82}
83
7884fn theme_icon(data: &Path, side: u32) -> PathBuf {
7985 data.join(format!("icons/hicolor/{side}x{side}/apps/{APP_ID}.png"))
8086}
......@@ -163,7 +169,12 @@ fn entry_text(exec: &Path, icon: &str) -> String {
163169 TEMPLATE
164170 .lines()
165171 .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 }
167178 Some(("Icon", _)) => format!("Icon={icon}\n"),
168179 _ => format!("{line}\n"),
169180 })
......@@ -339,7 +350,7 @@ pub fn set_toplevel_icon(window: &Window) -> Option<()> {
339350}
340351
341352/// 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.
343354pub fn install() {
344355 match try_install() {
345356 Ok(()) => {
......@@ -377,10 +388,13 @@ fn try_install() -> io::Result<()> {
377388 .write_header()?
378389 .write_image_data(&scaled(&icon, side))?;
379390 }
391 let types = mime_types(&data);
392 fs::create_dir_all(types.parent().ok_or(io::ErrorKind::NotFound)?)?;
393 fs::write(types, MIME_TYPES)?;
380394 // Replaces this run's hidden entry, which exiting then leaves alone.
381395 write_entry(&entry(&data), &entry_text(&binary, APP_ID))?;
382396 let _ = runtime_icon().map(fs::remove_file);
383 refresh_icon_cache(&data);
397 refresh_caches(&data);
384398 Ok(())
385399}
386400
......@@ -398,7 +412,10 @@ pub fn uninstall() {
398412 }
399413 let mut failed = None;
400414 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 {
402419 if let Err(error) = fs::remove_file(path)
403420 && error.kind() != io::ErrorKind::NotFound
404421 {
......@@ -411,21 +428,27 @@ pub fn uninstall() {
411428 let _ = fs::remove_dir(folder);
412429 }
413430 }
414 refresh_icon_cache(&data);
431 refresh_caches(&data);
415432 match failed {
416433 Some(error) => crate::platform::alert("Couldn't uninstall Snowbound", &error.to_string()),
417434 None => *INSTALLED.lock().unwrap() = Installed::No,
418435 }
419436}
420437
421/// Brings the icon theme's cache up to date where the user keeps one.
422fn refresh_icon_cache(data: &Path) {
423 let _ = Command::new("gtk-update-icon-cache")
424 .arg("-q")
425 .arg(data.join("icons/hicolor"))
426 .stdout(Stdio::null())
427 .stderr(Stdio::null())
428 .status();
438/// Brings the icon theme's cache, the file types and the apps opening them up to date, where
439/// the desktop has the tools.
440fn refresh_caches(data: &Path) {
441 for (tool, folder) in [
442 ("gtk-update-icon-cache", "icons/hicolor"),
443 ("update-mime-database", "mime"),
444 ("update-desktop-database", "applications"),
445 ] {
446 let _ = Command::new(tool)
447 .arg(data.join(folder))
448 .stdout(Stdio::null())
449 .stderr(Stdio::null())
450 .status();
451 }
429452}
430453
431454#[cfg(test)]
......@@ -444,7 +467,7 @@ mod tests {
444467 #[test]
445468 fn only_the_hidden_entry_names_an_owner() {
446469 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"));
448471 assert!(installed.contains(&format!("\nIcon={APP_ID}\n")));
449472 assert_eq!(portable_owner(&installed), None);
450473 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> {
1111 None
1212}
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
1620pub fn drive() -> Option<PathBuf> {
1721 None
......@@ -19,6 +23,7 @@ pub fn drive() -> Option<PathBuf> {
1923
2024pub fn section(
2125 notebook: &Notebook,
26 _: &Path,
2227 path: &str,
2328 notify: impl Fn() + Send + 'static,
2429) -> Result<Section, notebook::Error> {
crates/snowbound/src/icloud_macos.rs+95-10
......@@ -77,29 +77,45 @@ pub fn ubiquitous(path: &Path) -> bool {
7777 })
7878}
7979
80/// The app's own folder in iCloud Drive as `look_up` last found it.
81static FOLDER: Mutex<Option<PathBuf>> = Mutex::new(None);
80/// The app's own folder in iCloud Drive as `look_up` last found it, and what reports its
81/// notebooks coming and going.
82static FOLDER: Mutex<Option<(PathBuf, Option<Presenter>)>> = Mutex::new(None);
8283
8384/// The app's own folder in iCloud Drive (its container's Documents, shown as iCloud Drive's
8485/// Snowbound folder), as `look_up` last found it: while the app carries the container's
8586/// entitlement and iCloud Drive is on.
8687pub fn folder() -> Option<PathBuf> {
87 FOLDER.lock().ok()?.clone()
88 Some(FOLDER.lock().ok()?.as_ref()?.0.clone())
8889}
8990
9091/// 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`.
92pub fn look_up(done: impl Fn() + Send + 'static) {
92/// iCloud daemon can take a while, then calls `changed`, and again whenever a folder at its
93/// top comes or goes, as another device adds or removes a notebook.
94pub fn look_up(changed: impl Fn() + Send + Sync + 'static) {
9395 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 });
95106 if let Ok(mut folder) = FOLDER.lock() {
96107 *folder = found;
97108 }
98 done();
109 changed();
99110 });
100111}
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.
102115fn container() -> Option<PathBuf> {
116 if let Some(folder) = std::env::var_os("SNOWBOUND_ICLOUD_FOLDER") {
117 return Some(folder.into());
118 }
103119 if !available() {
104120 return None;
105121 }
......@@ -183,6 +199,43 @@ fn present(path: &Path) -> io::Result<()> {
183199 ))
184200}
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
186239/// A section file in iCloud Drive, read and published under file coordination, with the
187240/// conflict versions iCloud keeps beside it.
188241pub struct Coordinated(pub PathBuf);
......@@ -303,23 +356,39 @@ impl Remote for Coordinated {
303356 }
304357}
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.
307362pub fn section(
308363 notebook: &Notebook,
364 root: &Path,
309365 path: &str,
310366 notify: impl Fn() + Send + 'static,
311367) -> Result<Section, notebook::Error> {
368 if !notebook.replica_path(path)?.exists() {
369 fetch(&root.join(path))?;
370 }
312371 let section = notebook.section_with(path, |file| Ok(Coordinated(file.to_owned())), notify)?;
313372 section.set_pause(PAUSE);
314373 Ok(section)
315374}
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.
318386pub fn lone_section(
319387 file: &Path,
320388 cache: &Path,
321389 notify: impl Fn() + Send + 'static,
322390) -> Result<Section, notebook::Error> {
391 fetch(file)?;
323392 let section = Section::open_with(file, cache, |file| Ok(Coordinated(file.to_owned())), notify)?;
324393 section.set_pause(PAUSE);
325394 Ok(section)
......@@ -385,6 +454,22 @@ declare_class!(
385454 self.report(url);
386455 }
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
388473 #[method(presentedSubitemAtURL:didResolveConflictVersion:)]
389474 unsafe fn subitem_did_resolve_conflict_version(
390475 &self,
......@@ -491,7 +576,7 @@ mod tests {
491576 let notebook = Notebook::create(&root, &cache, Notebook::NEW_COLOR, &page).unwrap();
492577 assert!(ubiquitous(&root));
493578 let path = notebook.catalog().sections[0].path.clone();
494 let section = section(&notebook, &path, || {}).unwrap();
579 let section = section(&notebook, &root, &path, || {}).unwrap();
495580 let (space, ..) = section.pages().unwrap()[0].clone();
496581 let title = section
497582 .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 @@
33use canvas::{editor::NoteTag, gpu::TagArt};
44use notebook::discover::{Folder, SectionState};
55use notebook::session::{Background, Notebook, Section};
6use notebook::sidecar::themes::Themes;
67use notebook::smb::{Client, Credentials};
78use std::{
89 error::Error,
......@@ -312,6 +313,11 @@ pub struct Library {
312313 watch: Option<Arc<crate::watch::Watch>>,
313314 /// The art the notebook's tags draw with, as its `.snowbound` folder maps them.
314315 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>)>>,
315321}
316322
317323impl Library {
......@@ -329,7 +335,7 @@ impl Library {
329335 .and_then(|login| Self::on_share(location, mount.clone(), login, cache))
330336 .unwrap_or_else(|reason| Self {
331337 location: location.to_owned(),
332 name: mount.name(),
338 name: display_name(cache, location).unwrap_or_else(|| mount.name()),
333339 notebook: Err(reason),
334340 server: None,
335341 notice: None,
......@@ -337,6 +343,8 @@ impl Library {
337343 background: None,
338344 watch: None,
339345 tag_art: Default::default(),
346 kept: Mutex::new(crate::prefetch::Recent::new(KEPT)),
347 themes: Default::default(),
340348 });
341349 }
342350 let mut notice = None;
......@@ -358,12 +366,14 @@ impl Library {
358366 };
359367 Self {
360368 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))),
362370 background,
363371 watch,
364372 tag_art: Mutex::new(Arc::new(
365373 notebook.as_ref().map(read_tag_art).unwrap_or_default(),
366374 )),
375 kept: Mutex::new(crate::prefetch::Recent::new(KEPT)),
376 themes: Default::default(),
367377 notebook: notebook.map(Some).map_err(|error| error.to_string()),
368378 server: None,
369379 notice,
......@@ -397,11 +407,13 @@ impl Library {
397407 background.watch(notebook.replicas());
398408 Ok(Self {
399409 location: location.to_owned(),
400 name: match server_address(location) {
410 name: display_name(cache, location).unwrap_or_else(|| match server_address(location) {
401411 Some(_) => server.mount.name(),
402412 None => file_name(Path::new(location)),
403 },
413 }),
404414 tag_art: Mutex::new(Arc::new(read_tag_art(&notebook))),
415 kept: Mutex::new(crate::prefetch::Recent::new(KEPT)),
416 themes: Default::default(),
405417 notebook: Ok(Some(notebook)),
406418 server: Some(server),
407419 notice: None,
......@@ -411,6 +423,17 @@ impl Library {
411423 })
412424 }
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
414437 /// The art the notebook's tags draw with.
415438 pub fn tag_art(&self) -> Arc<TagArt> {
416439 Arc::clone(
......@@ -455,6 +478,79 @@ impl Library {
455478 });
456479 }
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
458554 /// This notebook read again, the way it was opened, for changing its structure.
459555 pub fn reopen(&self) -> Result<Notebook, Box<dyn Error>> {
460556 Ok(match &self.server {
......@@ -482,7 +578,7 @@ impl Library {
482578 };
483579 Self {
484580 location: self.location.clone(),
485 name: self.name.clone(),
581 name: display_name(&self.cache, &self.location).unwrap_or_else(|| self.name.clone()),
486582 notebook: Ok(Some(notebook)),
487583 server: self.server.clone(),
488584 notice: self.notice.clone(),
......@@ -490,6 +586,8 @@ impl Library {
490586 background: self.background.clone(),
491587 watch,
492588 tag_art: Mutex::new(self.tag_art()),
589 kept: Mutex::new(crate::prefetch::Recent::new(KEPT)),
590 themes: Default::default(),
493591 }
494592 }
495593
......@@ -502,6 +600,8 @@ impl Library {
502600 background,
503601 watch,
504602 tag_art: Default::default(),
603 kept: Mutex::new(crate::prefetch::Recent::new(KEPT)),
604 themes: Default::default(),
505605 notebook: Ok(Some(notebook)),
506606 server: None,
507607 notice: None,
......@@ -521,6 +621,8 @@ impl Library {
521621 background: None,
522622 watch: None,
523623 tag_art: Default::default(),
624 kept: Mutex::new(crate::prefetch::Recent::new(KEPT)),
625 themes: Default::default(),
524626 }
525627 }
526628
......@@ -533,7 +635,16 @@ impl Library {
533635 ) -> Result<Section, Box<dyn Error>> {
534636 // The background may hold the replica for a step, which takes a network round trip,
535637 // 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>> {
537648 let notify = Arc::new(Mutex::new(notify));
538649 let notifier = || {
539650 let notify = Arc::clone(&notify);
......@@ -544,6 +655,14 @@ impl Library {
544655 }
545656 };
546657 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 }
547666 let opened = match (&self.notebook, &self.server) {
548667 (Ok(Some(notebook)), Some(server)) => {
549668 let file = match server.mount.root.as_str() {
......@@ -567,7 +686,7 @@ impl Library {
567686 })
568687 }
569688 (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())
571690 }
572691 (Ok(Some(notebook)), None) => notebook.section(path, notifier()),
573692 (Ok(None), _) if self.in_icloud() => {
......@@ -595,6 +714,49 @@ impl Library {
595714 Ok(section)
596715 }
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
598760 /// Where `file`, a section file of this notebook, sits on this computer: under the
599761 /// mount it was opened from when the embedded client holds it share-relative.
600762 pub fn local(&self, file: &Path) -> Option<PathBuf> {
......@@ -619,23 +781,60 @@ impl Library {
619781 self.folder().is_some_and(crate::icloud::ubiquitous)
620782 }
621783
622 /// How the notebook's files are reached.
623 pub fn transport(&self) -> String {
624 match (&self.server, &self.notice) {
625 (Some(server), _) => format!(
626 "Snowbound’s SMB client, {}/{}",
627 server.mount.server, server.mount.share
628 ),
629 (None, Some(_)) => "The system’s mount of the share".to_owned(),
630 (None, None) if self.in_icloud() => "iCloud Drive".to_owned(),
631 (None, None) => "Folder on this computer".to_owned(),
632 }
784 /// Where the notebook lives, as its reader knows the place: the server and share, iCloud
785 /// Drive, a drive or this computer, then each folder down to the notebook's, left out.
786 pub fn place(&self) -> Vec<String> {
787 let mut place: Vec<String> = match self
788 .server
789 .as_ref()
790 .map(|server| server.mount.clone())
791 .or_else(|| server_address(&self.location))
792 {
793 Some(mount) => {
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
633832 }
634833
635834 /// Names the section at catalog `path` across every open notebook, for remembering
636835 /// its pages.
637836 pub fn key(&self, path: &str) -> String {
638 format!("{}\n{path}", self.location)
837 key(&self.location, path)
639838 }
640839
641840 /// The readable sections of the folder at catalog path `folder`, as tabs in its order.
......@@ -660,6 +859,21 @@ impl Library {
660859 }
661860 }
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
663877 /// The first readable section, searching groups after sections, as OneNote opens a
664878 /// notebook.
665879 pub fn first_section(&self) -> Option<String> {
......@@ -696,6 +910,31 @@ impl Library {
696910 false
697911 }
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
699938 /// The notebook's folders of sections, for the sidebar.
700939 pub fn catalog(&self) -> Option<&Folder> {
701940 match &self.notebook {
......@@ -715,6 +954,28 @@ pub fn server_address(location: &str) -> Option<Mount> {
715954}
716955
717956/// 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
718979pub fn recycle_bin(path: &str) -> bool {
719980 path.rsplit('/').next() == Some("OneNote_RecycleBin")
720981}
......@@ -781,6 +1042,17 @@ fn watcher(
7811042 .map(Arc::new)
7821043}
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
7841056pub fn file_name(path: &Path) -> String {
7851057 path.canonicalize()
7861058 .ok()
......@@ -890,6 +1162,37 @@ pub fn locate(path: &Path) -> Located {
8901162mod tests {
8911163 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
8931196 #[cfg(unix)]
8941197 #[test]
8951198 fn within_mount_resolves_symlinks() {
......@@ -1051,6 +1354,8 @@ mod tests {
10511354 background: None,
10521355 watch: None,
10531356 tag_art: Default::default(),
1357 kept: Mutex::new(crate::prefetch::Recent::new(KEPT)),
1358 themes: Default::default(),
10541359 };
10551360 let shown = |root, file| library(root).local(Path::new(file));
10561361 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 {
206206 .unwrap_or_default()
207207 }
208208
209 pub fn get_html(&mut self) -> Option<String> {
210 self.through_x11(|clipboard| clipboard.get().html().ok())
211 }
212
209213 pub fn get_picture(&mut self) -> Option<Vec<u8>> {
210214 self.through_x11(|clipboard| crate::paste::bitmap(clipboard.get_image().ok()?))
211215 }
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.
214219 fn through_x11<T>(
215220 &mut self,
216221 read: impl FnOnce(&mut arboard::Clipboard) -> Option<T>,
crates/snowbound/src/macos.rs+33
......@@ -193,6 +193,10 @@ impl Clipboard {
193193 self.0.get().file_list().unwrap_or_default()
194194 }
195195
196 pub fn get_html(&mut self) -> Option<String> {
197 self.0.get().html().ok()
198 }
199
196200 /// The pasteboard's PNG as copied, keeping its resolution, or else its TIFF as a PNG.
197201 pub fn get_picture(&mut self) -> Option<Vec<u8>> {
198202 unsafe {
......@@ -1300,9 +1304,38 @@ pub fn event_loop(headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLo
13001304 let event_loop = builder.build()?;
13011305 QUIT.set(event_loop.create_proxy())
13021306 .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 }
13031322 Ok(event_loop)
13041323}
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
13061339/// Replaces Winit's application menu with the menu bar, whose items the table validates.
13071340pub fn install_menu() {
13081341 let mtm = MainThreadMarker::new().expect("Menus belong to the main thread");
crates/snowbound/src/main.rs+580-87
......@@ -15,6 +15,9 @@ mod history;
1515#[cfg_attr(not(target_os = "macos"), path = "icloud_linux.rs")]
1616#[cfg_attr(target_os = "macos", path = "icloud_macos.rs")]
1717mod icloud;
18#[cfg_attr(target_os = "macos", allow(dead_code))]
19mod instance;
20mod keys;
1821mod library;
1922mod link;
2023mod manage;
......@@ -35,7 +38,9 @@ mod paste;
3538#[cfg_attr(target_os = "macos", path = "macos.rs")]
3639#[cfg_attr(windows, path = "windows.rs")]
3740mod platform;
41mod prefetch;
3842mod print;
43mod properties;
3944#[cfg_attr(target_os = "linux", path = "print_linux.rs")]
4045#[cfg_attr(target_os = "macos", path = "print_macos.rs")]
4146#[cfg_attr(windows, path = "print_windows.rs")]
......@@ -57,6 +62,8 @@ mod surface;
5762mod sync;
5863mod tags;
5964mod templates;
65mod themes;
66mod undo;
6067mod update;
6168mod video;
6269mod watch;
......@@ -203,6 +210,12 @@ enum UserEvent {
203210 /// The iCloud account signed out, signed in or switched, or iCloud Drive was turned off.
204211 #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
205212 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>),
206219}
207220
208221/// Asks the event loop for a frame from any thread.
......@@ -228,6 +241,11 @@ impl Clipboard {
228241 let files = clipboard.get_files();
229242 if !files.is_empty() {
230243 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))
231249 } else if let Some(text) = clipboard.get_text().ok().filter(|text| !text.is_empty())
232250 {
233251 Some(paste::Pasted::Text(text))
......@@ -307,6 +325,8 @@ struct App {
307325 screenshot: Option<PathBuf>,
308326 /// What the window starts from besides its input, until it opens.
309327 launch: Option<settings::Launch>,
328 /// Files and folders to open, as File, Open does, once the window opens.
329 opening: Vec<PathBuf>,
310330 state: Option<State>,
311331 startup_error: Option<Box<dyn Error>>,
312332}
......@@ -330,6 +350,20 @@ enum Input {
330350 },
331351}
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
333367/// Asks the event loop to poll sections when their synchronization reports.
334368fn notify(proxy: EventLoopProxy<UserEvent>) -> impl Fn() + Send + 'static {
335369 move || {
......@@ -337,6 +371,13 @@ fn notify(proxy: EventLoopProxy<UserEvent>) -> impl Fn() + Send + 'static {
337371 }
338372}
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
340381/// The open section and the page the editor shows.
341382struct Session {
342383 section: notebook::session::Section,
......@@ -501,6 +542,7 @@ struct Opening {
501542 scene: (PageScene, [f32; 2]),
502543 editor: CanvasEditor,
503544 since: Instant,
545 requested: Instant,
504546}
505547
506548/// How long an opening page waits for its pictures before showing without them.
......@@ -519,8 +561,8 @@ enum Command {
519561 OpenFromServer(Option<String>),
520562 /// Asks where to keep a new notebook and creates it.
521563 NewNotebook,
522 /// Opens the notebooks in the app's iCloud Drive folder, making one there if it has none.
523 UseICloud,
564 /// Asks for a name and creates a notebook at the top of the app's iCloud Drive folder.
565 NewICloudNotebook,
524566 /// Opens the Snowbound Guide from the user's documents, copying it there first.
525567 OpenGuide,
526568 /// Adds Snowbound to the app menu.
......@@ -600,6 +642,9 @@ struct State {
600642 /// The newest read requested; older ones are dropped when they finish.
601643 loading: u64,
602644 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,
603648 /// Where each page was left this run, by `Library::key` and page space, as OneNote
604649 /// returns to it until the notebook closes.
605650 places: HashMap<(String, ExGuid), Place>,
......@@ -607,6 +652,8 @@ struct State {
607652 last_pages: HashMap<String, ExGuid>,
608653 /// Pages visited, for Back and Forward.
609654 trail: navigation::Trail,
655 /// What Undo and Redo take back across pages and sections.
656 undo: undo::Timeline,
610657 swipe: navigation::Swipe,
611658 /// Asks for a frame when a worker thread finishes something the page shows.
612659 redraw: std::task::Waker,
......@@ -629,13 +676,30 @@ struct State {
629676 folded: HashSet<String>,
630677 /// The open context menu: what it was opened on, and where.
631678 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)>,
632686 /// The Options dialog's choices while it is open.
633687 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,
634692 updates: update::Updates,
635693 /// The Link dialog's fields while it is open.
636694 link: Option<link::LinkDialog>,
695 properties: Option<properties::Properties>,
637696 /// Open Notebook from Server while it is open.
638697 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>,
639703 /// The user's tag list, which the toolbar, menus and Ctrl+1 to Ctrl+9 apply.
640704 tags: Vec<canvas::editor::NoteTag>,
641705 /// The Customize Tags dialog's list while it is open.
......@@ -649,8 +713,6 @@ struct State {
649713 hide_spelling: bool,
650714 /// Options' "Use pen pressure sensitivity": a tablet pen's strokes follow its pressure.
651715 pen_pressure: bool,
652 /// Options' Default font, which new text and titles take.
653 default_font: settings::DefaultFont,
654716 /// Options' "Page tabs appear on the left".
655717 page_tabs_left: bool,
656718 /// Options' "Navigation bar appears on the left" off: the notebooks on the right.
......@@ -790,6 +852,7 @@ impl State {
790852 platform::install_text_input(&window);
791853 // Beneath the surface's layer, which is added over it.
792854 let backdrop = visible && platform::install_backdrop(&window);
855 commands::Keymap::from_saved(&stored.keys).install();
793856 platform::install_menu();
794857 let access_adapter =
795858 accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy.clone());
......@@ -812,7 +875,7 @@ impl State {
812875 icloud::on_account_change(move || {
813876 let _ = account.send_event(UserEvent::ICloudAccount);
814877 });
815 icloud::look_up(notify(proxy.clone()));
878 icloud::look_up(icloud_listed(proxy.clone()));
816879 let mut notebooks = Vec::new();
817880 let mut session = None;
818881 let mut sectionless = None;
......@@ -859,6 +922,10 @@ impl State {
859922 if let Err(error) = &library.notebook {
860923 eprintln!("Cannot open the notebook at {}: {error}", library.location);
861924 }
925 // Drawn offscreen, the app leaves the notebooks as they are.
926 if visible {
927 library.purge_recycle_bin();
928 }
862929 }
863930 let shown = notebooks
864931 .iter()
......@@ -946,6 +1013,7 @@ impl State {
9461013 let spelling = spell::dictionary()
9471014 .map(|dictionary| canvas::spelling::Spelling::new(dictionary, redraw.clone()));
9481015 let updates = update::Updates::start(visible && !stored.manual_updates, proxy.clone());
1016 let prefetch = prefetch::Prefetch::new(Arc::clone(&layouts), redraw.clone());
9491017 let mut state = Self {
9501018 author: stored.user_name.unwrap_or_else(platform::user_name),
9511019 window,
......@@ -972,14 +1040,22 @@ impl State {
9721040 dismissed: HashSet::new(),
9731041 title_focus: None,
9741042 menu: None,
1043 sync_notebook: None,
1044 actions: None,
1045 after_open: None,
9751046 link: None,
1047 properties: None,
9761048 server: None,
1049 new_icloud: None,
1050 icloud_reading: HashSet::new(),
9771051 tags: stored
9781052 .tags
9791053 .unwrap_or_else(canvas::editor::NoteTag::defaults),
9801054 tag_list: None,
9811055 text_menu: None,
9821056 options: None,
1057 themes: None,
1058 printing: print::Printing::default(),
9831059 updates,
9841060 color_scheme: stored.color_scheme,
9851061 light_pages: stored.light_pages,
......@@ -1016,12 +1092,19 @@ impl State {
10161092 ime_allowed: true,
10171093 clipboard,
10181094 loads: mpsc::channel(),
1095 prefetch,
10191096 layouts,
10201097 loading: 0,
10211098 opening: None,
1099 switching: None,
10221100 places: HashMap::new(),
10231101 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(),
10251108 swipe: navigation::Swipe::default(),
10261109 access_adapter,
10271110 accessibility: accessibility::Accessibility::default(),
......@@ -1029,13 +1112,13 @@ impl State {
10291112 spelling,
10301113 hide_spelling: stored.hide_spelling,
10311114 pen_pressure: !stored.ignore_pen_pressure,
1032 default_font: stored.default_font,
10331115 page_tabs_left: stored.page_tabs_left,
10341116 navigation_bar_right: stored.navigation_bar_right,
10351117 servers: stored.servers,
10361118 correction: None,
10371119 };
10381120 state.view.snap_to_grid = !stored.ignore_grid;
1121 state.view.editor.default_font = stored.default_font;
10391122 // A notebook opened from its server that couldn't sign in asks to, as the Finder does.
10401123 let unsigned = state.notebooks.iter().find(|library| {
10411124 library.notebook.is_err() && library::server_address(&library.location).is_some()
......@@ -1044,6 +1127,7 @@ impl State {
10441127 state.commands.push(Command::OpenFromServer(Some(location)));
10451128 }
10461129 state.visited();
1130 state.prefetch_around();
10471131 state.show_spelling();
10481132 state.title();
10491133 platform::update_tag_menu(&state.tags);
......@@ -1090,7 +1174,10 @@ impl State {
10901174 lap("drawn", start);
10911175 self.sync_index(false, Vec::new());
10921176 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();
10941181 for command in commands {
10951182 self.apply(command)?;
10961183 }
......@@ -1118,8 +1205,12 @@ impl State {
11181205 platform::cover_border_line(&mut self.ui, size[0]);
11191206 let (section, open_tab, open_page) = self.build()?;
11201207 self.options_dialog();
1208 self.themes_dialog();
1209 self.print_dialog();
11211210 self.server_dialog();
1211 self.new_icloud_dialog();
11221212 self.link_dialog()?;
1213 self.properties_dialog();
11231214 self.customize_tags();
11241215 self.palette();
11251216 self.sync_popup()?;
......@@ -1185,7 +1276,8 @@ impl State {
11851276 let target = self.ui.theme.section(section_color(
11861277 self.session
11871278 .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),
11891281 ));
11901282 self.ui.icon_palette = self.ui.theme.icon_palette(
11911283 target.accent,
......@@ -1235,7 +1327,12 @@ impl State {
12351327 ..Spec::default()
12361328 },
12371329 );
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;
12391336 self.ui.open(
12401337 "main",
12411338 Spec {
......@@ -1267,10 +1364,11 @@ impl State {
12671364 // outlines start where the button's square ends; the room shrinks on the sidebar's
12681365 // easing, so the tabs ease with it.
12691366 let room = TAB_ROW - FRAME - self.rounding() - ui::SHADOW[0];
1367 let room = if self.navigation_bar_right { 0.0 } else { room };
12701368 self.ui.leaf(
12711369 "rail",
12721370 Spec {
1273 size: [px(room * (1.0 - sidebar / sidebar::WIDTH)), px(1.0)],
1371 size: [px(room * beside), px(1.0)],
12741372 ..Spec::default()
12751373 },
12761374 );
......@@ -1301,6 +1399,7 @@ impl State {
13011399 .collect();
13021400 let lit = self.page_drop(row);
13031401 let dragged = self.dragged_tab(row);
1402 let shown = self.open_tab().unwrap_or(session.tab);
13041403 let ui::shell::Tabs {
13051404 clicked,
13061405 context,
......@@ -1313,7 +1412,7 @@ impl State {
13131412 &mut self.ui,
13141413 row,
13151414 &tabs,
1316 session.tab,
1415 shown,
13171416 lit,
13181417 dragged,
13191418 &section,
......@@ -1373,6 +1472,12 @@ impl State {
13731472 }
13741473 };
13751474 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 }
13761481 self.ui.leaf(
13771482 "space",
13781483 Spec {
......@@ -1383,10 +1488,15 @@ impl State {
13831488 if self.session.is_some() {
13841489 self.page_tools(&theme);
13851490 }
1491 let button = if self.navigation_bar_right && !self.temporary {
1492 TAB_ROW * beside
1493 } else {
1494 0.0
1495 };
13861496 self.ui.leaf(
13871497 "trail",
13881498 Spec {
1389 size: [px(self.trailing() - FRAME), px(1.0)],
1499 size: [px(self.trailing() - FRAME + button), px(1.0)],
13901500 ..Spec::default()
13911501 },
13921502 );
......@@ -1402,6 +1512,11 @@ impl State {
14021512 ..Spec::default()
14031513 },
14041514 );
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 }
14051520 self.ui.open(
14061521 "column",
14071522 Spec {
......@@ -1427,6 +1542,9 @@ impl State {
14271542 self.ui.close();
14281543 self.ui.close();
14291544 self.ui.close();
1545 if self.navigation_bar_right {
1546 self.sidebar(&theme, sidebar);
1547 }
14301548 self.sidebar_button(&theme, height);
14311549 self.ui.close();
14321550 self.context_menu();
......@@ -1445,12 +1563,20 @@ impl State {
14451563 if let Some(node) = self.ui.access(page()) {
14461564 node.set_tree_id(PAGE_TREE);
14471565 }
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 }
14491571 if let Some(rect) = self.ui.rect(page()) {
14501572 self.transport(&theme, rect)?;
14511573 }
14521574 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 {
14541580 if let Some(offset) = ui::scrollbar(
14551581 &mut self.ui,
14561582 index,
......@@ -1491,9 +1617,14 @@ impl State {
14911617 }
14921618 self.page_events(signal.events)?;
14931619 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 }
14951623 self.ui.close();
14961624 self.ui.close();
1625 if self.navigation_bar_right {
1626 self.sidebar(&theme, sidebar);
1627 }
14971628 self.task_pane(&theme);
14981629 self.sidebar_button(&theme, height);
14991630 self.ui.close();
......@@ -1523,6 +1654,7 @@ impl State {
15231654 // Where the open page tab meets the page, concentric with its neighbours.
15241655 let join = PILL_MARGIN + rounding;
15251656 let [left, top, right, bottom] = page;
1657 let left_tabs = self.page_tabs_left;
15261658 let tab = open_page
15271659 .and_then(|id| self.ui.rect(id))
15281660 .map(|row| [row[0], row[1], row[2], row[3] - ROW_GAP])
......@@ -1532,8 +1664,12 @@ impl State {
15321664 row[1].max(panel[1]).max(top),
15331665 row[3].min(panel[3]).min(bottom),
15341666 ];
1535 let reach = row[2].min(panel[2]);
1536 (reach - right > 2.0 * join && end - start > 2.0 * rounding).then(|| {
1667 // How far the tab reaches out from the page's side.
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(|| {
15371673 // A tab too near the page's corner joins it along the edge.
15381674 let start = if start < top + rounding + join {
15391675 top
......@@ -1548,9 +1684,24 @@ impl State {
15481684 [start, end, reach]
15491685 })
15501686 });
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();
15521689 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 }
15531703 Some([start, end, reach]) => {
1704 outline.push(([left, top], rounding));
15541705 if start > top {
15551706 outline.extend([([right, top], rounding), ([right, start], join)]);
15561707 }
......@@ -1558,10 +1709,15 @@ impl State {
15581709 if end < bottom {
15591710 outline.extend([([right, end], join), ([right, bottom], rounding)]);
15601711 }
1712 outline.push(([left, bottom], rounding));
15611713 }
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 ]),
15631720 }
1564 outline.push(([left, bottom], rounding));
15651721 let height = (frame[3] - frame[1]).max(1.0);
15661722 let paper = self.paper().color;
15671723 self.ui.round_corners(&outline, paper, |y| {
......@@ -1573,7 +1729,9 @@ impl State {
15731729 // the open tab stands on it.
15741730 let [start, end] = [frame[0], frame[2]];
15751731 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));
15771735 let strip = self.ui.theme.strip;
15781736 // The border runs half its width inside the frame, on whole device pixels at any
15791737 // scale, its corners concentric with the fill's. The fill is cut at the border's
......@@ -1585,7 +1743,7 @@ impl State {
15851743 &[
15861744 ([left, top], (outer - cut).max(0.0)),
15871745 ([right, top], (outer - cut).max(0.0)),
1588 ([right, frame[3]], 0.0),
1746 ([right, frame[3]], (beside_right - cut).max(0.0)),
15891747 ([left, frame[3]], (beside - cut).max(0.0)),
15901748 ],
15911749 paper,
......@@ -1603,7 +1761,7 @@ impl State {
16031761 .map_or([end; 2], |tab| ui::shell::tab_base(tab, TAB_ROW))
16041762 .map(|x| x.clamp(left, right));
16051763 // 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.
16071765 self.ui.border(
16081766 &[
16091767 ([west + beside, frame[3]], 0.0),
......@@ -1618,7 +1776,8 @@ impl State {
16181776 &[
16191777 ([toe, north], 0.0),
16201778 ([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),
16221781 ],
16231782 false,
16241783 section.edge,
......@@ -1756,6 +1915,7 @@ impl State {
17561915 let session = self.session.as_ref();
17571916 let update = self.updates.status();
17581917 let label = format!("{:.0}%", self.view.zoom() * 100.0);
1918 let sheet = self.gallery_sheet();
17591919 let drawing_pens = self.pens();
17601920 let ui = &mut self.ui;
17611921 let text = theme.text;
......@@ -1850,6 +2010,22 @@ impl State {
18502010 },
18512011 );
18522012 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 }
18532029 let combo = ui.id("font");
18542030 // It gives up room last of all, once every group has folded.
18552031 let width = ui::Extent {
......@@ -2546,8 +2722,9 @@ impl State {
25462722 self.ui.close();
25472723 }
25482724
2549 /// The section's pages as tabs down the frame's right side, returning the open page's
2550 /// tab, which is the page's colour and joins it.
2725 /// The section's pages as tabs down the frame's right side, or its left with OneNote's
2726 /// "Page tabs appear on the left", returning the open page's tab, which is the page's
2727 /// colour and joins it.
25512728 fn page_list(&mut self, theme: &Theme, section: &ui::Section, tabs: Id) -> Option<Id> {
25522729 let session = self.session.as_ref()?;
25532730 let panel = self.ui.id("panel");
......@@ -2567,6 +2744,11 @@ impl State {
25672744 node.set_label("Pages");
25682745 node.set_orientation(accesskit::Orientation::Vertical);
25692746 }
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 }
25702752 let found = self
25712753 .search
25722754 .found_in(&session.library.key(&session.tabs[session.tab].path));
......@@ -2578,7 +2760,7 @@ impl State {
25782760 section,
25792761 session,
25802762 &found,
2581 rounding,
2763 (rounding, self.page_tabs_left),
25822764 self.renaming.as_mut(),
25832765 dragged,
25842766 );
......@@ -2587,7 +2769,12 @@ impl State {
25872769 .map(|(page, version)| Command::OpenVersion { page, version }),
25882770 );
25892771 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));
25912778 self.ui.open_popup(menus::id());
25922779 }
25932780 if let Some(space) = rows.renamed {
......@@ -2600,12 +2787,19 @@ impl State {
26002787 if !self.dragged() {
26012788 self.commands.extend(rows.clicked.map(Command::OpenPage));
26022789 }
2790 if let Some(space) = rows.hovered {
2791 self.prefetch_page(space);
2792 }
26032793 self.ui.close();
26042794 rows.open
26052795 }
26062796
26072797 /// Hands the page the events routed to its box, in its device pixels.
26082798 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 }
26092803 let read_only = self.session.as_ref().is_some_and(Session::read_only);
26102804 let scale = self.ui.scale();
26112805 let corner = self.ui.rect(page()).unwrap_or_default();
......@@ -2706,16 +2900,39 @@ impl State {
27062900 fn apply(&mut self, command: Command) -> Result<(), Box<dyn Error>> {
27072901 match command {
27082902 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()));
27092919 let notify = notify(self.proxy.clone());
27102920 let last = self.last_pages.get(&library.key(&path)).copied();
27112921 self.load(move || {
2922 let start = Instant::now();
27122923 let section = library.open(&path, notify)?;
2924 lap("switch section open", start);
27132925 let (session, page) = read_session(section, library, path, last)?;
27142926 Ok(Loaded::Section(Box::new(session), page))
27152927 });
27162928 }
27172929 Command::OpenPage(space) => {
27182930 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()));
27192936 let read = session.reader(space);
27202937 self.load(move || Ok(Loaded::Page(space, read()?)));
27212938 }
......@@ -2773,12 +2990,17 @@ impl State {
27732990 }
27742991 Command::OpenFromServer(location) => self.open_server(location.as_deref()),
27752992 Command::NewNotebook => self.new_notebook()?,
2776 Command::UseICloud => self.use_icloud()?,
2993 Command::NewICloudNotebook => self.new_icloud_notebook(),
27772994 Command::OpenGuide => self.open_guide()?,
27782995 #[cfg(target_os = "linux")]
27792996 Command::Install => desktop::install(),
27802997 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 }
27823004 // The index follows the section's page list.
27833005 Command::NewPage { under } => {
27843006 self.new_page(under)?;
......@@ -2812,8 +3034,12 @@ impl State {
28123034 self.loading += 1;
28133035 let (id, sender, redraw) = (self.loading, self.loads.0.clone(), self.redraw.clone());
28143036 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);
28153040 std::thread::spawn(move || {
28163041 let laid = read().and_then(|loaded| {
3042 lap("switch read", requested);
28173043 let (shown, page) = match loaded {
28183044 Loaded::Section(session, page) => (Shown::Section(session), page),
28193045 Loaded::Created(session, page) => (Shown::Created(session), page),
......@@ -2822,8 +3048,28 @@ impl State {
28223048 Loaded::Library(library, path) => return Ok(Laid::Library(library, path)),
28233049 };
28243050 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);
28263071 Ok(Laid::Page(Box::new(Opening {
3072 requested,
28273073 loaded: shown,
28283074 scene: (scene, [0.0; 2]),
28293075 editor,
......@@ -2846,11 +3092,19 @@ impl State {
28463092 Ok(Laid::Page(opening)) => self.opening = Some(*opening),
28473093 Ok(Laid::Library(library, path)) => self.adopt(library, path.as_deref())?,
28483094 Err(error) => {
3095 if id == self.loading {
3096 self.switching = None;
3097 }
28493098 eprintln!("{error}");
28503099 platform::alert("Couldn't open", &error);
28513100 }
28523101 }
28533102 }
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);
28543108 let Some(mut opening) = self.opening.take() else {
28553109 return Ok(());
28563110 };
......@@ -2867,15 +3121,27 @@ impl State {
28673121 self.opening = Some(opening);
28683122 return Ok(());
28693123 }
3124 lap("switch pictures", opening.since);
3125 let requested = opening.requested;
28703126 // A recording goes on the page it started on.
28713127 self.stop_recording(true)?;
28723128 // The page shown so far keeps what was typed while the next one loaded.
28733129 self.persist()?;
3130 let mut leaving = self
3131 .session
3132 .as_ref()
3133 .filter(|session| !session.read_only())
3134 .map(|session| session.space);
28743135 if let Some(session) = &self.session {
2875 let key = session.library.key(&session.tabs[session.tab].path);
3136 let key = session.key();
28763137 self.places
28773138 .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 }
28793145 }
28803146 let created = matches!(opening.loaded, Shown::Created(_));
28813147 match opening.loaded {
......@@ -2894,14 +3160,23 @@ impl State {
28943160 Some(library) => *library = Arc::clone(&session.library),
28953161 None => self.notebooks.push(Arc::clone(&session.library)),
28963162 }
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.
28983168 if let Some(previous) = self.session.replace(*session) {
2899 let section = previous.section;
2900 std::thread::spawn(move || {
2901 if let Err(error) = section.close() {
2902 eprintln!("Synchronization stopped: {error}");
2903 }
2904 });
3169 let library = &previous.library;
3170 if self.notebooks.iter().any(|open| Arc::ptr_eq(open, library)) {
3171 library.keep(&previous.tabs[previous.tab].path, previous.section);
3172 } else {
3173 let section = previous.section;
3174 std::thread::spawn(move || {
3175 if let Err(error) = section.close() {
3176 eprintln!("Synchronization stopped: {error}");
3177 }
3178 });
3179 }
29053180 }
29063181 self.sectionless = None;
29073182 if other_notebook {
......@@ -2918,15 +3193,41 @@ impl State {
29183193 session.change = None;
29193194 // Pages created, moved or deleted since the list was read.
29203195 session.pages = session.section.pages()?;
3196 leaving = leaving.filter(|left| session.pages.iter().any(|(s, ..)| s == left));
29213197 }
29223198 }
29233199 let place = self.session.as_ref().and_then(|session| {
29243200 let key = session.library.key(&session.tabs[session.tab].path);
29253201 self.places.get(&(key, session.space)).copied()
29263202 });
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;
29283218 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 }
29293229 self.opened()?;
3230 self.prefetch_around();
29303231 self.refind(true)?;
29313232 if self.title_focus.take().is_some_and(|space| {
29323233 self.session
......@@ -2958,6 +3259,9 @@ impl State {
29583259 /// the open section, now at catalog `path` in it, stays open, reopened where it moved.
29593260 /// Without `path` the notebook has no sections left, and one of its shows none.
29603261 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 }
29613265 match self
29623266 .notebooks
29633267 .iter_mut()
......@@ -2978,9 +3282,10 @@ impl State {
29783282 if let Some(session) = self.session.take() {
29793283 session.section.close()?;
29803284 }
2981 self.sectionless = Some(library);
3285 self.sectionless = Some(Arc::clone(&library));
29823286 self.title();
29833287 }
3288 self.show_arrived(&library);
29843289 return Ok(());
29853290 };
29863291 let Some(session) = &mut self.session else {
......@@ -3014,6 +3319,9 @@ impl State {
30143319
30153320 /// Follows a page shown in place of another.
30163321 fn opened(&mut self) -> Result<(), Box<dyn Error>> {
3322 if let Err(error) = self.wear_theme() {
3323 eprintln!("Restyling the page failed: {error}");
3324 }
30173325 // A page a search result shows leaves the keys with the search.
30183326 if !search::takes_text(self.ui.focused()) {
30193327 self.ui.set_focus(Some(page()));
......@@ -3220,6 +3528,9 @@ impl State {
32203528 session.pages = session.section.pages()?;
32213529 }
32223530 self.edited(vec![space]);
3531 let editor = &self.view.editor;
3532 self.undo
3533 .edited(space, editor.history_depth(), editor.typing().is_some());
32233534 Ok(())
32243535 }
32253536
......@@ -3610,19 +3921,32 @@ impl State {
36103921 self.view.tag_art = session.library.tag_art();
36113922 }
36123923 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);
36233924 let scale = self.ui.scale();
36243925 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);
36263950 let interface = self.ui.layers();
36273951 let layers: Vec<_> = interface
36283952 .iter()
......@@ -3673,17 +3997,18 @@ impl State {
36733997 (x0..x1).contains(&x) && (y0..y1).contains(&y)
36743998 }
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
36764007 /// Zooms the page by `factor` about the pointer, when it is over the page.
36774008 fn pinch(&mut self, factor: f32) -> Result<(), Box<dyn Error>> {
36784009 if self.over_page() {
36794010 // 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();
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)?;
4011 let response = self.view.pinch(factor, self.page_point(self.pointer))?;
36874012 self.respond(response);
36884013 }
36894014 Ok(())
......@@ -3755,10 +4080,15 @@ impl State {
37554080 }
37564081 return;
37574082 }
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.
37594088 if let ui::Event::Key { key, .. } = &event
37604089 && let Some(id) =
37614090 commands::find(&ui::edit_key(key), ui::edit_modifiers(self.ui.modifiers()))
4091 && !(id == commands::Id::Link && self.ui.popup_open(palette::id()))
37624092 {
37634093 self.choose(commands::Choice::Command(id));
37644094 } else {
......@@ -3977,6 +4307,8 @@ struct Rows {
39774307 context: Option<(ExGuid, [f32; 2])>,
39784308 /// The page held down, which a drag moves.
39794309 held: Option<ExGuid>,
4310 /// A page other than the open one under the pointer.
4311 hovered: Option<ExGuid>,
39804312 /// A page version clicked: the page, and the version's context.
39814313 version: Option<(ExGuid, ExGuid)>,
39824314 /// The page whose tab was pressed twice in a row, which renames it.
......@@ -4013,7 +4345,7 @@ fn page_rows(
40134345 section: &ui::Section,
40144346 session: &Session,
40154347 found: &HashSet<ExGuid>,
4016 rounding: f32,
4348 shape: (f32, bool),
40174349 mut renaming: Option<&mut rename::Renaming>,
40184350 dragged: Option<PageDrag>,
40194351) -> Rows {
......@@ -4058,7 +4390,7 @@ fn page_rows(
40584390 section,
40594391 session,
40604392 found,
4061 rounding,
4393 shape,
40624394 renaming.as_deref_mut(),
40634395 (space, title, *level),
40644396 shift,
......@@ -4085,7 +4417,7 @@ fn page_rows(
40854417 section,
40864418 session,
40874419 found,
4088 rounding,
4420 shape,
40894421 renaming,
40904422 (space, title, *level),
40914423 top - laid,
......@@ -4106,7 +4438,7 @@ fn page_row(
41064438 section: &ui::Section,
41074439 session: &Session,
41084440 found: &HashSet<ExGuid>,
4109 rounding: f32,
4441 shape: (f32, bool),
41104442 renaming: Option<&mut rename::Renaming>,
41114443 (space, title, level): (&ExGuid, &String, u32),
41124444 shift: f32,
......@@ -4135,8 +4467,11 @@ fn page_row(
41354467 if selected && !lifted {
41364468 rows.open = Some(ui.id(space));
41374469 }
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);
41394471 rows.kept = rows.kept.or(kept);
4472 if signal.hovered && !selected {
4473 rows.hovered = Some(*space);
4474 }
41404475 if signal.clicked && !selected {
41414476 rows.clicked = Some(*space);
41424477 }
......@@ -4176,7 +4511,7 @@ fn page_row(
41764511 rows.open = Some(ui.id(version.context));
41774512 }
41784513 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)
41804515 .0
41814516 .clicked
41824517 && !selected
......@@ -4208,7 +4543,7 @@ fn page_row(
42084543 rows.open = Some(ui.id(version.space));
42094544 }
42104545 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)
42124547 .0
42134548 .clicked
42144549 && !selected
......@@ -4237,8 +4572,9 @@ struct PageTab<'a> {
42374572 lifted: bool,
42384573}
42394574
4240/// A page's tab down the frame's right side: the open one is the page's colour and joins
4241/// it, the others float free of it as pills rounded like the page.
4575/// A page's tab down the frame's side, the left where `shape` says so beside its `rounding`:
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.
42424578fn page_tab(
42434579 ui: &mut Ui,
42444580 theme: &Theme,
......@@ -4246,7 +4582,7 @@ fn page_tab(
42464582 id: &ExGuid,
42474583 tab: PageTab,
42484584 selected: bool,
4249 rounding: f32,
4585 (rounding, left): (f32, bool),
42504586) -> (ui::Signal, Option<bool>) {
42514587 // A rename field's text stands where the label did.
42524588 let pad = 10.0 + 16.0 * tab.indent as f32
......@@ -4280,13 +4616,20 @@ fn page_tab(
42804616 fill
42814617 }
42824618 };
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 };
42834626 let spec = Spec {
42844627 fill: Some(tint(section.tab)),
42854628 hover_fill: Some(tint(section.hover())),
42864629 radius: rounding,
4287 inset: [PILL_MARGIN, 0.0, 6.0, ROW_GAP],
4630 inset: [start, 0.0, end, ROW_GAP],
42884631 // 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],
42904633 ..spec
42914634 };
42924635 let color = if tab.dim {
......@@ -4743,6 +5086,7 @@ fn artwork(id: commands::Id) -> Option<&'static [&'static str]> {
47435086 Cmd::Toggle(Toggle::Subscript) => art::SUBSCRIPT,
47445087 Cmd::Toggle(Toggle::Superscript) => art::SUPERSCRIPT,
47455088 Cmd::ClearFormatting => art::CLEAR_FORMATTING,
5089 Cmd::Styles => art::STYLES,
47465090 Cmd::Highlight => art::HIGHLIGHTER,
47475091 Cmd::FontColor => art::FONT_COLOR,
47485092 Cmd::Bullets => art::BULLETS,
......@@ -4971,7 +5315,7 @@ impl ApplicationHandler<UserEvent> for App {
49715315 let event = match event {
49725316 UserEvent::Picture(bytes) => {
49735317 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) {
49755319 eprintln!("{error}");
49765320 }
49775321 state.window.request_redraw();
......@@ -5005,6 +5349,12 @@ impl ApplicationHandler<UserEvent> for App {
50055349 }
50065350 return;
50075351 }
5352 UserEvent::ICloudFolder => {
5353 if let Some(state) = &mut self.state {
5354 state.list_icloud();
5355 }
5356 return;
5357 }
50085358 UserEvent::ICloudAccount => {
50095359 if let Some(state) = &mut self.state {
50105360 state.icloud_account_changed();
......@@ -5012,6 +5362,21 @@ impl ApplicationHandler<UserEvent> for App {
50125362 }
50135363 return;
50145364 }
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 }
50155380 UserEvent::Update => {
50165381 if let Some(state) = &mut self.state {
50175382 state.updated();
......@@ -5115,10 +5480,19 @@ impl ApplicationHandler<UserEvent> for App {
51155480 if self.state.is_some() {
51165481 return;
51175482 }
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 }
51185492 match pollster::block_on(State::new(
51195493 event_loop,
51205494 self.proxy.clone(),
5121 self.input.take().unwrap(),
5495 input,
51225496 &self.substitutes,
51235497 self.screenshot.is_none(),
51245498 self.launch.take().expect("The window opens once"),
......@@ -5129,7 +5503,12 @@ impl ApplicationHandler<UserEvent> for App {
51295503 self.startup_error = state.screenshot(prefix).err();
51305504 event_loop.exit();
51315505 }
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 }
51335512 },
51345513 Err(error) => {
51355514 self.startup_error = Some(error);
......@@ -5193,6 +5572,9 @@ impl ApplicationHandler<UserEvent> for App {
51935572 if !focused {
51945573 // Someone switching to another device finds their edits there.
51955574 state.publish_now(std::time::Duration::ZERO);
5575 } else {
5576 // Notebooks another device added or removed meanwhile.
5577 state.list_icloud();
51965578 }
51975579 state.window.request_redraw();
51985580 }
......@@ -5247,7 +5629,7 @@ impl ApplicationHandler<UserEvent> for App {
52475629 WindowEvent::Ime(ime) => state.input(ui::Event::Ime(ime)),
52485630 WindowEvent::DroppedFile(path) => {
52495631 let at = platform::drop_point(&state.window);
5250 state.attach(&path, at)?;
5632 state.place_file(&path, at)?;
52515633 state.window.request_redraw();
52525634 }
52535635 WindowEvent::PinchGesture { delta, .. } => state.pinch(1.0 + delta as f32)?,
......@@ -5425,6 +5807,7 @@ fn launch() -> Result<(), Box<dyn Error>> {
54255807 let mut cache = None;
54265808 let mut settings_file = None;
54275809 let mut screenshot = None;
5810 let mut opening = Vec::new();
54285811 while let Some(arg) = args.next() {
54295812 if arg == "--substitute-font" {
54305813 substitutes.push(PathBuf::from(
......@@ -5485,6 +5868,9 @@ fn launch() -> Result<(), Box<dyn Error>> {
54855868 .to_str()
54865869 .ok_or("The page title must be valid Unicode.")?,
54875870 )?);
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)?);
54885874 } else {
54895875 positional.push(arg);
54905876 }
......@@ -5499,7 +5885,7 @@ fn launch() -> Result<(), Box<dyn Error>> {
54995885 }
55005886 if positional.len() > 2 {
55015887 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]..."
55035889 .into(),
55045890 );
55055891 }
......@@ -5531,16 +5917,14 @@ fn launch() -> Result<(), Box<dyn Error>> {
55315917 } else if editable {
55325918 Input::Page(reference.unwrap())
55335919 } else if positional.is_empty() && reference.is_none() {
5534 let mut locations = saved.notebooks.clone();
5535 let mut current = saved.current.clone();
5920 let mut input = Input::Notebooks {
5921 locations: saved.notebooks.clone(),
5922 current: saved.current.clone(),
5923 };
55365924 if let Some(root) = notebook {
5537 let location = std::path::absolute(root)?.to_string_lossy().into_owned();
5538 if !locations.contains(&location) {
5539 locations.push(location.clone());
5540 }
5541 current = Some(location);
5925 input.show(&root)?;
55425926 }
5543 Input::Notebooks { locations, current }
5927 input
55445928 } else {
55455929 Input::Notes {
55465930 document: TextDocument::new(
......@@ -5552,10 +5936,22 @@ fn launch() -> Result<(), Box<dyn Error>> {
55525936 reference,
55535937 }
55545938 };
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 };
55555947 if screenshot.is_some() {
55565948 screenshot::prepare();
55575949 }
55585950 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 }
55595955 if let Some(script) = std::env::var_os("SNOWBOUND_REPLAY") {
55605956 replay(std::fs::read_to_string(script)?, event_loop.create_proxy())?;
55615957 }
......@@ -5568,6 +5964,7 @@ fn launch() -> Result<(), Box<dyn Error>> {
55685964 saved,
55695965 cache,
55705966 }),
5967 opening,
55715968 substitutes,
55725969 screenshot,
55735970 state: None,
......@@ -5589,6 +5986,26 @@ mod tests {
55895986 use super::*;
55905987 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
55926009 /// The ops an editing session records reach the section through `apply` and read back
55936010 /// as the editor's page.
55946011 #[test]
......@@ -5778,4 +6195,80 @@ mod tests {
57786195 theirs.close().unwrap();
57796196 std::fs::remove_dir_all(&directory).unwrap();
57806197 }
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 }
57816274}
crates/snowbound/src/manage.rs+395-116
......@@ -1,7 +1,7 @@
11//! Changes to notebooks, sections, section groups and pages, as OneNote 2010 makes them:
22//! 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};
55use canvas::template::Template;
66use notebook::session::Notebook;
77use onestore::{
......@@ -9,7 +9,7 @@ use onestore::{
99 op::{Edit, Op, PageOp, SectionOp},
1010 page::{Image, Page, PageObject},
1111};
12use std::{error::Error, sync::Arc};
12use std::{error::Error, path::Path, sync::Arc};
1313
1414/// A change to a notebook's sections and groups, by catalog path.
1515pub enum Structure {
......@@ -37,6 +37,44 @@ pub enum Structure {
3737 folder: String,
3838 paths: Vec<String>,
3939 },
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))
4078}
4179
4280/// `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 {
69107}
70108
71109impl State {
72 /// A page OneNote 2010 would create now: titled, with its date and time.
73 fn dated_page(&self, before: Option<ExGuid>) -> Result<PageCreation, Box<dyn Error>> {
74 let creation = PageCreation::new(before, Some(""), &self.author)?;
110 /// A page OneNote 2010 would create now: titled in the Default font's face, with its date
111 /// and time.
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 )?;
75120 let [date, time] = platform::date_text(creation.created());
76121 Ok(creation.dated(&date, &time)?)
77122 }
......@@ -113,6 +158,12 @@ impl State {
113158 ops,
114159 },
115160 )?;
161 let shown = session.space;
162 self.made(Change::Delete {
163 pages: vec![space],
164 show: Some(shown),
165 created: true,
166 });
116167 self.title_focus = Some(space);
117168 self.commands.push(Command::OpenPage(space));
118169 Ok(())
......@@ -211,23 +262,292 @@ impl State {
211262 self.create_notebook(root)
212263 }
213264
214 /// Opens the notebooks in the app's own iCloud Drive folder, or makes one there.
215 pub(crate) fn use_icloud(&mut self) -> Result<(), Box<dyn Error>> {
216 let folder = crate::icloud::folder().ok_or("iCloud Drive is off")?;
217 let mut notebooks: Vec<std::path::PathBuf> = std::fs::read_dir(&folder)?
218 .flatten()
219 .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))
220 .filter(|entry| !entry.file_name().to_string_lossy().starts_with('.'))
221 .map(|entry| entry.path())
265 /// New iCloud Notebook: asks for a name, the first free of "iCloud Notebook", "iCloud
266 /// Notebook 2"…, for a notebook at the top of the app's iCloud Drive folder.
267 pub(crate) fn new_icloud_notebook(&mut self) {
268 let taken: Vec<String> = self
269 .notebooks
270 .iter()
271 .map(|library| library.name.clone())
222272 .collect();
223 notebooks.sort();
224 if notebooks.is_empty() {
225 return self.create_notebook(folder.join("My Notebook"));
273 self.new_icloud = Some(Naming {
274 name: unused(&taken, "iCloud Notebook", false),
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");
226315 }
227 for notebook in notebooks {
228 self.open_notebook(notebook.to_string_lossy().into_owned(), None);
316 ui.leaf(
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 );
229353 }
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 });
231551 }
232552
233553 /// Creates a notebook in the new folder `root` and opens it.
......@@ -252,6 +572,7 @@ impl State {
252572 /// folders go with the account. Replicas holding edits stay, the open section's also
253573 /// exported as a recovery archive; signing in again and reopening publishes them.
254574 pub(crate) fn icloud_account_changed(&mut self) {
575 eprintln!("DEBUG account changed");
255576 let closing: Vec<Arc<Library>> = self
256577 .notebooks
257578 .iter()
......@@ -281,13 +602,14 @@ impl State {
281602 for library in closing {
282603 self.close_notebook(&library);
283604 }
284 crate::icloud::look_up(notify(self.proxy.clone()));
605 crate::icloud::look_up(crate::icloud_listed(self.proxy.clone()));
285606 }
286607
287608 /// Closes `library`: its files stay, its offline copies go unless edits wait in them,
288609 /// and the sidebar and the next launch leave it out.
289610 pub(crate) fn close_notebook(&mut self, library: &Arc<Library>) {
290611 self.notebooks.retain(|open| !Arc::ptr_eq(open, library));
612 self.undo.close(&library.location);
291613 if let Some(background) = &library.background {
292614 background.discard();
293615 }
......@@ -336,6 +658,8 @@ impl State {
336658 .map(|session| session.tabs[session.tab].path.clone());
337659 let notify = notify(self.proxy.clone());
338660 self.load(move || {
661 // A section kept open would hold a file the change moves.
662 library.close_kept();
339663 let mut notebook = library.reopen()?;
340664 let names = |notebook: &Notebook, folder: &str| -> Vec<String> {
341665 let mut folders = vec![notebook.catalog()];
......@@ -356,6 +680,11 @@ impl State {
356680 }
357681 Vec::new()
358682 };
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 );
359688 // The open section where the change leaves it, and the section to show.
360689 let (followed, created) = match change {
361690 Structure::NewSection { folder } => {
......@@ -391,10 +720,36 @@ impl State {
391720 notebook.reorder(&folder, &paths)?;
392721 (current, None)
393722 }
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 }
394746 };
395747 let fresh = created.is_some();
396748 let open = created.or(followed.clone());
397749 let library = Arc::new(library.with(notebook));
750 if stays && followed.is_none() {
751 return Ok(Loaded::Library(library, None));
752 }
398753 let Some(path) = shown(&library, open) else {
399754 return Ok(Loaded::Library(library, None));
400755 };
......@@ -416,115 +771,38 @@ impl State {
416771 /// Deletes pages of the open section as OneNote 2010 does: each goes to the notebook's
417772 /// recycle bin, then leaves the section. A section left without pages gains a new one.
418773 pub(crate) fn delete_pages(&mut self, spaces: Vec<ExGuid>) -> Result<(), Box<dyn Error>> {
419 self.persist()?;
420 let session = self.session.as_ref().ok_or("No section is open")?;
421 let pages: Vec<Page> = spaces
422 .iter()
423 .map(|space| session.section.page(*space))
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(())
774 self.change_pages(Change::Delete {
775 pages: spaces,
776 show: None,
777 created: false,
778 })
470779 }
471780
472781 /// Moves page `space` of the open section to the end of the section at catalog `path`,
473782 /// as OneNote moves a page dropped on a section's tab: the page keeps its identity,
474783 /// title, date and content there and leaves this section.
475784 pub(crate) fn move_page(&mut self, space: ExGuid, path: String) -> Result<(), Box<dyn Error>> {
476 self.persist()?;
477785 let session = self.session.as_ref().ok_or("No section is open")?;
478 let import = notebook::session::moved(&session.section.page(space)?, &self.author)?;
479 let at = session
480 .pages
481 .iter()
482 .position(|(listed, ..)| *listed == space)
483 .unwrap_or_default();
484 let next = session.pages[at + 1..]
485 .iter()
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(())
786 let target = session
787 .library
788 .section_identity(&path)
789 .ok_or("That section is not in the notebook")?;
790 self.change_pages(Change::Send {
791 page: space,
792 target,
793 })
522794 }
523795
524796 /// Moves or indents pages of the open section, in order.
525797 pub(crate) fn edit_pages(&mut self, edits: Vec<PageEdit>) -> Result<(), Box<dyn Error>> {
526798 self.persist()?;
527799 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();
528806 session.section.apply(
529807 &self.author,
530808 Edit {
......@@ -533,6 +811,7 @@ impl State {
533811 },
534812 )?;
535813 session.pages = session.section.pages()?;
814 self.made(Change::Arrange { order, moved });
536815 Ok(())
537816 }
538817}
crates/snowbound/src/meeting.rs+1
......@@ -102,6 +102,7 @@ pub fn content(page: &Page) -> Result<Page, Box<dyn Error>> {
102102 Definition {
103103 kind: Kind::Style {
104104 name: Some("p".into()),
105 next: None,
105106 },
106107 format: Format {
107108 language: None,
crates/snowbound/src/menubar.rs+34-7
......@@ -68,7 +68,6 @@ const MENUS: &[Item] = &[
6868 C(Id::ShowNotebook),
6969 S,
7070 C(Id::ExportPdf),
71 C(Id::ExportSectionPdf),
7271 S,
7372 C(Id::Print),
7473 ],
......@@ -158,6 +157,31 @@ const MENUS: &[Item] = &[
158157 Item::Menu(
159158 "Format",
160159 &[
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,
161185 Item::Menu("Font", &[Item::Fonts]),
162186 Item::Menu("Size", &[Item::Sizes]),
163187 S,
......@@ -264,11 +288,10 @@ fn menu(
264288 for item in items {
265289 match item {
266290 Item::Command(id) => {
267 let command = commands::command(*id);
268291 choose(
269292 &Choice::Command(*id),
270 command.title,
271 command.mac.first().copied(),
293 commands::command(*id).title,
294 commands::chords(*id).first().copied(),
272295 );
273296 }
274297 Item::System(title, action, chord) => {
......@@ -320,8 +343,12 @@ pub fn tags(mtm: MainThreadMarker, target: &AnyObject, menu: &NSMenu, tags: &[No
320343 menu.addItem(&NSMenuItem::separatorItem(mtm));
321344 }
322345 for id in [Id::CustomizeTags, Id::RemoveTags, Id::FindTags] {
323 let command = commands::command(id);
324 choose(menu, id, command.title, command.mac.first().copied());
346 choose(
347 menu,
348 id,
349 commands::command(id).title,
350 commands::chords(id).first().copied(),
351 );
325352 }
326353}
327354
......@@ -451,7 +478,7 @@ mod tests {
451478 }
452479 // The keyboard's first chord for each command is on its menu item.
453480 for command in commands::COMMANDS {
454 let Some(chord) = command.mac.first() else {
481 let Some(chord) = commands::chords(command.id).first().copied() else {
455482 continue;
456483 };
457484 let line = tree
crates/snowbound/src/menus.rs+620-116
......@@ -1,7 +1,12 @@
11//! Context menus on pages, sections, section groups and notebooks, with OneNote 2010's
22//! 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};
510use onestore::{ExGuid, PageEdit};
611use std::sync::Arc;
712use ui::{Anchor, Id, popup::Item};
......@@ -46,12 +51,94 @@ impl Drag {
4651 }
4752}
4853
49/// What a context menu was opened on.
54/// What a context menu, or the palette's actions, are on.
55#[derive(Clone)]
5056pub enum Target {
51 Page(ExGuid),
52 Section { library: Arc<Library>, path: String },
53 Group { library: Arc<Library>, path: String },
57 /// A page of a section, open or not.
58 Page {
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 },
5471 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]>,
55142}
56143
57144pub fn id() -> Id {
......@@ -134,115 +221,328 @@ impl State {
134221 self.menu = None;
135222 return;
136223 }
137 let item = |text| Item {
138 text,
139 ..Default::default()
224 let (target, point) = (target.clone(), *point);
225 let mut actions: Vec<(Action, Item)> = self.actions(&target);
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,
140233 };
141 let items: Vec<Item> = match target {
142 Target::Page(space) => {
143 let pages = self
144 .session
145 .as_ref()
146 .map_or(&[][..], |session| &session.pages[..]);
234 if let Some((_, text, icon)) = &status {
235 actions[0].1.separated = true;
236 let item = Item {
237 text,
238 icon: Some(*icon),
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;
147295 let at = pages.iter().position(|(listed, ..)| listed == space);
148296 let level = at.map_or(1, |at| pages[at].2);
149297 let above = at
150298 .and_then(|at| at.checked_sub(1))
151299 .map_or(0, |above| pages[above].2);
152 let (versions, shown) = self.session.as_ref().map_or((false, false), |session| {
153 (
154 !session.page_versions(*space).is_empty(),
155 session.shown_history == Some(*space),
156 )
157 });
300 let shown = session.shown_history == Some(*space);
301 let versions = if shown {
302 "Hide Page Versions"
303 } else {
304 "Show Page Versions"
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);
158348 vec![
159 item("Delete"),
160 Item {
161 separated: true,
162 ..item("Copy Link to Page")
163 },
164 Item {
165 separated: true,
166 ..item("New Page")
167 },
168 // OneNote offers this beside New Page, which is a plain + here.
169 item("New Subpage"),
170 Item {
171 separated: true,
172 disabled: level > above || level >= 3,
173 ..item("Make Subpage")
174 },
175 Item {
176 disabled: level <= 1,
177 ..item("Promote Subpage")
178 },
179 Item {
180 separated: true,
181 disabled: !versions,
182 ..item(if shown {
183 "Hide Page Versions"
184 } else {
185 "Show Page Versions"
186 })
187 },
349 item(Action::Rename, "Rename…", false, false),
350 item(
351 Action::Sync,
352 "Sync This Notebook Now",
353 library.background.is_none(),
354 false,
355 ),
356 // The app's iCloud Drive folder lists every notebook in it.
357 item(
358 Action::Close,
359 "Close This Notebook",
360 crate::manage::in_icloud_folder(&library.location),
361 false,
362 ),
363 item(Action::CopyLink, "Copy Link to Notebook", false, true),
364 item(Action::NewSection, "New Section", false, true),
365 item(Action::NewGroup, "New Section Group", false, false),
366 item(
367 Action::NewNotebook,
368 commands::command(commands::Id::NewNotebook).title,
369 false,
370 false,
371 ),
372 item(
373 Action::Raise(true),
374 "Move Up",
375 listed.is_none_or(|at| at == 0),
376 true,
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 ),
188397 ]
189398 }
190 Target::Section { .. } | Target::Group { .. } => vec![
191 item("Rename"),
192 item("Delete"),
193 Item {
194 separated: true,
195 ..item("New Section")
196 },
197 item("New Section Group"),
198 ],
199 Target::Notebook(_) => vec![
200 item("New Section"),
201 item("New Section Group"),
202 Item {
203 separated: true,
204 ..item("Close This Notebook")
205 },
206 ],
207 };
208 let Some(chosen) = ui::popup::menu(&mut self.ui, id(), Anchor::Point(*point), &items, None)
209 else {
210 return;
399 Target::Closed(_) | Target::Server(_) => {
400 vec![item(Action::Delete, "Remove from Recent", false, false)]
401 }
402 Target::Command(_) => Vec::new(),
403 }
404 }
405
406 /// Builds menu `id` of `actions` on `target` at `anchor`, under a filter field showing
407 /// `filter` when given, with Move's places in its submenu: the action chosen.
408 pub(crate) fn action_menu(
409 &mut self,
410 id: Id,
411 anchor: Anchor,
412 filter: Option<&str>,
413 target: &Target,
414 actions: &[(Action, Item)],
415 ) -> Option<Action> {
416 let items: Vec<Item> = actions.iter().map(|(_, item)| *item).collect();
417 let chosen = ui::popup::menu(&mut self.ui, id, anchor, &items, filter);
418 let places = id.child("move");
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
211439 };
212 let Some((target, _)) = self.menu.take() else {
213 return;
440 let colored = match target {
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,
214465 };
215 let command = match (target, items[chosen].text) {
216 (Target::Page(space), "Delete") => Some(Command::DeletePages(vec![space])),
217 (Target::Page(space), "Copy Link to Page") => {
218 let copied = self
219 .page_link(space, None)
220 .and_then(|link| self.clipboard.set_text(link));
221 if let Err(error) = copied {
222 eprintln!("Copying the link failed: {error}");
223 }
466 chosen
467 .map(|index| actions[index].0.clone())
468 .or(moved)
469 .or(colored)
470 }
471
472 /// Where Move takes `target`: the other sections of a page's folder, or the folders a
473 /// section or group may move into.
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);
224541 None
225542 }
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 }),
243543 (
244544 Target::Section { library, path } | Target::Group { library, path },
245 "Rename",
545 Action::Rename,
246546 ) => {
247547 self.rename(crate::rename::Target::Entry {
248548 library,
......@@ -251,37 +551,162 @@ impl State {
251551 });
252552 None
253553 }
254 (Target::Section { library, path }, "Delete") => platform::confirm(
554 (Target::Section { library, path }, Action::Delete) => platform::confirm(
255555 "Are you sure you want to move this section to this notebook's Recycle Bin?",
256556 &path,
257557 "Cancel",
258558 "Delete",
259559 )
260560 .then(|| Command::Structure(library, Structure::Delete { path })),
261 (Target::Group { library, path }, "Delete") => platform::confirm(
561 (Target::Group { library, path }, Action::Delete) => platform::confirm(
262562 "Are you sure you want to move the sections in this section group to this notebook's Recycle Bin?",
263563 path.rsplit('/').next().unwrap_or_default(),
264564 "Cancel",
265565 "Delete",
266566 )
267567 .then(|| Command::Structure(library, Structure::Delete { path })),
268 (Target::Section { library, path }, text) => {
269 let folder = folder(&path);
270 Some(Command::Structure(library, new(text, folder)))
568 (
569 Target::Section { library, path } | Target::Group { library, path },
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
271629 }
272 (Target::Group { library, path }, text) => {
273 Some(Command::Structure(library, new(text, path)))
630 (Target::Notebook(library), Action::Raise(up)) => {
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
274638 }
275 (Target::Notebook(library), "Close This Notebook") => {
276 Some(Command::CloseNotebook(library))
639 (Target::Notebook(library), action) => {
640 new(&action, String::new()).map(|structure| Command::Structure(library, structure))
277641 }
278 (Target::Notebook(library), text) => {
279 Some(Command::Structure(library, new(text, String::new())))
642 (Target::Closed(location), Action::Delete) => {
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
280656 }
281657 };
282658 self.commands.extend(command);
283659 }
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
285710 /// The section tab, of those built as `row`, under the pointer while a page is dragged
286711 /// there, which a drop moves the page into.
287712 pub(crate) fn page_drop(&self, row: Id) -> Option<usize> {
......@@ -472,11 +897,90 @@ impl State {
472897 }
473898}
474899
475/// The new section or group a menu's `text` asks for in `folder`.
476fn new(text: &str, folder: String) -> Structure {
477 if text == "New Section Group" {
478 Structure::NewGroup { folder }
479 } else {
480 Structure::NewSection { folder }
900/// The file of the section at catalog `path` on this computer; none on a server reached
901/// without a mount.
902fn section_file(library: &Library, path: &str) -> Option<std::path::PathBuf> {
903 Some(library.folder()?.join(path))
904}
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();
481985 }
482986}
crates/snowbound/src/navigation.rs+113-23
......@@ -1,33 +1,42 @@
11//! 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
45use super::*;
5use std::time::Duration;
6use canvas::search::Index;
7use std::{collections::HashSet, time::Duration};
68use winit::event::TouchPhase;
79
810/// 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)]
1012pub struct Place {
1113 pub notebook: String,
1214 pub section: String,
1315 pub page: ExGuid,
1416}
1517
16/// Pages visited before and after the one shown.
18/// Pages visited before and after the one shown, and those shown lately.
1719#[derive(Default)]
1820pub struct Trail {
1921 back: Vec<Place>,
2022 here: Option<Place>,
2123 forward: Vec<Place>,
24 /// Each page shown lately once, latest first, kept between launches.
25 pub recent: Vec<Place>,
2226}
2327
2428/// Places Back keeps, at most.
2529const KEPT: usize = 100;
30/// Pages `Trail::recent` keeps, at most.
31const RECENT: usize = 8;
2632
2733impl Trail {
2834 /// Notes `place` shown. Arriving anywhere but where Back or Forward went drops the pages
2935 /// ahead, as a browser does.
3036 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);
3140 if self.here.as_ref() == Some(&place) {
3241 return;
3342 }
......@@ -59,17 +68,58 @@ impl Trail {
5968 pub fn open(&self) -> [bool; 2] {
6069 [!self.back.is_empty(), !self.forward.is_empty()]
6170 }
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 }
6288}
6389
6490impl 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.
6692 pub(crate) fn visited(&mut self) {
67 if let Some(session) = &self.session {
68 self.trail.visit(Place {
69 notebook: session.library.location.clone(),
70 section: session.tabs[session.tab].path.clone(),
71 page: session.space,
72 });
93 let Some(session) = &self.session else {
94 return;
95 };
96 let place = Place {
97 notebook: session.library.location.clone(),
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));
73123 }
74124 }
75125
......@@ -87,13 +137,6 @@ impl State {
87137 let Some(place) = self.trail.step(forward, exists) else {
88138 return;
89139 };
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 }
97140 let Some(library) = self
98141 .notebooks
99142 .iter()
......@@ -101,11 +144,7 @@ impl State {
101144 else {
102145 return;
103146 };
104 let library = Arc::clone(library);
105 self.last_pages
106 .insert(library.key(&place.section), place.page);
107 self.commands
108 .push(Command::OpenSection(library, place.section));
147 self.go(Arc::clone(library), place.section, place.page);
109148 }
110149}
111150
......@@ -228,6 +267,57 @@ mod tests {
228267 assert_eq!(trail.open(), [true, false]);
229268 }
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
231321 #[test]
232322 fn back_keeps_its_last_hundred_places() {
233323 let mut trail = Trail::default();
crates/snowbound/src/options.rs+674-206
......@@ -1,14 +1,14 @@
1//! OneNote's Options dialog: pages of settings listed on the left as OneNote 2010 lists
2//! them, grouped under the same headings, each kept when OK is chosen.
1//! OneNote's Options dialog as one scrolling list of sections (`resources/settings.md`):
2//! indexed on the left, filtered by the search field above, each choice kept when OK is
3//! chosen.
34
45use crate::{State, platform, settings::ColorScheme, update};
56use 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};
79use winit::keyboard::NamedKey;
810
9const WIDTH: f32 = 640.0;
10/// The pages' area, the same for every page so the dialog keeps its size.
11const HEIGHT: f32 = 320.0;
11const WIDTH: f32 = 680.0;
1212const NAV: f32 = 150.0;
1313/// The column the fields' labels share.
1414const LABEL: f32 = 104.0;
......@@ -17,38 +17,214 @@ const SCHEMES: [(ColorScheme, &str); 3] = [
1717 (ColorScheme::Light, "Light"),
1818 (ColorScheme::Dark, "Dark"),
1919];
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)]
22enum Page {
23 General,
24 Display,
25 SaveBackup,
26 Advanced,
39struct Row {
40 label: &'static str,
41 /// Other words search finds the row by.
42 keywords: &'static str,
43 control: Control,
2744}
2845
29impl Page {
30 const ALL: [(Page, &str); 4] = [
31 (Page::General, "General"),
32 (Page::Display, "Display"),
33 (Page::SaveBackup, "Save & Backup"),
34 (Page::Advanced, "Advanced"),
35 ];
46enum Control {
47 /// A check box before the label, bound to a choice.
48 Check(fn(&mut Options) -> &mut bool),
49 /// The label in the labels' column, then what the function builds.
50 Field(fn(&mut State, &mut Options)),
51 /// What `build` makes across the row, unlabelled, from the search's words; `finds` tells
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 },
3657}
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.
39200pub 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>,
41204 user_name: String,
42205 color_scheme: ColorScheme,
43 light_pages: bool,
206 pages_match: bool,
207 hide_spelling: bool,
44208 automatic_updates: bool,
45209 pen_pressure: bool,
210 default_font: DefaultFont,
211 page_tabs_left: bool,
212 navigation_bar_left: bool,
213 pub(crate) keyboard: crate::keys::Keyboard,
46214}
47215
48216fn id() -> Id {
49217 Id::ROOT.child("options")
50218}
51219
220fn search() -> Id {
221 id().child("search")
222}
223
224fn list() -> Id {
225 id().child("list")
226}
227
52228fn user_name() -> Id {
53229 id().child("user-name")
54230}
......@@ -57,45 +233,107 @@ fn schemes() -> Id {
57233 id().child("color-schemes")
58234}
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
60287impl State {
61288 pub(crate) fn open_options(&mut self) {
62289 self.options = Some(Options {
63 page: Page::General,
290 query: String::new(),
291 picked: None,
64292 user_name: self.author.clone(),
65293 color_scheme: self.color_scheme,
66 light_pages: self.light_pages,
294 pages_match: !self.light_pages,
295 hide_spelling: self.hide_spelling,
67296 automatic_updates: self.updates.automatic(),
68297 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(),
69302 });
70303 self.ui.open_popup(id());
71 self.ui.set_focus(Some(user_name()));
304 self.ui.set_focus(Some(search()));
72305 }
73306
74 /// Builds the Options dialog while it is open. OK, or Enter in a field, keeps its choices
75 /// with a non-empty user name; Cancel, Escape or a press outside leave them.
307 /// Builds the Options dialog while it is open. OK, or Enter in the user name, keeps its
308 /// choices with a non-empty user name; Cancel, Escape or a press outside leave them.
76309 pub(crate) fn options_dialog(&mut self) {
77 let Some(options) = &mut self.options else {
310 let Some(mut options) = self.options.take() else {
78311 return;
79312 };
80 let ui = &mut self.ui;
81 if !ui.popup_open(id()) {
82 self.options = None;
313 if !self.ui.popup_open(id()) {
83314 return;
84315 }
85 let theme = ui.theme.clone();
86 let row = theme.font_size * 2.0;
316 let theme = self.ui.theme.clone();
317 let row = row_height(&theme);
318 let ui = &mut self.ui;
87319 let entered = ui::popup::navigation(ui, &[user_name()], &[NamedKey::Enter])
88320 .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 };
89326 ui.open_as(
90327 id(),
91328 Spec {
92329 axis: Axis::Y,
93 size: [px(WIDTH), children()],
330 size: [px(WIDTH), loose],
94331 fill: Some(theme.popup),
95332 border: Some(theme.chip),
96333 shadow: Some(theme.shadow),
97334 radius: 8.0,
98335 pad: [6.0, 6.0],
336 gap: 6.0,
99337 anchor: Some(Anchor::Dialog),
100338 role: Some(Role::Dialog),
101339 ..Spec::default()
......@@ -104,15 +342,59 @@ impl State {
104342 if let Some(node) = ui.access(id()) {
105343 node.set_label("Options");
106344 }
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();
107388 ui.open(
108389 "body",
109390 Spec {
110 size: [fill(), px(HEIGHT)],
391 size: [fill(), loose],
392 gap: 6.0,
111393 ..Spec::default()
112394 },
113395 );
114 let pages = ui.open(
115 "pages",
396 let index = ui.open(
397 "index",
116398 Spec {
117399 axis: Axis::Y,
118400 size: [px(NAV), fill()],
......@@ -124,177 +406,175 @@ impl State {
124406 ..Spec::default()
125407 },
126408 );
127 if let Some(node) = ui.access(pages) {
409 if let Some(node) = ui.access(index) {
128410 node.set_orientation(accesskit::Orientation::Vertical);
129411 }
130 for (page, name) in Page::ALL {
131 let shown = page == options.page;
132 let spec = Spec {
133 flags: Flags::CLICKABLE,
134 size: [fill(), px(row)],
135 text: Some(name),
136 bold: shown,
137 fill: shown.then(|| theme.hover()),
138 hover_fill: Some(theme.hover()),
139 radius: 4.0,
140 pad: [8.0, 0.0],
141 role: Some(Role::Tab),
142 ..Spec::default()
143 };
144 let tab = ui.open(name, spec);
412 // The section in the top quarter of the list, or the last once the list is scrolled to
413 // its end.
414 let current = options.picked.or_else(|| {
415 let [_, top, _, bottom] = ui.rect(list())?;
416 let starts = |id| ui.rect(id).map(|rect: [f32; 4]| rect[1]);
417 let end = starts(list().child("top")).is_some_and(|start| start < top - 1.0)
418 && starts(list().child("end")).is_some_and(|end| end <= bottom + 1.0);
419 sections
420 .iter()
421 .rfind(|section| {
422 end || starts(section_id(section))
423 .is_some_and(|start| start <= top + (bottom - top) / 4.0)
424 })
425 .or(sections.first())
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 );
145445 if let Some(node) = ui.access(tab) {
146 node.set_selected(shown);
446 node.set_selected(marked);
147447 }
148448 ui.close();
149449 if ui.signal(tab).clicked {
150 options.page = page;
450 options.picked = Some(section.name);
451 ui.scroll_to(list(), section_id(section));
151452 }
152453 }
153454 ui.close();
154 ui.open(
155 "page",
455 ui.open_as(
456 list(),
156457 Spec {
458 flags: Flags::SCROLL | Flags::CLIP,
157459 axis: Axis::Y,
158 size: [fill(), fill()],
159 pad: [16.0, 10.0],
160 gap: 6.0,
460 size: [fill(), loose],
461 pad: [10.0, 0.0],
161462 ..Spec::default()
162463 },
163464 );
164 match options.page {
165 Page::General => {
166 heading(ui, &theme, "User Interface Options");
167 field(ui, "Appearance:", |ui| {
168 let combo = ui.id("combo");
169 let current = SCHEMES
170 .iter()
171 .find(|(scheme, _)| *scheme == options.color_scheme)
172 .map_or("", |(_, name)| name);
173 ui::shell::combo(ui, "combo", "Appearance", current, 140.0, schemes(), true);
174 let items = SCHEMES.map(|(scheme, name)| Item {
175 text: name,
176 checked: Some(scheme == options.color_scheme),
177 current: scheme == options.color_scheme,
178 ..Item::default()
179 });
180 let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default());
181 if let Some(index) = ui::popup::menu(ui, schemes(), anchor, &items, None) {
182 options.color_scheme = SCHEMES[index].0;
183 }
184 });
185 heading(ui, &theme, "Personalize");
186 field(ui, "User name:", |ui| {
187 ui::text_field(
188 ui,
189 user_name(),
190 &mut options.user_name,
191 "",
192 Spec {
193 size: [px(260.0), px(row)],
194 fill: Some(theme.base),
195 border: Some(theme.accent),
196 radius: 4.0,
197 pad: [6.0, 0.0],
198 ..Spec::default()
199 },
200 );
201 if let Some(node) = ui.access(user_name()) {
202 node.set_label("User name");
203 }
204 });
205 heading(ui, &theme, "Updates");
206 ui.leaf(
207 "version",
208 Spec {
209 size: [fill(), px(row)],
210 text: Some(&update::describe_running()),
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;
465 if !ui.signal(list()).events.is_empty() {
466 options.picked = None;
467 }
468 ui.leaf(
469 "top",
470 Spec {
471 size: [fill(), px(0.0)],
472 ..Spec::default()
473 },
474 );
475 if sections.is_empty() {
476 ui.leaf(
477 "none",
478 Spec {
479 size: [fill(), px(row * 2.0)],
480 text: Some("No options match"),
481 color: Some(theme.text_dim),
482 center: true,
483 ..Spec::default()
484 },
485 );
486 }
487 for section in &sections {
488 self.ui.open_as(
489 section_id(section),
490 Spec {
491 axis: Axis::Y,
492 size: [fill(), ui::children()],
493 pad: [0.0, 4.0],
494 gap: 4.0,
495 ..Spec::default()
496 },
497 );
498 title(
499 &mut self.ui,
500 &theme,
501 section.name,
502 lit(&query, section.name),
503 );
504 for group in section.groups {
505 let rows: Vec<&Row> = group
506 .rows
507 .iter()
508 .filter(|row| shown(&query, section, group, row))
509 .collect();
510 if rows.is_empty() {
511 continue;
251512 }
252 }
253 Page::Advanced => {
254 heading(ui, &theme, "Pen");
255 if ui::check_box(
256 ui,
257 "pen-pressure",
258 "Use pen pressure sensitivity",
259 options.pen_pressure,
260 )
261 .clicked
262 {
263 options.pen_pressure = !options.pen_pressure;
513 if !group.heading.is_empty() {
514 heading(
515 &mut self.ui,
516 &theme,
517 group.heading,
518 lit(&query, group.heading),
519 );
264520 }
265 }
266 Page::SaveBackup => {
267 heading(ui, &theme, "Cache file location");
268 let cache = &self.cache;
269 field(ui, "Path:", |ui| {
270 ui.leaf(
271 "path",
521 for row in rows {
522 let highlight = !matches!(row.control, Control::Block { .. })
523 && (lit(&query, row.label) || lit(&query, row.keywords));
524 self.ui.open(
525 row.label,
272526 Spec {
273 flags: Flags::CLIP,
274 size: [fill(), px(row)],
275 text: Some(&cache.to_string_lossy()),
276 color: Some(theme.text_dim),
527 size: [fill(), ui::children()],
528 fill: highlight.then(|| ui::mix(theme.popup, theme.accent, 0.18)),
529 radius: 4.0,
530 pad: [8.0, 0.0],
531 gap: 8.0,
277532 ..Spec::default()
278533 },
279534 );
280 let label = if cfg!(target_os = "macos") {
281 "Show in Finder"
282 } else {
283 "Open Folder"
284 };
285 if ui::button(ui, "reveal", label).clicked {
286 platform::reveal(cache);
535 match row.control {
536 Control::Check(choice) => {
537 let checked = choice(&mut options);
538 if ui::check_box(&mut self.ui, "check", row.label, *checked).clicked {
539 *checked = !*checked;
540 }
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 }
287557 }
288 });
558 self.ui.close();
559 }
289560 }
561 self.ui.close();
290562 }
563 let ui = &mut self.ui;
564 ui.leaf(
565 "end",
566 Spec {
567 size: [fill(), px(6.0)],
568 ..Spec::default()
569 },
570 );
291571 ui.close();
292572 ui.close();
293573 ui.open(
294574 "buttons",
295575 Spec {
296 size: [fill(), children()],
297 pad: [10.0, 8.0],
576 size: [fill(), ui::children()],
577 pad: [10.0, 4.0],
298578 gap: 8.0,
299579 ..Spec::default()
300580 },
......@@ -314,56 +594,244 @@ impl State {
314594 if ok && !name.is_empty() {
315595 self.author = name.to_owned();
316596 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;
318599 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;
319603 self.updates.set_automatic(options.automatic_updates);
604 self.show_spelling();
320605 self.follow_color_scheme();
606 self.install_keymap(options.keyboard.keymap);
321607 self.save_settings();
322608 } else if !cancel {
609 self.options = Some(options);
323610 return;
324611 }
325612 self.ui.close_popup(id());
326 self.options = None;
327613 }
328614}
329615
330/// A group's heading on a band, as OneNote heads the groups of its Options pages.
331fn heading(ui: &mut Ui, theme: &Theme, text: &str) {
616fn section_id(section: &Section) -> Id {
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) {
332626 ui.leaf(
333 text,
627 "title",
334628 Spec {
335 size: [fill(), px(theme.font_size * 1.8)],
629 size: [fill(), px(theme.font_size * 2.2)],
336630 text: Some(text),
631 font_size: Some(theme.font_size * 1.25),
337632 bold: true,
338 fill: Some(ui::mix(theme.panel, theme.chip, 0.4)),
339 radius: 4.0,
340 pad: [8.0, 0.0],
633 color: lit.then_some(theme.accent),
341634 role: Some(Role::Heading),
342635 ..Spec::default()
343636 },
344637 );
345638}
346639
347/// A row of `label` in the labels' column and the controls `build` adds after it.
348fn field(ui: &mut Ui, label: &str, build: impl FnOnce(&mut Ui)) {
349 let row = ui.theme.font_size * 2.0;
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 );
640/// A group's heading on a band, as OneNote heads the groups of its Options pages.
641fn heading(ui: &mut Ui, theme: &Theme, text: &str, lit: bool) {
642 let band = ui::mix(theme.panel, theme.chip, 0.4);
359643 ui.leaf(
360 "label",
644 text,
361645 Spec {
362 size: [px(LABEL), px(row)],
363 text: Some(label),
646 size: [fill(), px(theme.font_size * 1.8)],
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),
364657 ..Spec::default()
365658 },
366659 );
367 build(ui);
368 ui.close();
660}
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 }
369837}
crates/snowbound/src/palette.rs+249-73
......@@ -1,14 +1,18 @@
1//! The palette: the open notebooks, their sections and every page the search index holds to
2//! go to, or after a leading `>` the command table's commands, narrowed as typed.
1//! The palette: the pages and sections shown lately, then the open notebooks, their sections
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
45use crate::{
5 Command, Library, State, art,
6 State, art,
67 commands::{self, Choice},
78 library,
9 menus::{self, Action, Target},
810};
9use onestore::ExGuid;
1011use std::sync::Arc;
11use ui::{Id, popup::Item};
12use ui::{
13 Anchor, Id,
14 popup::{Item, Pick},
15};
1216
1317/// What the palette's query starts with to list commands.
1418pub(crate) const COMMANDS: &str = ">";
......@@ -17,20 +21,16 @@ pub(crate) fn id() -> Id {
1721 Id::ROOT.child("palette")
1822}
1923
20enum Target {
21 Command(commands::Id),
22 Notebook(String),
23 Section(Arc<Library>, String),
24 Page(String, ExGuid),
25}
26
2724/// A row: its text, the dim text after it, its icon and what it runs; a heading runs nothing.
25#[derive(Default)]
2826struct Row {
2927 text: String,
3028 after: String,
3129 icon: Option<&'static [&'static str]>,
3230 tint: Option<[f32; 4]>,
3331 disabled: bool,
32 /// Repeats a row listed further on, as the recent ones do.
33 repeated: bool,
3434 target: Option<Target>,
3535}
3636
......@@ -38,11 +38,7 @@ impl Row {
3838 fn heading(text: &str) -> Self {
3939 Self {
4040 text: text.to_owned(),
41 after: String::new(),
42 icon: None,
43 tint: None,
44 disabled: false,
45 target: None,
41 ..Self::default()
4642 }
4743 }
4844
......@@ -55,13 +51,15 @@ impl Row {
5551 disabled: self.disabled,
5652 heading: self.target.is_none(),
5753 separated: self.target.is_none(),
54 repeated: self.repeated,
5855 ..Item::default()
5956 }
6057 }
6158}
6259
6360impl 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.
6563 pub(crate) fn palette(&mut self) {
6664 if !self.ui.popup_open(id()) {
6765 return;
......@@ -74,9 +72,9 @@ impl State {
7472 text: command.title.to_owned(),
7573 after: commands::shortcut(command.id),
7674 icon: crate::artwork(command.id),
77 tint: None,
7875 disabled: !self.status(&Choice::Command(command.id), &format).enabled,
7976 target: Some(Target::Command(command.id)),
77 ..Row::default()
8078 })
8179 .collect();
8280 commands.extend(self.tags.iter().enumerate().map(|(place, tag)| {
......@@ -88,41 +86,30 @@ impl State {
8886 tint: Some([1.0; 4]),
8987 disabled: !self.status(&Choice::Command(id), &format).enabled,
9088 target: Some(Target::Command(id)),
89 ..Row::default()
9190 }
9291 }));
93 let mut places = vec![Row::heading("Notebooks")];
92 let mut places = self.recent();
93 places.push(Row::heading("Notebooks"));
9494 places.extend(
9595 (self.notebooks.iter())
9696 .filter(|library| library.catalog().is_some())
9797 .map(|library| Row {
9898 text: library.name.clone(),
99 after: String::new(),
10099 icon: Some(art::NOTEBOOK),
101 tint: None,
102 disabled: false,
103 target: Some(Target::Notebook(library.location.clone())),
100 target: Some(Target::Notebook(Arc::clone(library))),
101 ..Row::default()
104102 }),
105103 );
106104 places.push(Row::heading("Sections"));
107105 let theme = &self.ui.theme;
108106 for library in &self.notebooks {
109107 // A section opened on its own has no catalog, and lists itself at "".
110 let mut folders: Vec<_> = library.catalog().into_iter().collect();
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());
108 let folders = menus::folders(library);
122109 let paths: Vec<&str> = if folders.is_empty() {
123110 vec![""]
124111 } else {
125 paths.collect()
112 folders.iter().map(|folder| folder.path.as_str()).collect()
126113 };
127114 for path in paths {
128115 places.extend(library.tabs(path).into_iter().map(|tab| Row {
......@@ -130,8 +117,11 @@ impl State {
130117 after: library.name.clone(),
131118 icon: Some(art::SECTION),
132119 tint: Some(theme.section(crate::section_color(tab.color)).accent),
133 disabled: false,
134 target: Some(Target::Section(Arc::clone(library), tab.path)),
120 target: Some(Target::Section {
121 library: Arc::clone(library),
122 path: tab.path,
123 }),
124 ..Row::default()
135125 }));
136126 }
137127 }
......@@ -141,57 +131,243 @@ impl State {
141131 .index
142132 .lock()
143133 .unwrap_or_else(|poison| poison.into_inner());
144 places.extend(index.entries().iter().map(|entry| {
134 places.extend(index.entries().iter().filter_map(|entry| {
145135 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)?;
146140 let mut section = library::section_name(path, &None);
147141 if section.is_empty() {
148 section = (self.notebooks.iter())
149 .find(|library| library.location == location)
150 .map_or_else(String::new, |library| library.name.clone());
142 section = library.name.clone();
151143 }
152 Row {
153 text: if entry.title.is_empty() {
154 "Untitled page".to_owned()
155 } else {
156 entry.title.clone()
157 },
144 Some(Row {
145 text: title(&entry.title),
158146 after: section,
159147 icon: Some(art::PAGE),
160 tint: None,
161 disabled: false,
162 target: Some(Target::Page(entry.section.clone(), entry.space)),
163 }
148 target: Some(Target::Page {
149 library: Arc::clone(library),
150 path: path.to_owned(),
151 space: entry.space,
152 }),
153 ..Row::default()
154 })
164155 }));
165156 drop(index);
166 let items = [&commands, &places].map(|rows| rows.iter().map(Row::item).collect::<Vec<_>>());
167 let chosen = ui::popup::palette(
157 let mut items =
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(
168164 &mut self.ui,
169165 id(),
170166 &[(COMMANDS, &items[0]), ("", &items[1])],
171167 "Search pages, sections and notebooks (type > for commands)",
172168 );
173169 let mut rows = [commands, places];
174 match chosen.and_then(|(mode, index)| rows[mode].swap_remove(index).target) {
175 Some(Target::Command(command)) => self.choose(Choice::Command(command)),
176 Some(Target::Notebook(location)) => self.open_notebook(location, None),
177 Some(Target::Section(library, path)) => {
178 self.commands.push(Command::OpenSection(library, path));
179 }
180 Some(Target::Page(section, space)) => {
181 let open = (self.session.as_ref())
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);
170 match picked {
171 Some(Pick::Run(mode, index)) => {
172 if let Some(target) = rows[mode].swap_remove(index).target {
173 let action = match target {
174 Target::Command(_) => Action::Run,
175 _ => Action::Open,
176 };
177 self.act_on(target, action);
192178 }
193179 }
180 Some(Pick::Actions(mode, index)) => {
181 self.actions = rows[mode].swap_remove(index).target;
182 }
194183 None => {}
195184 }
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()
196372 }
197373}
crates/snowbound/src/paste.rs+572-42
......@@ -1,13 +1,21 @@
11//! Pictures and files from the clipboard, and pictures inserted, as OneNote 2010 places them.
22
33use 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
611/// 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 offer
8/// a picture of copied text beside it.
12/// as text and their icons as pictures; then a page holding pictures, as its text alone
13/// would lose them; text before a picture, as Office and browsers offer a picture of copied
14/// text beside it.
915pub(crate) enum Pasted {
1016 Files(Vec<PathBuf>),
17 /// HTML holding a picture.
18 Page(String),
1119 Text(String),
1220 Picture(Vec<u8>),
1321}
......@@ -17,13 +25,39 @@ impl State {
1725 match self.clipboard.pasted() {
1826 Some(Pasted::Files(paths)) => {
1927 for path in paths {
20 // OneNote 2010 pastes a picture file as the picture (lab, 2026-09-30).
21 match std::fs::read(&path) {
22 Ok(bytes) if draw::RasterImage::measure(&bytes).is_ok() => {
23 self.insert_picture(bytes)?
24 }
25 _ => self.attach(&path, None)?,
28 self.place_file(&path, None)?;
29 }
30 }
31 Some(Pasted::Page(html)) => {
32 // Web pictures come later, so the window never waits on the network.
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);
2638 }
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 });
2761 }
2862 }
2963 Some(Pasted::Text(text)) => {
......@@ -31,15 +65,34 @@ impl State {
3165 let response = self.view.paste(&text, language)?;
3266 self.respond(response);
3367 }
34 Some(Pasted::Picture(bytes)) => self.insert_picture(bytes)?,
68 Some(Pasted::Picture(bytes)) => self.insert_picture(bytes, None)?,
3569 None => {}
3670 }
3771 Ok(())
3872 }
3973
40 /// Puts an encoded picture at the caret at the size its resolution gives it, as OneNote
41 /// 2010 inserts and pastes one.
42 pub(crate) fn insert_picture(&mut self, bytes: Vec<u8>) -> Result<(), Box<dyn Error>> {
74 /// A pasted file, or one dropped at `at`, a window point: a picture file as its picture,
75 /// as OneNote 2010 pastes one (lab, 2026-09-30), and any other file attached.
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>> {
4396 let Ok(pixels) = draw::RasterImage::measure(&bytes) else {
4497 platform::alert(
4598 "Couldn't insert the picture",
......@@ -48,12 +101,289 @@ impl State {
48101 return Ok(());
49102 };
50103 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 };
52110 self.respond(response);
53111 Ok(())
54112 }
55113}
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
57387/// A picture's size in points: its pixels at the resolution it declares, or at 96 dpi
58388/// without one, as OneNote 2010 sizes inserted and pasted pictures and pasted bitmaps
59389/// (lab, 2026-09-30).
......@@ -62,39 +392,122 @@ fn picture_size(encoded: &[u8], pixels: [u32; 2]) -> [f32; 2] {
62392 [0, 1].map(|axis| pixels[axis] as f32 * 72.0 / dpi[axis])
63393}
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.
66397fn resolution(encoded: &[u8]) -> Option<[f32; 2]> {
67 let dpi = if let Some(mut chunks) = encoded.strip_prefix(b"\x89PNG\r\n\x1a\n") {
68 loop {
69 let length = u32::from_be_bytes(chunks.get(..4)?.try_into().ok()?) as usize;
70 let kind = chunks.get(4..8)?;
71 let data = chunks.get(8..8 + length)?;
72 if kind == b"IDAT" {
73 return None;
74 }
75 // Unit 1 is the metre.
76 if kind == b"pHYs" && data.len() == 9 && data[8] == 1 {
77 let per_metre =
78 |at: usize| u32::from_be_bytes(data[at..at + 4].try_into().unwrap());
79 break [per_metre(0), per_metre(4)].map(|dots| dots as f32 * 0.0254);
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" {
398 let dpi = match encoded.strip_prefix(b"\x89PNG\r\n\x1a\n") {
399 Some(chunks) => png_resolution(chunks)?,
400 None => jpeg_resolution(encoded.strip_prefix(b"\xff\xd8")?)?,
401 };
402 dpi.iter().all(|dpi| *dpi > 0.0).then_some(dpi)
403}
404
405fn png_resolution(mut chunks: &[u8]) -> Option<[f32; 2]> {
406 loop {
407 let length = u32::from_be_bytes(chunks.get(..4)?.try_into().ok()?) as usize;
408 let kind = chunks.get(4..8)?;
409 let data = chunks.get(8..8 + length)?;
410 if kind == b"IDAT" {
87411 return None;
88412 }
89 let density = |at: usize| u16::from_be_bytes([app0[at], app0[at + 1]]) as f32;
90 let dots = [density(10), density(12)];
91 match app0[9] {
92 1 => dots,
93 2 => dots.map(|per_cm| per_cm * 2.54),
94 _ => return None,
413 // Unit 1 is the metre.
414 if kind == b"pHYs" && data.len() == 9 && data[8] == 1 {
415 let per_metre = |at: usize| u32::from_be_bytes(data[at..at + 4].try_into().unwrap());
416 return Some([per_metre(0), per_metre(4)].map(|dots| dots as f32 * 0.0254));
417 }
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 _ => {}
95460 }
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,
96472 };
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 }
98511}
99512
100513/// A bitmap from the clipboard as the PNG a page stores, without a resolution, so it takes
......@@ -148,6 +561,120 @@ mod tests {
148561 bytes
149562 }
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
151678 fn close(size: [f32; 2], expected: [f32; 2]) -> bool {
152679 (0..2).all(|axis| (size[axis] - expected[axis]).abs() < 0.01)
153680 }
......@@ -166,6 +693,9 @@ mod tests {
166693 assert!(close(size(&jfif(0, 1)), [150.0, 75.0]));
167694 assert!(close(size(&jfif(1, 0)), [150.0, 75.0]));
168695 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]));
169699 }
170700
171701 #[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//! File, Print… and Export as PDF…: the open page, or the whole section, laid on paper as
2//! OneNote 2010 prints it (`canvas::print`), then handed to the system's printing or saved.
1//! File, Print… and Export as PDF…: OneNote 2010's Print Preview and Settings, less the
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 onestore::page::Page;
5use crate::{State, UserEvent, library::Library, platform, printer};
6use accesskit::Role;
7use canvas::print::Footer;
8use notebook::discover::Folder;
9use onestore::{ExGuid, page::Page};
610use std::{error::Error, path::PathBuf, sync::Arc};
11use ui::{Anchor, Axis, Id, Spec, Ui, children, fill, popup::Item, px};
12use winit::keyboard::NamedKey;
713
814#[derive(Clone, Copy, PartialEq, Eq)]
915pub enum Scope {
1016 Page,
17 /// The page with the subpages of its group.
18 PageGroup,
1119 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();
12127}
13128
14129impl State {
15 /// Prints `scope` through the system's print dialog, or with `export` saves it as a PDF
16 /// where the user chooses. Pages are laid out on a thread of their own.
17 pub(crate) fn print(&mut self, scope: Scope, export: bool) -> Result<(), Box<dyn Error>> {
130 /// Opens Print Preview and Settings for Print…, or with `export` for Export as PDF….
131 pub(crate) fn open_print(&mut self, export: bool) {
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>> {
18292 let session = self.session.as_ref().ok_or("No section is open")?;
19 let section = session.tabs[session.tab].name.clone();
20 // The open page as the editor holds it; the section's others as stored.
293 let tab = &session.tabs[session.tab];
294 // The open page as the editor holds it; the others as stored.
21295 let open = (session.space, self.view.editor.page()?);
22 let spaces: Vec<_> = match scope {
23 Scope::Page => vec![session.space],
24 Scope::Section => session.pages.iter().map(|(space, ..)| *space).collect(),
296 let at = session
297 .pages
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],
25312 };
26 let title = match scope {
27 Scope::Page => open.1.title.clone(),
28 Scope::Section => section.clone(),
313 let title = match setup.scope {
314 Scope::Page | Scope::PageGroup => open.1.title.clone(),
315 Scope::Section => tab.name.clone(),
316 Scope::Notebook => session.library.name.clone(),
29317 };
30318 let destination = if export {
31319 let name = format!("{}.pdf", file_name(&title));
......@@ -39,22 +327,62 @@ impl State {
39327 } else {
40328 None
41329 };
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 );
42339 let replica = Arc::clone(session.section.replica());
43340 let layouts = Arc::clone(&self.layouts);
44341 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 };
46352 std::thread::spawn(move || {
47353 let made = (|| -> Result<Vec<u8>, Box<dyn Error>> {
48354 let mut open = Some(open);
49 let pages = spaces
355 let this = spaces
50356 .into_iter()
51357 .map(|space| match open.take_if(|(shown, _)| *shown == space) {
52358 Some((_, page)) => Ok(page),
53359 None => replica.page(space).map_err(Into::into),
54360 })
55361 .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 };
56384 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)?)
58386 })();
59387 let done = made.and_then(|pdf| match &destination {
60388 Some(path) => std::fs::write(path, pdf).map(|()| None).map_err(Into::into),
......@@ -79,6 +407,40 @@ impl State {
79407 }
80408}
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
82444/// `title` as a file name: without the characters Windows and macOS refuse in one.
83445fn file_name(title: &str) -> String {
84446 let name: String = title
......@@ -115,4 +477,13 @@ mod tests {
115477 assert_eq!(file_name("Notes: 3/4"), "Notes- 3-4");
116478 assert_eq!(file_name(" "), "Untitled");
117479 }
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 }
118489}
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 {
204204 kind: if video { 2 } else { 1 },
205205 duration_ms,
206206 }),
207 tags: Vec::new(),
207208 };
208209 let response = self.view.finish_recording(file)?;
209210 self.respond(response);
......@@ -983,6 +984,7 @@ pub(crate) mod tests {
983984 kind,
984985 duration_ms: Some(duration),
985986 }),
987 tags: Vec::new(),
986988 };
987989 editor.finish_recording(&mut engine, file).unwrap();
988990 store(&mut editor);
crates/snowbound/src/rename.rs+34-18
......@@ -6,7 +6,7 @@ use crate::{Command, Library, State, manage::Structure};
66use onestore::{
77 ExGuid,
88 op::{Edit, Op, PageOp},
9 page::PageObject,
9 page::{Page, PageObject},
1010};
1111use std::{error::Error, sync::Arc};
1212use ui::{Event, Id, Spec, Theme, Ui, fill, px};
......@@ -170,32 +170,48 @@ impl State {
170170 fn retitle(&mut self, space: ExGuid, name: String) -> Result<(), Box<dyn Error>> {
171171 self.persist()?;
172172 let session = self.session.as_mut().ok_or("No section is open")?;
173 let page = session.section.page(space)?;
174 let title = page
175 .objects
173 let op = retitled(&session.section.page(space)?, space, name.clone())?;
174 let old = session
175 .pages
176176 .iter()
177 .find_map(|object| match object {
178 PageObject::Title(title) => title.outlines.first()?.paragraphs.first()?.text(),
179 _ => None,
180 })
181 .ok_or("The page has no title to rename")?;
182 let length = title.text.utf16_offset(title.text.text().len())?;
177 .find(|(page, ..)| *page == space)
178 .map(|(_, title, _)| title.clone())
179 .unwrap_or_default();
183180 session.section.apply(
184181 &self.author,
185182 Edit {
186183 at: crate::filetime(),
187 ops: vec![Op::Page {
188 space,
189 op: PageOp::Text {
190 text: title.id,
191 range: 0..length,
192 with: name,
193 },
194 }],
184 ops: vec![op],
195185 },
196186 )?;
197187 session.pages = session.section.pages()?;
188 self.made(crate::undo::Change::Retitle {
189 page: space,
190 from: name,
191 to: old,
192 });
198193 self.edited(vec![space]);
199194 self.refresh()
200195 }
201196}
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 {
343343 Id::ROOT.child("server")
344344}
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
346416fn address_field() -> Id {
347417 id().child("address")
348418}
......@@ -409,6 +479,33 @@ impl State {
409479 self.ui.focus_all(focus);
410480 }
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
412509 /// Runs `request` off the frame, which wakes when it is answered.
413510 fn ask(&mut self, request: Request) {
414511 let Some(connect) = &self.server else {
......@@ -452,9 +549,13 @@ impl State {
452549 return;
453550 }
454551 let replies: Vec<Reply> = connect.replies.1.try_iter().collect();
552 let mut signed_in = None;
455553 for reply in replies {
456 if connect.receive(reply)
457 && connect.remember == Some(true)
554 if !connect.receive(reply) {
555 continue;
556 }
557 signed_in = connect.mount.as_ref().map(saved_address);
558 if connect.remember == Some(true)
458559 && !connect.guest
459560 && let (Some(mount), login) = (&connect.mount, connect.login())
460561 && let Err(error) = platform::save_login(mount, &login)
......@@ -462,6 +563,12 @@ impl State {
462563 eprintln!("Keeping the password for {}: {error}", mount.host());
463564 }
464565 }
566 if let Some(address) = signed_in {
567 self.remember_server(address);
568 }
569 let Some(connect) = &mut self.server else {
570 return;
571 };
465572 if connect.reopen
466573 && let Some((mount, login)) = connect.opens()
467574 {
......@@ -529,6 +636,7 @@ impl State {
529636 };
530637 let mut chosen = None;
531638 let mut back = false;
639 let mut saved = None;
532640 match connect.step {
533641 Step::Address => {
534642 labelled(ui, "Server Address:", |ui| {
......@@ -541,6 +649,7 @@ impl State {
541649 );
542650 name(ui, address_field(), "Server Address");
543651 });
652 saved = saved_servers(ui, &self.servers);
544653 }
545654 Step::SignIn => {
546655 if ui::check_box(ui, "guest", "Connect as guest", connect.guest).clicked {
......@@ -744,6 +853,9 @@ impl State {
744853 self.server = None;
745854 return;
746855 }
856 if let Some(saved) = saved {
857 return self.saved_server(saved);
858 }
747859 let connect = self.server.as_mut().expect("the dialog is open");
748860 let request = if let Some(index) = chosen {
749861 connect.enter(index)
......@@ -1032,6 +1144,15 @@ mod tests {
10321144 assert_eq!(connect.listing.folders, ["Notes", "public"]);
10331145 }
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
10351156 #[test]
10361157 fn guests_sign_in_without_an_account() {
10371158 let mut connect = Connect::new("nas/Notes".into(), None);
crates/snowbound/src/settings.rs+19-21
......@@ -36,7 +36,7 @@ pub struct Settings {
3636 /// Places and moves things where they are dropped, as OneNote's Snap To Grid off.
3737 pub ignore_grid: bool,
3838 /// 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,
4040 /// OneNote's "Page tabs appear on the left".
4141 pub page_tabs_left: bool,
4242 /// OneNote's "Navigation bar appears on the left" turned off: the notebooks on the right.
......@@ -44,24 +44,10 @@ pub struct Settings {
4444 /// Servers Open Notebook from Server signed in to, latest first, as addresses without a
4545 /// password.
4646 pub servers: Vec<String>,
47}
48
49/// The font and size OneNote's Default font gives new text.
50#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
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 }
47 /// Chords set in Options' Keyboard, by command, each list in place of its defaults.
48 pub keys: std::collections::BTreeMap<String, Vec<String>>,
49 /// Pages shown lately, latest first, which the palette lists first.
50 pub recent: Vec<crate::navigation::Place>,
6551}
6652
6753/// What the toolbar's buttons apply from their menus' last picks.
......@@ -174,10 +160,12 @@ impl crate::State {
174160 manual_updates: !self.updates.automatic(),
175161 ignore_pen_pressure: !self.pen_pressure,
176162 ignore_grid: !self.view.snap_to_grid,
177 default_font: self.default_font.clone(),
163 default_font: self.view.editor.default_font.clone(),
178164 page_tabs_left: self.page_tabs_left,
179165 navigation_bar_right: self.navigation_bar_right,
180166 servers: self.servers.clone(),
167 keys: crate::commands::Keymap::current().saved(),
168 recent: self.trail.recent.clone(),
181169 };
182170 if let Err(error) = settings.save(path) {
183171 eprintln!("Cannot save the settings in {}: {error}", path.display());
......@@ -239,13 +227,23 @@ mod tests {
239227 manual_updates: true,
240228 ignore_pen_pressure: true,
241229 ignore_grid: true,
242 default_font: DefaultFont {
230 default_font: canvas::editor::DefaultFont {
243231 face: "Georgia".into(),
244232 size: 14.0,
233 color: Some(0xc07000),
245234 },
246235 page_tabs_left: true,
247236 navigation_bar_right: true,
248237 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 }],
249247 };
250248 settings.save(&path).unwrap();
251249 assert_eq!(Settings::load(&path), settings);
crates/snowbound/src/sidebar.rs+101-37
......@@ -31,6 +31,7 @@ pub enum Action {
3131 /// Folds or unfolds a notebook's or section group's rows, by `fold_key`.
3232 Fold(String),
3333 NewNotebook,
34 NewICloudNotebook,
3435 OpenNotebook,
3536 /// Signs in again to the notebook opened from its server at this location.
3637 SignIn(String),
......@@ -137,7 +138,8 @@ pub fn header() -> Id {
137138}
138139
139140/// 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.
141143fn sidebar(
142144 ui: &mut Ui,
143145 tree: &mut Tree,
......@@ -145,6 +147,7 @@ fn sidebar(
145147 header: f32,
146148 drags: bool,
147149 rows: bool,
150 right: bool,
148151) {
149152 let (theme, folded) = (tree.theme, tree.folded);
150153 ui.open_as(
......@@ -162,13 +165,18 @@ fn sidebar(
162165 },
163166 );
164167 // The notebook button's square, which floats over this place.
165 ui.leaf(
166 "toggle",
167 Spec {
168 size: [px(RAIL - crate::FRAME), px(ui::shell::TOOL)],
169 ..Spec::default()
170 },
171 );
168 let toggle = |ui: &mut Ui| {
169 ui.leaf(
170 "toggle",
171 Spec {
172 size: [px(RAIL - crate::FRAME), px(ui::shell::TOOL)],
173 ..Spec::default()
174 },
175 );
176 };
177 if !right {
178 toggle(ui);
179 }
172180 ui.leaf(
173181 "title",
174182 Spec {
......@@ -178,6 +186,9 @@ fn sidebar(
178186 ..Spec::default()
179187 },
180188 );
189 if right {
190 toggle(ui);
191 }
181192 ui.close();
182193 if !rows {
183194 return;
......@@ -242,12 +253,21 @@ fn sidebar(
242253 },
243254 );
244255 let options = ("options", art::OPTIONS, "Options", Action::Options);
245 for (part, icon, label, chosen) in [
246 ("new", art::PLUS, "New Notebook", Action::NewNotebook),
247 ("open", art::NOTEBOOK, "Open Existing", Action::OpenNotebook),
248 ]
249 .into_iter()
250 .chain((!cfg!(target_os = "macos")).then_some(options))
256 let icloud = crate::icloud::folder().map(|_| {
257 (
258 "new icloud",
259 art::ICLOUD,
260 "New iCloud Notebook",
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))
251271 {
252272 let (row, _) = tree_row(
253273 ui,
......@@ -395,10 +415,10 @@ fn folder(
395415 ui.id(("unavailable", notebook, &entry.path)),
396416 Row {
397417 label: &name,
398 icon: if entry.group {
399 Leading::Icon(art::SECTION_GROUP)
400 } else {
401 Leading::Section(section_color(None))
418 icon: match entry.reason {
419 _ if entry.group => Leading::Icon(art::SECTION_GROUP),
420 Reason::Evicted => Leading::Icon(art::ICLOUD),
421 _ => Leading::Section(section_color(None)),
402422 },
403423 depth,
404424 dim: true,
......@@ -413,6 +433,10 @@ fn folder(
413433 format!("Can't read \u{201c}{name}\u{201d}"),
414434 format!("{}\n\nIt appears here once you have access.", entry.error),
415435 ),
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 ),
416440 _ => Action::Unavailable(
417441 format!("Can't open \u{201c}{name}\u{201d}"),
418442 format!("{}.", entry.error),
......@@ -663,19 +687,27 @@ fn tree_row(ui: &mut Ui, tree: &mut Tree, id: Id, row: Row) -> (Signal, bool) {
663687}
664688
665689impl crate::State {
666 /// The sidebar left of the section tabs, easing open and shut; returns its width.
667 pub(crate) fn sidebar(&mut self, theme: &Theme) -> f32 {
690 /// How wide the sidebar stands this frame as it eases open and shut.
691 pub(crate) fn sidebar_width(&mut self) -> f32 {
668692 if self.temporary {
669693 return 0.0;
670694 }
671 let width = self.ui.animate(
695 self.ui.animate(
672696 self.ui.id("sidebar"),
673697 if self.sidebar && !self.full_page {
674698 WIDTH
675699 } else {
676700 0.0
677701 },
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 }
679711 self.ui.open(
680712 "sidebar",
681713 Spec {
......@@ -721,6 +753,7 @@ impl crate::State {
721753 crate::TAB_ROW,
722754 drags,
723755 width > 0.5,
756 self.navigation_bar_right,
724757 );
725758 let Tree {
726759 action,
......@@ -754,6 +787,9 @@ impl crate::State {
754787 }
755788 Some(Action::Renamed(keep)) => self.finish_renaming(keep),
756789 Some(Action::NewNotebook) => self.commands.push(crate::Command::NewNotebook),
790 Some(Action::NewICloudNotebook) => {
791 self.commands.push(crate::Command::NewICloudNotebook)
792 }
757793 Some(Action::OpenNotebook) => self.commands.push(crate::Command::OpenNotebook),
758794 Some(Action::SignIn(location)) => self
759795 .commands
......@@ -762,7 +798,6 @@ impl crate::State {
762798 Some(Action::Unavailable(title, message)) => crate::platform::alert(&title, &message),
763799 Some(Action::Open { .. }) | None => {}
764800 }
765 width
766801 }
767802
768803 /// The sidebar's button in its square at the window's edge, and the room the section
......@@ -774,11 +809,20 @@ impl crate::State {
774809 if self.temporary {
775810 return;
776811 }
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 };
777820 self.ui.open(
778821 "toggle",
779822 Spec {
780823 flags: Flags::FLOAT | Flags::CLIP,
781824 size: [px(RAIL), px(height)],
825 position: [x, 0.0],
782826 pad: [
783827 (RAIL - ui::shell::TOOL) / 2.0,
784828 (RAIL - ui::shell::TOOL) / 2.0 + DROP,
......@@ -1058,15 +1102,25 @@ impl crate::State {
10581102 self.load(move || {
10591103 let library = match open {
10601104 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 }
10621110 };
10631111 if let Err(error) = &library.notebook {
10641112 return Err(error.clone().into());
10651113 }
1066 let path = section
1114 let Some(path) = section
10671115 .filter(|path| library.contains(path))
10681116 .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 };
10701124 let section = library.open(&path, notify)?;
10711125 let (session, page) = crate::read_session(section, library, path, None)?;
10721126 Ok(crate::Loaded::Section(Box::new(session), page))
......@@ -1086,18 +1140,20 @@ impl crate::State {
10861140 ));
10871141 #[cfg(not(target_os = "linux"))]
10881142 let install = None;
1143 let servers = self.servers.clone();
10891144 self.notice(
10901145 theme,
10911146 id,
10921147 theme.base,
10931148 "No notebooks open",
1149 &servers,
10941150 crate::icloud::folder()
10951151 .map(|_| {
10961152 (
10971153 "icloud",
10981154 art::ICLOUD,
1099 "Use iCloud Drive",
1100 crate::Command::UseICloud,
1155 "New iCloud Notebook",
1156 crate::Command::NewICloudNotebook,
11011157 )
11021158 })
11031159 .into_iter()
......@@ -1133,8 +1189,9 @@ impl crate::State {
11331189 }
11341190
11351191 /// 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.
11371193 pub(crate) fn no_sections(&mut self, theme: &Theme, library: Arc<Library>) {
1194 let downloading = library.downloading();
11381195 let new = crate::Command::Structure(
11391196 library,
11401197 crate::manage::Structure::NewSection {
......@@ -1142,23 +1199,26 @@ impl crate::State {
11421199 },
11431200 );
11441201 let id = self.ui.id("no sections");
1145 self.notice(
1146 theme,
1147 id,
1148 theme.strip,
1149 "No sections in this notebook",
1150 vec![("new", art::PLUS, "New Section", new)],
1151 );
1202 let (title, buttons) = if downloading {
1203 ("Downloading from iCloud Drive…", Vec::new())
1204 } else {
1205 (
1206 "No sections in this notebook",
1207 vec![("new", art::PLUS, "New Section", new)],
1208 )
1209 };
1210 self.notice(theme, id, theme.strip, title, &[], buttons);
11521211 }
11531212
11541213 /// 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.
11561215 fn notice(
11571216 &mut self,
11581217 theme: &Theme,
11591218 id: ui::Id,
11601219 background: [f32; 4],
11611220 title: &str,
1221 servers: &[String],
11621222 buttons: Vec<(&str, &'static [&'static str], &str, crate::Command)>,
11631223 ) {
11641224 const BUTTON: [f32; 2] = [240.0, 32.0];
......@@ -1217,8 +1277,12 @@ impl crate::State {
12171277 }
12181278 }
12191279 self.commands.extend(chosen);
1280 let saved = crate::server::saved_servers(&mut self.ui, servers);
12201281 self.ui.close();
12211282 self.ui.close();
1283 if let Some(saved) = saved {
1284 self.saved_server(saved);
1285 }
12221286 }
12231287}
12241288
crates/snowbound/src/spell_windows.rs+2-1
......@@ -45,7 +45,8 @@ type Out = *mut *mut *const usize;
4545
4646/// The object a method answered through `out`, where it answered one.
4747fn 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))
4950}
5051
5152/// The strings an IEnumString holds, freed as they are read.
crates/snowbound/src/surface_windows.rs+48-8
......@@ -73,23 +73,42 @@ impl Surface {
7373 unreachable!()
7474 };
7575 let device = unsafe { GetDC(handle.hwnd.get() as HWND) };
76 let alpha = if backdrop { 8 } else { 0 };
77 let format = gl::PIXELFORMATDESCRIPTOR {
76 let request = |alpha| gl::PIXELFORMATDESCRIPTOR {
7877 nSize: size_of::<gl::PIXELFORMATDESCRIPTOR>() as u16,
7978 nVersion: 1,
80 // PFD_SUPPORT_COMPOSITION: the desktop composes the window's alpha.
8179 dwFlags: gl::PFD_DRAW_TO_WINDOW
8280 | gl::PFD_SUPPORT_OPENGL
8381 | gl::PFD_DOUBLEBUFFER
84 | 0x8000,
82 | gl::PFD_SUPPORT_COMPOSITION,
8583 iPixelType: gl::PFD_TYPE_RGBA,
8684 cColorBits: 32,
8785 cAlphaBits: alpha,
8886 iLayerType: gl::PFD_MAIN_PLANE as u8,
8987 ..unsafe { std::mem::zeroed() }
9088 };
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 }
91110 unsafe {
92 let chosen = gl::ChoosePixelFormat(device, &format);
111 let chosen = chosen.unwrap_or_else(|| gl::ChoosePixelFormat(device, &format));
93112 if chosen == 0 || gl::SetPixelFormat(device, chosen, &format) == 0 {
94113 return Err("No OpenGL pixel format".into());
95114 }
......@@ -105,15 +124,36 @@ impl Surface {
105124 );
106125 }
107126 }
108 let renderer = Renderer::opengl()?;
109 eprintln!("Canvas GPU: OpenGL");
127 let string = |name| unsafe {
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 })?;
110150 let size = window.inner_size();
111151 Ok((
112152 Self {
113153 size: [size.width, size.height],
114154 backend: Backend::Gl {
115155 device,
116 translucent: backdrop,
156 translucent,
117157 target: None,
118158 },
119159 },
crates/snowbound/src/sync.rs+1032-252
......@@ -1,14 +1,27 @@
11//! 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};
55use notebook::session::{SyncState, SyncStatus};
6use std::error::Error;
6use onestore::ExGuid;
7use std::{error::Error, path::Path, sync::Arc, time::Duration};
78use ui::{
89 Anchor, Axis, Flags, Id, Overflow, Spec, Theme, Ui, children, fill, fit, px, shell::TOOL,
910};
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
1326fn id() -> Id {
1427 Id::ROOT.child("sync-status")
......@@ -18,55 +31,52 @@ fn button() -> Id {
1831 Id::ROOT.child("sync-button")
1932}
2033
21/// The status's label and icon, and what the reader can do about an error; `mounted` where
22/// the notebook syncs through the system's mount because Snowbound's client couldn't sign in.
34/// The status's headline and icon. `quiet` holds a sync back that hasn't run `SHOW_AFTER`
35/// yet: a notebook synced before still reads up to date.
2336fn describe(
2437 sync: &SyncStatus,
25 mounted: bool,
26) -> (&'static str, &'static [&'static str], Option<&'static str>) {
27 if library::offline() {
28 return (
29 "Working offline",
30 art::SYNC_OFFLINE,
31 Some("Changes stay on this computer until you sync."),
32 );
38 offline: bool,
39 quiet: bool,
40) -> (&'static str, &'static [&'static str]) {
41 if offline {
42 return ("Working offline", art::SYNC_OFFLINE);
3343 }
3444 match sync.state() {
35 SyncState::ReadOnly => (
36 "Unable to sync",
37 art::SYNC_ERROR,
38 Some(
39 "You can’t change this notebook where it’s stored. Changes stay on this computer.",
40 ),
41 ),
42 SyncState::InUse => (
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),
45 SyncState::ReadOnly => ("Can’t save changes", art::SYNC_ERROR),
46 SyncState::InUse => ("Waiting for another device", art::SYNC_BUSY),
47 SyncState::NotConnected => ("Waiting for connection", art::SYNC_OFFLINE),
48 SyncState::Protected => ("Password-protected section", art::SYNC_ERROR),
49 SyncState::Unreadable => ("Can’t read a section", art::SYNC_ERROR),
50 SyncState::Failed => ("Unable to sync", art::SYNC_ERROR),
51 SyncState::Syncing if !quiet || sync.synced.is_none() => ("Syncing…", art::SYNC_BUSY),
52 SyncState::Syncing | SyncState::UpToDate => ("Up to date", art::SYNC_DONE),
6453 }
6554}
6655
67/// Every section of the open section's notebook with its status, in catalog order: the
68/// open one's from its session, the others' from the notebook's background sync.
69fn sections(session: &Session) -> Vec<(String, SyncStatus)> {
56/// A section's status beside its name.
57fn brief(sync: &SyncStatus, offline: bool) -> String {
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)> {
7080 let open = &session.tabs[session.tab].path;
7181 let copy = |sync: &SyncStatus| SyncStatus {
7282 synced: sync.synced,
......@@ -76,16 +86,23 @@ fn sections(session: &Session) -> Vec<(String, SyncStatus)> {
7686 .map(|error| std::io::Error::new(error.kind(), error.to_string())),
7787 queued: sync.queued,
7888 };
79 let mut sections = session
80 .library
89 let mut sections = library
8190 .background
8291 .as_ref()
8392 .map(|background| background.status())
8493 .unwrap_or_default();
85 match sections.iter_mut().find(|(path, _)| path == open) {
86 Some((_, sync)) => *sync = copy(&session.sync),
87 None => sections.insert(0, (open.clone(), copy(&session.sync))),
94 if std::ptr::eq(library, &*session.library) {
95 match sections.iter_mut().find(|(path, _)| path == open) {
96 Some((_, sync)) => *sync = copy(&session.sync),
97 None => sections.insert(0, (open.clone(), copy(&session.sync))),
98 }
8899 }
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 });
89106 sections
90107}
91108
......@@ -117,15 +134,39 @@ fn changes(count: u64) -> String {
117134 }
118135}
119136
120/// A FILETIME as OneNote's "Last sync": the time, and the date too before today.
121fn when(time: u64) -> String {
137/// A FILETIME as how long ago it was: minutes within the hour, then the time, and the date
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 }
122147 let [_, clock] = platform::date_text(time);
123148 let day = platform::short_date(time);
124149 if day == platform::short_date(filetime()) {
125 clock
150 format!("at {clock}")
126151 } 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);
128168 }
169 running >= SHOW_AFTER || !syncing && shown && ended < LINGER
129170}
130171
131172const TITLE: &str = "Notebook Sync Status";
......@@ -143,8 +184,16 @@ fn update_note(update: &update::Status) -> Option<String> {
143184/// The status's icon in the toolbar, which opens the popup; its tooltip names the status,
144185/// and a dot on it says a newer build is ready.
145186pub(crate) fn control(ui: &mut Ui, session: &Session, update: &update::Status, theme: &Theme) {
146 let sync = overall(&sections(session));
147 let (label, icon, _) = describe(&sync, session.library.notice.is_some());
187 let sync = overall(&sections(&session.library, session));
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 };
148197 ui.open_as(
149198 button(),
150199 Spec {
......@@ -197,255 +246,986 @@ pub(crate) fn control(ui: &mut Ui, session: &Session, update: &update::Status, t
197246 }
198247}
199248
200impl State {
201 /// Builds the sync status popup while it is open.
202 pub(crate) fn sync_popup(&mut self) -> Result<(), Box<dyn Error>> {
203 let (ui, notebooks) = (&mut self.ui, &self.notebooks);
204 let update = self.updates.status();
205 let Some(session) = &mut self.session else {
206 ui.close_popup(id());
207 return Ok(());
208 };
209 if !ui.popup_open(id()) {
210 return Ok(());
211 }
212 // Read afresh while shown, so the last sync time follows each poll.
213 session.sync = session.section.sync_status()?;
214 let theme = ui.theme.clone();
215 let offline = library::offline();
216 let sections = sections(session);
217 let sync = overall(&sections);
218 let (progress, _, advice) = describe(&sync, session.library.notice.is_some());
219 ui.open_as(
220 id(),
249/// What the popup shows of the notebook.
250struct Facts<'a> {
251 name: &'a str,
252 /// The notebook's folder or address in full.
253 location: &'a str,
254 place: Vec<String>,
255 /// Why the notebook syncs through the system's mount of its share.
256 notice: Option<&'a str>,
257 sections: Vec<(String, SyncStatus)>,
258 /// The open section's pages with conflict pages, and their titles.
259 conflicts: Vec<(ExGuid, &'a str)>,
260 offline: bool,
261 update: &'a update::Status,
262 /// Whether the open section's file can be shown in the file manager.
263 local: bool,
264}
265
266/// What the reader picked in the popup.
267#[derive(Default)]
268struct Picked {
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,
221286 Spec {
222 axis: Axis::Y,
223 size: [px(WIDTH), children()],
224 fill: Some(theme.popup),
225 border: Some(theme.chip),
226 shadow: Some(theme.shadow),
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),
287 size: [fill(), fit()],
288 text: Some(text),
289 color: Some(color),
290 overflow: Overflow::Wrap,
291 pad: [0.0, 2.0],
232292 ..Spec::default()
233293 },
234294 );
235 if let Some(node) = ui.access(id()) {
236 node.set_label(TITLE);
295 };
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,
237346 }
238 let text = |ui: &mut Ui, part: &str, text: &str, color: [f32; 4], bold: bool| {
239 ui.leaf(
240 part,
241 Spec {
242 size: [fill(), fit()],
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 );
347 };
348 if !showing && headline == "Up to date" && !facts.conflicts.is_empty() {
349 conflict = match facts.conflicts.as_slice() {
350 [(_, title)] => format!("Conflict on “{title}”"),
351 pages => format!("Conflicts on {} pages", pages.len()),
251352 };
252 let row = |ui: &mut Ui, key: &str, value: &str| {
253 ui.open(
254 key,
255 Spec {
256 size: [fill(), children()],
257 gap: 8.0,
258 ..Spec::default()
259 },
260 );
261 ui.leaf(
262 "key",
263 Spec {
264 size: [px(92.0), px(theme.font_size * 1.6)],
265 text: Some(key),
266 color: Some(theme.text_dim),
267 ..Spec::default()
268 },
269 );
270 ui.leaf(
271 "value",
272 Spec {
273 size: [fill(), px(theme.font_size * 1.6)],
274 text: Some(value),
275 overflow: Overflow::Ellipsis,
276 ..Spec::default()
277 },
278 );
279 ui.close();
353 headline = &conflict;
354 icon = art::SYNC_WARNING;
355 }
356
357 // The status: headline, when it last synced or what to do, and a running sync's bar.
358 ui.open(
359 "status",
360 Spec {
361 axis: Axis::Y,
362 size: [fill(), children()],
363 gap: 2.0,
364 ..Spec::default()
365 },
366 );
367 if let Some(node) = ui.access(ui.current()) {
368 node.set_live(accesskit::Live::Polite);
369 }
370 ui.leaf(
371 "headline",
372 Spec {
373 size: [fill(), px(theme.font_size * 2.0)],
374 text: Some(headline),
375 icon: Some(icon),
376 font_size: Some(theme.font_size * 1.2),
377 bold: true,
378 overflow: Overflow::Ellipsis,
379 ..Spec::default()
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,
280387 };
281 text(ui, "title", TITLE, theme.text, true);
282 row(ui, "Notebook", &session.library.name);
283 row(ui, "Location", &session.library.location);
284 row(ui, "Connection", &session.library.transport());
285 row(ui, "Progress", progress);
286 if let Some(synced) = sync.synced {
287 row(ui, "Last sync", &when(synced));
288 }
289 if sync.queued > 0 {
290 row(ui, "Not yet synced", &changes(sync.queued));
388 let fraction = ui.animate(bar.child("fraction"), fraction);
389 let running = ui.lasted(bar.child("running"), true).as_secs_f32();
390 ui.open_as(
391 bar,
392 Spec {
393 size: [fill(), px(6.0)],
394 fill: Some(theme.chip),
395 radius: 3.0,
396 role: Some(accesskit::Role::ProgressIndicator),
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));
291414 }
292 if let Some(advice) = advice {
293 text(ui, "advice", advice, theme.text, false);
415 if let Some(node) = ui.access(bar) {
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()));
294420 }
295 let mut sign_in = None;
296 if let Some(notice) = &session.library.notice {
297 let notice = format!("Snowbound’s SMB client couldn’t sign in: {notice}");
298 text(ui, "notice", &notice, theme.text_dim, false);
299 if ui::button(ui, "sign-in", "Sign In\u{2026}").clicked {
300 sign_in = Some(session.library.location.clone());
421 ui.close();
422 let mut caption = match total {
423 0 | 1 => String::new(),
424 _ => format!("{done} of {total} sections synced"),
425 };
426 if sync.queued > 0 {
427 if !caption.is_empty() {
428 caption += " · ";
301429 }
430 caption += &format!("{waiting} to send");
302431 }
303 text(ui, "sections", "Sections", theme.text, true);
304 // OneNote's sync dialog leaves out the recycle bin, unless something is wrong there.
305 let listed = sections.iter().filter(|(path, sync)| {
306 sync.error.is_some()
307 || !path
308 .rsplit_once('/')
309 .is_some_and(|(folder, _)| library::recycle_bin(folder))
310 });
311 for (index, (path, sync)) in listed.enumerate() {
312 ui.open(
313 format!("section-{index}"),
314 Spec {
315 size: [fill(), children()],
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 },
432 if caption.is_empty() {
433 caption = "Checking for changes…".to_owned();
434 }
435 text(ui, "caption", &caption, theme.text_dim);
436 } else {
437 if let Some(advice) = &advice {
438 text(ui, "advice", advice, theme.text);
439 } else if !facts.conflicts.is_empty() && headline != "Syncing…" {
440 text(
441 ui,
442 "advice",
443 "Both versions are kept. Open the page to compare them.",
444 theme.text,
329445 );
330 let status = match sync.queued {
331 0 => describe(sync, false).0.to_owned(),
332 queued => format!("{}, {}", describe(sync, false).0, changes(queued)),
446 }
447 if let Some(synced) = sync.synced {
448 let prefix = match advice {
449 Some(_) => "Last synced",
450 None => "Synced",
333451 };
334 ui.leaf(
335 "status",
336 Spec {
337 size: [fit(), px(theme.font_size * 1.6)],
338 text: Some(&status),
339 color: Some(theme.text_dim),
340 ..Spec::default()
341 },
452 text(
453 ui,
454 "synced",
455 &format!("{prefix} {}", ago(synced)),
456 theme.text_dim,
342457 );
343 ui.close();
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 }
458 ui.wake_after(Duration::from_secs(30));
348459 }
349 let (mut folder, mut restart) = (false, false);
350 if let Some(note) = update_note(&update) {
351 text(ui, "update-title", "Snowbound Update", theme.text, true);
352 row(ui, "Update", &note);
353 ui.open(
354 "update",
355 Spec {
356 size: [fill(), children()],
357 pad: [0.0, 4.0],
358 gap: 8.0,
359 ..Spec::default()
360 },
361 );
362 folder = ui::button(ui, "build-folder", "Build Folder").clicked;
363 ui.leaf(
364 "space",
365 Spec {
366 size: [fill(), px(1.0)],
367 ..Spec::default()
368 },
460 }
461 ui.close();
462
463 rule(ui, "identity-rule");
464 // Which notebook, and where it lives; the full location in the tooltip.
465 ui.open(
466 "identity",
467 Spec {
468 axis: Axis::Y,
469 size: [fill(), children()],
470 ..Spec::default()
471 },
472 );
473 ui.leaf(
474 "name",
475 Spec {
476 size: [fill(), px(line)],
477 text: Some(facts.name),
478 icon: Some(art::NOTEBOOK),
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,
369594 );
370 if matches!(update, update::Status::Ready(..)) {
371 restart = ui::button(ui, "restart", "Restart to Update").clicked;
372 }
373 ui.close();
374595 }
596 }
597 for (space, title) in &facts.conflicts {
375598 ui.open(
376 "controls",
599 ("conflict", space),
377600 Spec {
378601 size: [fill(), children()],
379 pad: [0.0, 6.0],
380602 gap: 8.0,
381603 ..Spec::default()
382604 },
383605 );
384 let toggled = ui::check_box(ui, "offline", "Work offline", offline).clicked;
385606 ui.leaf(
386 "space",
607 "name",
387608 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,
389613 ..Spec::default()
390614 },
391615 );
392 let file = session.library.local(session.section.file());
393 let show = match file {
394 Some(_) => ui::button(ui, "show-file", platform::SHOW_FILE).clicked,
395 None => {
396 ui.leaf(
397 "show-file",
398 Spec {
399 size: [fit(), px(theme.font_size * 2.0)],
400 text: Some(platform::SHOW_FILE),
401 color: Some(theme.text_dim),
402 fill: Some(theme.chip),
403 radius: 4.0,
404 pad: [theme.font_size * 0.75, 0.0],
405 center: true,
406 role: Some(accesskit::Role::Button),
407 ..Spec::default()
408 },
409 );
410 if let Some(node) = ui.access(ui.id("show-file")) {
411 node.set_disabled();
412 }
413 false
616 if ui::button(ui, "open", "Open Page").clicked {
617 picked.conflict = Some(*space);
618 }
619 ui.close();
620 }
621 let summaries = [
622 match (behind.len() + unreached, done) {
623 (_, 0) | (0, 1) => None,
624 (0, all) => Some(format!("All {all} sections up to date")),
625 (_, 1) => Some("1 other section up to date".to_owned()),
626 (_, others) => Some(format!("{others} other sections up to date")),
627 },
628 match (unreached, behind.is_empty() && done == 0) {
629 (0, _) | (1, true) => None,
630 (count, true) => Some(format!("All {count} sections waiting for connection")),
631 (1, false) => Some("1 other section waiting for connection".to_owned()),
632 (count, false) => Some(format!("{count} other sections waiting for connection")),
633 },
634 ];
635 for (index, summary) in summaries.iter().flatten().enumerate() {
636 ui.leaf(
637 ("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();
414710 }
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(());
415783 };
416 let now = ui::button(ui, "sync-now", "Sync Now").clicked;
417 ui.close();
784 if !ui.popup_open(id()) {
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);
418824 ui.close();
419825 let backgrounds = notebooks
420826 .iter()
421827 .filter_map(|library| library.background.as_ref());
422 if toggled {
828 if picked.offline {
423829 library::set_offline(!offline);
424830 session.section.set_offline(!offline);
425 backgrounds.for_each(|background| background.set_offline(!offline));
426 } else if now {
427 session.section.wake();
831 notebooks
832 .iter()
833 .for_each(|library| library.set_offline(!offline));
834 } else if picked.sync_now {
835 if own {
836 session.section.wake();
837 }
428838 backgrounds.for_each(|background| background.wake());
429839 }
430 if let Some(file) = file.filter(|_| show) {
840 if let Some(file) = file.filter(|_| picked.show_file) {
431841 platform::show_file(&file);
432842 }
433 if let Some(location) = sign_in {
843 if picked.sign_in {
844 let location = library.location.clone();
434845 self.ui.close_popup(id());
435846 self.commands
436847 .push(crate::Command::OpenFromServer(Some(location)));
437848 }
849 if let Some(space) = picked.conflict {
850 self.ui.close_popup(id());
851 self.commands.push(crate::Command::OpenPage(space));
852 }
438853 match update {
439854 update::Status::Downloading(version)
440855 | update::Status::Ready(version, _)
441856 | update::Status::Available(version)
442 if folder =>
857 if picked.build_folder =>
443858 {
444859 update::show_build(&version)
445860 }
446 _ if restart => self.restart_to_update(),
861 _ if picked.restart => self.restart_to_update(),
447862 _ => {}
448863 }
449864 Ok(())
450865 }
451866}
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 {
158158 unsafe { Dwm::DwmIsCompositionEnabled(&mut on) >= 0 && on != 0 }
159159}
160160
161pub fn event_loop(_headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLoopError> {
161pub fn event_loop(headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLoopError> {
162162 let event_loop = EventLoop::with_user_event().build()?;
163163 QUIT.set(event_loop.create_proxy())
164164 .expect("Only one application event loop is created");
165165 COMPOSED.store(composed(), Ordering::Relaxed);
166 if !headless {
167 offer_to_open();
168 }
166169 Ok(event_loop)
167170}
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
169227/// The executable's icon, and where the row draws the caption buttons, no system frame:
170228/// the desktop draws the window's shadow.
171229pub fn window_attributes() -> WindowAttributes {
......@@ -760,8 +818,132 @@ pub fn move_cursor() -> winit::window::CursorIcon {
760818/// No system border lies under the chrome.
761819pub fn cover_border_line(_: &mut Ui, _: f32) {}
762820
763pub fn with_pool<R>(run: impl FnOnce() -> R) -> R {
764 run()
821/// Runs the app. Where nothing reads stderr, as when Explorer starts it, stderr goes to a
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
765947}
766948
767949/// The interface keeps its own font, which fontique finds as Segoe UI.
......@@ -820,6 +1002,10 @@ impl Clipboard {
8201002 self.0.get().file_list().unwrap_or_default()
8211003 }
8221004
1005 pub fn get_html(&mut self) -> Option<String> {
1006 self.0.get().html().ok()
1007 }
1008
8231009 /// The clipboard's PNG, or else its device-independent bitmap.
8241010 pub fn get_picture(&mut self) -> Option<Vec<u8>> {
8251011 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() {
122122 assert_named_once(&tree);
123123 }
124124}
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 @@
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};
24use std::collections::HashMap;
35
46/// Sizes each box on both axes, then places it: standalone sizes, sizes taken from
......@@ -49,8 +51,13 @@ pub(crate) fn solve(
4951 Size::Fraction(_) | Size::Children => 0.0,
5052 };
5153 }
54 fit_popups(nodes, axis);
5255 for index in 1..nodes.len() {
5356 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 }
5461 let mut ancestor = nodes[index].parent;
5562 while ancestor != 0 && nodes[ancestor].size[axis].size == Size::Children {
5663 ancestor = nodes[ancestor].parent;
......@@ -65,6 +72,8 @@ pub(crate) fn solve(
6572 nodes[index].computed[axis] = content + 2.0 * nodes[index].pad[axis];
6673 }
6774 }
75 yield_popups(nodes, axis);
76 fit_popups(nodes, axis);
6877 for index in 0..nodes.len() {
6978 let node = &nodes[index];
7079 if node.children.is_empty() || (axis == 1 && node.flags.contains(Flags::SCROLL)) {
......@@ -118,6 +127,12 @@ pub(crate) fn solve(
118127 for child in children {
119128 let room =
120129 (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 }
121136 let over = nodes[child].computed[axis] - room;
122137 if over > 0.0 {
123138 nodes[child].computed[axis] -=
......@@ -197,6 +212,50 @@ pub(crate) fn solve(
197212 }
198213}
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
200259/// The length `parent` lays `child` out across: a popup widening over its anchor lays its
201260/// contents out at its full width, but for boxes standing in for the anchor, which widen with it.
202261fn 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 {
205264 (Some(Anchor::Over(_)), Size::Pixels(full))
206265 if axis == 0 && !nodes[child].flags.contains(Flags::STILL) =>
207266 {
208 full
267 full.min(fitting(nodes[0].computed[axis]))
209268 }
210269 _ => node.computed[axis],
211270 }
crates/ui/src/lib.rs+57-17
......@@ -85,7 +85,8 @@ pub enum Size {
8585 Pixels(f32),
8686 /// The label and padding.
8787 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.
8990 Fraction(f32),
9091 /// The children laid out along this axis, and padding.
9192 Children,
......@@ -207,6 +208,14 @@ pub enum Overflow {
207208 Ellipsis,
208209}
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
210219/// Where a popup opens, flipping to the far side of its anchor where the window ends first.
211220#[derive(Clone, Copy, Debug, PartialEq)]
212221pub enum Anchor {
......@@ -231,13 +240,18 @@ impl Anchor {
231240 /// anchor on the axis it opens along, level with it otherwise. Shown only `shown` long
232241 /// as it opens, it keeps the edge it would have at full size.
233242 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);
234246 let (rect, along) = match self {
235247 Anchor::Below(rect) => (rect, Some(1)),
236248 Anchor::Right(rect) => (rect, Some(0)),
237249 Anchor::Over(rect) => (rect, None),
238250 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),
240 Anchor::Dialog | Anchor::Top => return (room / 8.0).min(room - size).max(0.0),
251 Anchor::Dialog | Anchor::Top if axis == 0 => {
252 return ((room - size) / 2.0).max(POPUP_MARGIN);
253 }
254 Anchor::Dialog | Anchor::Top => return (room / 8.0).clamp(POPUP_MARGIN, most),
241255 };
242256 let [low, high] = [rect[axis], rect[axis + 2]];
243257 let (first, second) = if along == Some(axis) {
......@@ -245,12 +259,12 @@ impl Anchor {
245259 } else {
246260 (low, high - size)
247261 };
248 if first + size <= room {
249 first
250 } else if second >= 0.0 {
251 second + size - shown
262 if first + size <= room - POPUP_MARGIN {
263 first.max(POPUP_MARGIN)
264 } else if second >= POPUP_MARGIN {
265 second.min(most) + size - shown
252266 } else {
253 first.min(room - size).max(0.0) + size - shown
267 first.clamp(POPUP_MARGIN, most) + size - shown
254268 }
255269 }
256270
......@@ -323,6 +337,8 @@ pub struct Spec<'a> {
323337 pub font_size: Option<f32>,
324338 /// Shapes the label semibold.
325339 pub bold: bool,
340 /// Shapes the label italic.
341 pub italic: bool,
326342 pub font: Option<&'a str>,
327343 pub overflow: Overflow,
328344 /// The label's colour; the theme's text colour otherwise.
......@@ -548,6 +564,8 @@ struct State {
548564 held: Option<(ScrollerPart, Instant)>,
549565 /// An animated value and its target, for `Ui::animate`.
550566 tween: Option<[f32; 2]>,
567 /// When the condition `Ui::lasted` follows last became true.
568 since: Option<Instant>,
551569}
552570
553571/// An open popup, above the one opened before it.
......@@ -839,6 +857,18 @@ impl Ui {
839857 self.states.get(&id).map(|state| state.rect)
840858 }
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
842872 /// The pointer's cursor, or None over a custom box, whose host chooses.
843873 pub fn cursor(&self) -> Option<CursorIcon> {
844874 let hit = self
......@@ -1180,7 +1210,7 @@ impl Ui {
11801210 let label = spec.text.map(|text| {
11811211 let size = spec.font_size.unwrap_or(self.theme.font_size);
11821212 self.texts
1183 .label(text, size, spec.bold, spec.font, self.frame)
1213 .styled(text, size, spec.bold, spec.italic, spec.font, self.frame)
11841214 });
11851215 // Popups hang from the root, outside the clips and flow of where they are built.
11861216 let parent = if spec.anchor.is_some() {
......@@ -1236,6 +1266,18 @@ impl Ui {
12361266 *value
12371267 }
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
12391281 /// Makes `family` the interface's font, where fontique doesn't know the system's, as
12401282 /// with Mac OS X 10.6's Lucida Grande.
12411283 pub fn set_system_font(&mut self, family: &str) {
......@@ -1627,10 +1669,12 @@ impl Ui {
16271669 radius: *radius,
16281670 });
16291671 }
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]);
16301674 let inner = [
1631 painted[0] + node.pad[0],
1675 left,
16321676 painted[1],
1633 painted[2] - node.pad[0],
1677 (painted[2] - node.pad[0]).max(left),
16341678 painted[3],
16351679 ];
16361680 let mut x = if node.center {
......@@ -1650,12 +1694,8 @@ impl Ui {
16501694 let rect = if node.label.is_some() {
16511695 [x, top, x + ICON, top + ICON]
16521696 } else {
1653 [
1654 inner[0],
1655 painted[1] + node.pad[1],
1656 inner[2],
1657 painted[3] - node.pad[1],
1658 ]
1697 let top = (painted[1] + node.pad[1]).min(painted[3]);
1698 [inner[0], top, inner[2], (painted[3] - node.pad[1]).max(top)]
16591699 };
16601700 self.display.push(Display::Image {
16611701 image: image.clone(),
crates/ui/src/list.rs+7
......@@ -55,6 +55,13 @@ pub(crate) struct State {
5555 selected: Option<u64>,
5656}
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
5865/// Builds `list` into a box of `spec`, calling `build` inside each row in view, and moves
5966/// `selected` with the list's keys. Returns the index of the row clicked.
6067pub fn list<R: Rows>(
crates/ui/src/popup.rs+60-10
......@@ -75,6 +75,8 @@ pub struct Item<'a> {
7575 pub separated: bool,
7676 /// Names the group it starts: shown only while unfiltered, never chosen.
7777 pub heading: bool,
78 /// Repeats an item listed further on, so shows only while unfiltered.
79 pub repeated: bool,
7880}
7981
8082impl Item<'_> {
......@@ -327,22 +329,38 @@ fn tooltip_below(
327329 ui.close();
328330}
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
330345/// Builds popup `id` as a command palette across the top of the window while it is open,
331346/// under a filter field showing `placeholder` while empty. What is typed picks the first of
332347/// `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.
334pub fn palette(
335 ui: &mut Ui,
336 id: Id,
337 modes: &[(&str, &[Item])],
338 placeholder: &str,
339) -> Option<(usize, usize)> {
348/// relevant first. Enter or a click runs the item; ⌘K (Ctrl+K elsewhere) on the highlighted
349/// one or a right-click on any opens its `actions` menu.
350pub fn palette(ui: &mut Ui, id: Id, modes: &[(&str, &[Item])], placeholder: &str) -> Option<Pick> {
340351 if !ui.popup_open(id) {
341352 return None;
342353 }
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");
343361 let window = ui.rect(Id::ROOT).unwrap_or_default();
344362 let width = PALETTE.min(window[2] - 8.0 * PAD).max(NARROWEST);
345 choose(
363 if let Some((mode, index)) = choose(
346364 ui,
347365 id,
348366 Role::Dialog,
......@@ -351,7 +369,39 @@ pub fn palette(
351369 Some(placeholder),
352370 width,
353371 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
355405}
356406
357407/// Opens popup `id` with `query` typed in its filter field and the caret after it, or
......@@ -1409,7 +1459,7 @@ impl<'a> Matches<'a> {
14091459 items
14101460 .iter()
14111461 .enumerate()
1412 .filter(|(_, item)| !item.heading)
1462 .filter(|(_, item)| !item.heading && !item.repeated)
14131463 .filter_map(|(index, item)| {
14141464 let text = Utf32Str::new(item.text, &mut chars);
14151465 Some((pattern.score(text, matcher)?, index))
crates/ui/src/tests.rs+295-8
......@@ -15,14 +15,19 @@ thread_local! {
1515 static FRAMES: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
1616}
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.
1919fn 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)) {
2025 let frames = FRAMES.with(|frames| {
2126 frames.set(frames.get() + 1);
2227 frames.get()
2328 });
2429 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);
2631 build(ui);
2732 ui.end();
2833}
......@@ -1087,13 +1092,17 @@ fn tooltips_wait_then_switch_at_once_and_hide_on_press_until_left() {
10871092 let [Some(first), None] = rest(&mut ui, 200) else {
10881093 panic!("the first tooltip shows after its delay");
10891094 };
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
10921101 ui.event(Event::PointerMoved([390.0, 10.0]));
10931102 let [None, Some(second)] = tip_frame(&mut ui) else {
10941103 panic!("moving to another shows its tooltip at once");
10951104 };
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
10981107 press(&mut ui, Instant::now(), true);
10991108 press(&mut ui, Instant::now(), false);
......@@ -1140,6 +1149,96 @@ fn popups_open_beside_their_anchor_and_flip_to_stay_in_the_window() {
11401149 );
11411150}
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
11431242#[test]
11441243fn popups_show_at_once_by_their_anchor() {
11451244 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() {
13481447 frame(ui, |ui| {
13491448 chosen = popup::palette(ui, palette, &[("", &commands)], "Search commands")
13501449 });
1351 chosen.map(|(_, index)| index)
1450 match chosen {
1451 Some(popup::Pick::Run(_, index)) => Some(index),
1452 _ => None,
1453 }
13521454 };
13531455 build(&mut ui);
13541456 ui.open_popup(palette);
......@@ -1385,7 +1487,11 @@ fn a_prefix_typed_in_the_palette_switches_what_it_lists() {
13851487 build(&mut ui);
13861488 ui.event(typed("sel"));
13871489 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 );
13891495 popup::open_with(&mut ui, palette, ">");
13901496 build(&mut ui);
13911497 ui.event(key(NamedKey::Backspace));
......@@ -1393,7 +1499,75 @@ fn a_prefix_typed_in_the_palette_switches_what_it_lists() {
13931499 assert_eq!(popup::query(&ui, palette), Some(""));
13941500 ui.event(typed("tnr"));
13951501 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 );
13971571}
13981572
13991573#[test]
......@@ -2297,11 +2471,21 @@ fn every_control_in_an_open_dialog_takes_clicks_where_it_rests() {
22972471fn a_combo_s_field_widens_over_rows_laid_out_where_they_end_and_its_rows_take_clicks() {
22982472 let mut ui = Ui::new(Theme::light(), DOUBLE_CLICK);
22992473 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");
23012476 let mut chosen = None;
23022477 let mut build = |ui: &mut Ui| {
23032478 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 );
23042487 shell::combo(ui, "combo", "Font", "Calibri", 120.0, menu_id(), true);
2488 ui.close();
23052489 let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default());
23062490 chosen = popup::menu(ui, menu_id(), anchor, &fonts, Some("Font"));
23072491 });
......@@ -2754,3 +2938,106 @@ fn password_fields_edit_the_characters_behind_their_bullets() {
27542938 edit(&mut ui, &mut text);
27552939 assert_eq!(text, "pß");
27562940}
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 {
1212 pub key: LabelKey,
1313}
1414
15type LabelKey = (String, u32, bool, Option<String>);
15type LabelKey = (String, u32, bool, bool, Option<String>);
1616
1717/// Labels shaped this frame or the previous one, keyed by their text and size.
1818#[derive(Default)]
......@@ -32,11 +32,25 @@ impl Texts {
3232 bold: bool,
3333 font: Option<&str>,
3434 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,
3548 ) -> Rc<Label> {
3649 let key = (
3750 text.to_owned(),
3851 size.to_bits(),
3952 bold,
53 italic,
4054 font.map(str::to_owned),
4155 );
4256 if let Some((label, touched)) = self.cache.get_mut(&key) {
......@@ -62,6 +76,9 @@ impl Texts {
6276 if bold {
6377 builder.push_default(StyleProperty::FontWeight(parley::FontWeight::SEMI_BOLD));
6478 }
79 if italic {
80 builder.push_default(StyleProperty::FontStyle(parley::FontStyle::Italic));
81 }
6582 let mut layout = builder.build(text);
6683 layout.break_all_lines(None);
6784 let size = [layout.full_width(), layout.height()];
......@@ -76,9 +93,9 @@ impl Texts {
7693
7794 /// `label` cut short to fit `width` with an ellipsis.
7895 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;
8097 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];
8299 let mut end = 0;
83100 let mut advance = 0.0;
84101 let mut cluster = Cluster::from_byte_index(&label.layout, 0);
......@@ -91,7 +108,7 @@ impl Texts {
91108 cluster = next.next_logical();
92109 }
93110 let cut = format!("{}…", text[..end].trim_end());
94 self.label(&cut, size, *bold, font, frame)
111 self.styled(&cut, size, *bold, *italic, font, frame)
95112 }
96113
97114 /// 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.
5555provisioning profile for `net.paperclover.snowbound` ("Snowbound Developer ID",
5656found where Xcode keeps profiles) as `Contents/embedded.provisionprofile`, with
5757hardened runtime, a secure timestamp, the production iCloud container
58`iCloud.net.paperclover.snowbound` that Use iCloud Drive needs, and the
58`iCloud.net.paperclover.snowbound` that iCloud notebooks need, and the
5959microphone and camera entitlements recording needs. It fails rather than fall
6060back to ad hoc. `--ad-hoc` signs ad hoc instead, without iCloud; the 10.6 bundle
6161stays unsigned, as it predates Developer ID. codesign fails with
tools/canvas/README.md+2-2
......@@ -14,7 +14,7 @@ cargo test -p draw -- --ignored
1414cargo test -p canvas --features gpu gpu:: -- --ignored
1515```
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
1919```sh
2020python3 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
5252
5353Launched 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
5757A 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()):
3939root = Path(__file__).resolve().parents[2]
4040# The Apple Developer team and what is registered under it. With the team's Developer ID
4141# 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);
4343# without them, ad hoc, and iCloud Drive works through folders the user picks.
4444TEAM = '9R7DPNW28H'
4545BUNDLE_ID = 'net.paperclover.snowbound'
4646CONTAINER = '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
4952def developer_id():
......@@ -147,6 +150,20 @@ if build:
147150 'NSUbiquitousContainerSupportedFolderLevels': 'Any',
148151 }},
149152 '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],
150167} | versions))
151168# 10.6 runs the bundle unsigned.
152169if 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):
3232 native = pictures(FIXTURE / 'native/read')
3333 self.assertEqual([(p, n) for p, n, _ in native], [('true', '0'), ('true', '1')])
3434 # 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.
3636 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
4040if __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()