diff --git a/apps/ios/Info.plist b/apps/ios/Info.plist index 4c4d8523c9c02c23057ad0e2cd42d2e08da38af4..ef1cb4094c7a988eb5bb7a1aeeeda6ca3b0f84ed 100644 --- a/apps/ios/Info.plist +++ b/apps/ios/Info.plist @@ -4,6 +4,21 @@ CADisableMinimumFrameDurationOnPhone + CFBundleDocumentTypes + + + CFBundleTypeName + OneNote Section + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + com.microsoft.onenote.section + + + LSSupportsOpeningDocumentsInPlace NSCameraUsageDescription @@ -38,5 +53,27 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + UTImportedTypeDeclarations + + + UTTypeConformsTo + + public.data + + UTTypeDescription + OneNote Section + UTTypeIdentifier + com.microsoft.onenote.section + UTTypeTagSpecification + + public.filename-extension + + one + + public.mime-type + application/onenote + + + diff --git a/apps/ios/Snowbound/App.swift b/apps/ios/Snowbound/App.swift index 37c8e5b2bf004626abf814304a791664146358b7..f9b5ac77699c548fc50618072c125d7093e55f84 100644 --- a/apps/ios/Snowbound/App.swift +++ b/apps/ios/Snowbound/App.swift @@ -19,6 +19,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { _ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { sb_set_coordinator(coordinate) + Editing.apply() ICloud.start() sb_set_sync_wake { DispatchQueue.main.async { NotificationCenter.default.post(name: Sync.changed, object: nil) } } #if DEBUG @@ -70,6 +71,63 @@ enum Appearance { } } +/// OneNote's Snap To Grid and Default font, kept between launches, which the pages opened +/// from then on take. +enum Editing { + /// OneNote 2010's Default font sizes, in points. + 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] + static let faces = ["Calibri", "Arial", "Times New Roman", "Courier New", "Georgia", "Verdana"] + + static var snapToGrid: Bool { + get { !UserDefaults.standard.bool(forKey: "ignoreGrid") } + set { + UserDefaults.standard.set(!newValue, forKey: "ignoreGrid") + apply() + } + } + + static var face: String { + get { UserDefaults.standard.string(forKey: "defaultFontFace") ?? "Calibri" } + set { + UserDefaults.standard.set(newValue, forKey: "defaultFontFace") + apply() + } + } + + static var size: Float { + get { UserDefaults.standard.object(forKey: "defaultFontSize") as? Float ?? 11 } + set { + UserDefaults.standard.set(newValue, forKey: "defaultFontSize") + apply() + } + } + + static func apply() { + sb_set_snap_to_grid(snapToGrid) + sb_set_default_font(face, size) + } + + /// Options' Default font: the face and size new text takes. + static func fontMenu() -> UIMenu { + let faces = faces.map { name in + let action = UIAction(title: name) { _ in face = name } + action.state = face == name ? .on : .off + return action + } + let sizes = sizes.map { points in + let action = UIAction(title: points.formatted()) { _ in size = points } + action.state = size == points ? .on : .off + return action + } + return UIMenu( + title: "Default Font", image: UIImage(systemName: "textformat"), + children: [ + UIMenu(options: .displayInline, children: faces), + UIMenu(title: "Size", children: sizes), + ]) + } +} + /// Where the reader was, reopened on the next launch as Notes reopens its last note. private struct Place: Codable { let notebook: String @@ -123,6 +181,12 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro NotificationCenter.default.addObserver(forName: ICloud.changed, object: nil, queue: .main) { [weak self] _ in self?.notebooks.rescan { self?.restore($0) } } + self.scene(scene, openURLContexts: options.urlContexts) + } + + /// A section file Files opens in Snowbound, in place. + func scene(_ scene: UIScene, openURLContexts contexts: Set) { + for context in contexts where context.url.isFileURL { notebooks.open(document: context.url) } } /// Reopens the section and page shown last, or the scripted one. diff --git a/apps/ios/Snowbound/CanvasView.swift b/apps/ios/Snowbound/CanvasView.swift index a28de9f73aa5d87e6203d72203bd6713a6ec8a3e..b2ed72e138d477a6cd0af10a4eba8e5cfde9c44a 100644 --- a/apps/ios/Snowbound/CanvasView.swift +++ b/apps/ios/Snowbound/CanvasView.swift @@ -193,6 +193,8 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI object: nil) registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: CanvasView, _) in view.paper() } formatBar.onApply = { [weak self] command in self?.apply(command) } + formatBar.onStyle = { [weak self] place in self?.applyStyle(place) } + formatBar.stylePlace = { [weak self] in self?.stylePlace ?? -1 } formatBar.onDismiss = { [weak self] in _ = self?.resignFirstResponder() } } @@ -879,6 +881,18 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI var pageText: String { handle.flatMap { take(sb_view_page_text($0)) } ?? "" } + /// The page on `paper`, in points, as OneNote 2010 prints it, each sheet's footer naming + /// `section`. + func pdf(paper: CGSize, section: String) -> Data? { + guard let handle else { return nil } + var length = 0 + guard let bytes = sb_view_pdf(handle, Float(paper.width), Float(paper.height), section, &length) else { + return nil + } + defer { sb_bytes_free(bytes, length) } + return Data(bytes: bytes, count: length) + } + /// The selection's formatting, from `sb_view_format`. var format: UInt64 { handle.map(sb_view_format) ?? 0 } @@ -934,6 +948,15 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI edit(external: true) { sb_view_apply(handle, command) } } + /// Gives the selected paragraphs style `place` of OneNote's gallery, in the page's theme. + func applyStyle(_ place: UInt8) { + guard let handle else { return } + edit(external: true) { sb_view_style(handle, place) } + } + + /// The selection's style's place in the gallery, or -1. + var stylePlace: Int32 { handle.map(sb_view_style_place) ?? -1 } + /// Shows the page as stored; with `discard`, after an edit was refused. func reload(discard: Bool) { guard let handle else { return } diff --git a/apps/ios/Snowbound/FormatBar.swift b/apps/ios/Snowbound/FormatBar.swift index 926c1da50065f09b965881b60a61b067396414e0..91f7980f369c39a0c1463863fc0f44862774222e 100644 --- a/apps/ios/Snowbound/FormatBar.swift +++ b/apps/ios/Snowbound/FormatBar.swift @@ -19,6 +19,14 @@ final class FormatBar: UIInputView { private var toggles: [(UIButton, UInt8)] = [] private var bits: UInt64 = 0 var onApply: ((UInt8) -> Void)? + /// Applies a style of OneNote's gallery, by place; `stylePlace` names the selection's. + var onStyle: ((UInt8) -> Void)? + var stylePlace: (() -> Int32)? + /// OneNote 2010's Styles gallery, in its order. + private static let styles = [ + "Heading 1", "Heading 2", "Heading 3", "Heading 4", "Heading 5", "Heading 6", + "Page Title", "Citation", "Quote", "Code", "Normal", + ] /// Asks for a picture from the camera (true) or the photo library. var onPicture: ((Bool) -> Void)? var onDismiss: (() -> Void)? @@ -33,6 +41,19 @@ final class FormatBar: UIInputView { addSubview(backdrop) let row = UIStackView() row.spacing = 2 + let styles = Self.button("textformat", "Styles") + styles.menu = UIMenu(children: [ + UIDeferredMenuElement.uncached { [weak self] done in + let current = self?.stylePlace?() ?? -1 + done(Self.styles.enumerated().map { place, name in + UIAction(title: name, state: Int32(place) == current ? .on : .off) { _ in + self?.onStyle?(UInt8(place)) + } + }) + } + ]) + styles.showsMenuAsPrimaryAction = true + row.addArrangedSubview(styles) for (symbol, label, command) in Self.buttons { let button = Self.button(symbol, label) button.addAction(UIAction { [weak self] _ in self?.onApply?(command) }, for: .primaryActionTriggered) diff --git a/apps/ios/Snowbound/ICloud.swift b/apps/ios/Snowbound/ICloud.swift index 233bfdd1fea102b542cb6dbcfa0727b038604993..62fdc27a8fb43e443df4ebefd25ca87cc3e2f802 100644 --- a/apps/ios/Snowbound/ICloud.swift +++ b/apps/ios/Snowbound/ICloud.swift @@ -54,15 +54,27 @@ enum ICloud { /// Whether iCloud is signed in with iCloud Drive on, container or not. static var signedIn: Bool { FileManager.default.ubiquityIdentityToken != nil } - /// Has the library merge conflict versions, and follows the account; call once at launch. + /// Has the library merge conflict versions, follows the account, and hears of notebooks + /// another device adds or removes; call once at launch. static func start() { sb_set_versions(listVersions, retireVersion) NotificationCenter.default.addObserver( forName: .NSUbiquityIdentityDidChange, object: nil, queue: .main ) { _ in lookUp() } lookUp() + query.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope] + query.predicate = NSPredicate(format: "%K LIKE '*'", NSMetadataItemFSNameKey) + for name in [Notification.Name.NSMetadataQueryDidFinishGathering, .NSMetadataQueryDidUpdate] { + NotificationCenter.default.addObserver(forName: name, object: query, queue: .main) { _ in + if Notebooks.inCloudChanged { NotificationCenter.default.post(name: changed, object: nil) } + } + } + query.start() } + /// Reports what iCloud Drive syncs of the container, as files come from other devices. + private static let query = NSMetadataQuery() + /// Looks the folder up off the main thread, as asking iCloud can take a while. static func lookUp() { background({ () -> URL? in @@ -77,4 +89,5 @@ enum ICloud { NotificationCenter.default.post(name: changed, object: nil) } } + } diff --git a/apps/ios/Snowbound/Ink.swift b/apps/ios/Snowbound/Ink.swift index af8746276b230e51c7e1e506d80c83aa8f72f023..c7c1ca1fb3db21a5beb9c5d2b18efe0d1486aac7 100644 --- a/apps/ios/Snowbound/Ink.swift +++ b/apps/ios/Snowbound/Ink.swift @@ -240,8 +240,9 @@ final class InkPicker: UIView { color.accessibilityLabel = "Colour, \(Self.name(shown.color))" } + /// The shapes, then OneNote's Snap To Grid. private func shapeMenu() -> [UIMenuElement] { - Self.shapes.enumerated().map { place, shape in + let shapes = Self.shapes.enumerated().map { place, shape in let kind = UInt8(place) let action = UIAction(title: shape.1, image: UIImage(systemName: shape.0)) { [weak self] _ in self?.onPick?(.shape(kind)) @@ -249,6 +250,11 @@ final class InkPicker: UIView { action.state = tool == .shape(kind) ? .on : .off return action } + let snap = UIAction(title: "Snap To Grid", image: UIImage(systemName: "grid")) { _ in + Editing.snapToGrid.toggle() + } + snap.state = Editing.snapToGrid ? .on : .off + return [UIMenu(options: .displayInline, children: shapes), UIMenu(options: .displayInline, children: [snap])] } /// The colours of the kind of pen shown, and for a pen its two widths. diff --git a/apps/ios/Snowbound/Lists.swift b/apps/ios/Snowbound/Lists.swift index 0c31c065fbd92eeb58915cf5544f1bb615db1245..ce578f1d86747a218eeb30943494deb8173a1249 100644 --- a/apps/ios/Snowbound/Lists.swift +++ b/apps/ios/Snowbound/Lists.swift @@ -104,6 +104,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke Author.ask(from: self) {} }) more.append(Appearance.menu()) + more.append(Editing.fontMenu()) provide(actions + [UIMenu(options: .displayInline, children: more)]) } ]) @@ -535,10 +536,13 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke private func add(_ notebook: Notebook) { Notebooks.add(notebook) + openFirstSection(of: notebook) + } + + private func openFirstSection(of notebook: Notebook) { reload() notebook.open { [weak self] in self?.reload() - // A notebook just added opens at its first section. if let tab = notebook.tabs.first(where: \.readable) { self?.onOpen?(tab, notebook) } } } @@ -549,14 +553,21 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke guard let author = Author.name else { return Author.ask(from: self) { [weak self] in self?.newNotebook(inCloud: inCloud) } } - let alert = UIAlertController(title: "New Notebook", message: nil, preferredStyle: .alert) + let alert = UIAlertController( + title: inCloud ? "New iCloud Notebook" : "New Notebook", message: nil, preferredStyle: .alert) let create = UIAlertAction(title: "Create", style: .default) { [weak self, weak alert] _ in let name = alert?.textFields?.first?.text?.trimmingCharacters(in: .whitespaces) ?? "" self?.create(name, by: author, inCloud: inCloud) } - create.isEnabled = false alert.addTextField { field in field.placeholder = "Name" + // A notebook in iCloud Drive starts named for it, so the list tells it apart. + if inCloud { + let taken = Set(Notebooks.inCloud.map(\.name)) + field.text = (1...).lazy.map { $0 == 1 ? "iCloud Notebook" : "iCloud Notebook \($0)" } + .first { !taken.contains($0) } + } + create.isEnabled = field.text?.isEmpty == false field.autocapitalizationType = .words NotificationCenter.default.addObserver( forName: UITextField.textDidChangeNotification, object: field, queue: .main @@ -634,9 +645,25 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke } func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { - guard let url = urls.first else { return } + if let url = urls.first { open(document: url) } + } + + /// Opens the folder or section file at `url`, chosen here or opened from Files, at its first + /// section, listing it unless it is listed. + func open(document url: URL) { let accessing = url.startAccessingSecurityScopedResource() defer { if accessing { url.stopAccessingSecurityScopedResource() } } + let listed = Notebooks.all.first { notebook in + guard case .files(let bookmark) = notebook.source else { return false } + var stale = false + let location = try? URL(resolvingBookmarkData: bookmark, bookmarkDataIsStale: &stale) + return location?.standardizedFileURL == url.standardizedFileURL + } + if let listed { + // A notebook still opening at launch lists no sections yet. + if let tab = listed.tabs.first(where: \.readable) { onOpen?(tab, listed) } else { openFirstSection(of: listed) } + return + } guard let bookmark = try? url.bookmarkData() else { return } add(Notebook(name: url.deletingPathExtension().lastPathComponent, source: .files(bookmark: bookmark))) } diff --git a/apps/ios/Snowbound/Notebooks.swift b/apps/ios/Snowbound/Notebooks.swift index b408549b0a0daad3010304af9144075cc7fddd5a..17562e7fdfb14a1e7ff3021420d38e255db6cb2a 100644 --- a/apps/ios/Snowbound/Notebooks.swift +++ b/apps/ios/Snowbound/Notebooks.swift @@ -277,7 +277,8 @@ final class Notebook { } /// Asks iCloud Drive for the notebook files it keeps elsewhere, which iOS lists as - /// `.Name.icloud`, and lists the sections again until they arrive. + /// `.Name.icloud` or dataless under their names, and lists the sections again until they + /// arrive. private func download() { let folder: URL? = switch source { @@ -288,13 +289,17 @@ final class Notebook { case .server: nil } guard let folder, recheck == nil, - let files = FileManager.default.enumerator(at: folder, includingPropertiesForKeys: nil) + let files = FileManager.default.enumerator( + at: folder, includingPropertiesForKeys: [.ubiquitousItemDownloadingStatusKey]) else { return } var waiting = false - for case let stub as URL in files where stub.lastPathComponent.hasPrefix(".") && stub.pathExtension == "icloud" { - let name = String(stub.deletingPathExtension().lastPathComponent.dropFirst()) - let file = stub.deletingLastPathComponent().appendingPathComponent(name) - try? FileManager.default.startDownloadingUbiquitousItem(at: file) + for case let file as URL in files { + let stub = file.lastPathComponent.hasPrefix(".") && file.pathExtension == "icloud" + let status = try? file.resourceValues(forKeys: [.ubiquitousItemDownloadingStatusKey]) + guard stub || status?.ubiquitousItemDownloadingStatus == .notDownloaded else { continue } + let real = file.deletingLastPathComponent().appendingPathComponent( + String(file.deletingPathExtension().lastPathComponent.dropFirst())) + try? FileManager.default.startDownloadingUbiquitousItem(at: stub ? real : file) waiting = true } guard waiting else { return } @@ -485,6 +490,13 @@ enum Notebooks { return added } + /// Whether Snowbound's folder in iCloud Drive holds other notebooks than the list shows, as + /// when another device added or removed one. + static var inCloudChanged: Bool { + guard let folder = ICloud.documents, scripted == nil else { return false } + return notebooks(in: folder).map { "iCloud/" + $0 } != inCloud.map(\.id) + } + /// The notebook folders and section files in `folder`, by name, as Files orders them. private static func notebooks(in folder: URL) -> [String] { let listed = try? FileManager.default.contentsOfDirectory( @@ -648,6 +660,27 @@ final class Section { } } + /// The notebook's themes, and the one page `id`, its section and its notebook each name. + struct Themes: Decodable { + struct Listed: Decodable { + let id: String + let name: String + } + let themes: [Listed] + let page: String? + let section: String? + let notebook: String? + } + + func themes(of id: String) -> Themes? { decode(Themes.self, sb_section_themes(handle, id)) } + + /// Gives `theme`, or none of its own, to page `id` (scope 0), its section (1) or its + /// notebook (2). + func setTheme(_ theme: String?, scope: UInt8, of id: String, done: @escaping (Bool) -> Void) { + let pointer = Int(bitPattern: handle) + background({ sb_section_set_theme(OpaquePointer(bitPattern: pointer), id, scope, theme) }, done: done) + } + /// Stores every edit and publishes them within `seconds`; call off the main thread. func flush(_ seconds: Double) -> Bool { sb_section_flush(handle, seconds) } diff --git a/apps/ios/Snowbound/PageViewController.swift b/apps/ios/Snowbound/PageViewController.swift index 937623ef55f5be22250b987a417530218da2e5c2..ccaa9a8eb997ef3b8ac0d4b3aab941754e4d56d1 100644 --- a/apps/ios/Snowbound/PageViewController.swift +++ b/apps/ios/Snowbound/PageViewController.swift @@ -169,10 +169,19 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate UIAction(title: "Page Background…", image: UIImage(systemName: "paintpalette"), attributes: editable) { [weak self] _ in self?.showPaper() }, + themeMenu(), UIAction(title: "Tags Summary", image: UIImage(systemName: "tag")) { [weak self] _ in guard let self else { return } scene?.showTags(of: section.notebook, from: self) }, + UIMenu(options: .displayInline, children: [ + UIAction(title: "Print…", image: UIImage(systemName: "printer")) { [weak self] _ in + self?.printPage(export: false) + }, + UIAction(title: "Export as PDF…", image: UIImage(systemName: "doc.richtext")) { [weak self] _ in + self?.printPage(export: true) + }, + ]), UIMenu(options: .displayInline, children: [ UIAction(title: "New Subpage", image: UIImage(systemName: "text.badge.plus")) { _ in scene?.newPage(subpage: true) @@ -186,6 +195,69 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate ] } + /// The page's theme, its section's and its notebook's, each pickable; the page wears a + /// new one when it opens again. + private func themeMenu() -> UIMenu { + guard let themes = section.themes(of: page) else { + return UIMenu(title: "Theme", image: UIImage(systemName: "textformat"), children: []) + } + let scopes: [(String, String?)] = [ + ("This Page", themes.page), ("This Section", themes.section), ("This Notebook", themes.notebook), + ] + let page = page + let menus = scopes.enumerated().map { scope, entry in + let (title, current) = entry + let choices = [(nil as String?, "None")] + themes.themes.map { ($0.id, $0.name) } + return UIMenu(title: title, children: choices.map { id, name in + UIAction(title: name, state: id == current ? .on : .off) { [weak self] _ in + guard let self else { return } + section.setTheme(id, scope: UInt8(scope), of: page) { [weak self] _ in + self?.onOpen?(page) + } + } + }) + } + return UIMenu(title: "Theme", image: UIImage(systemName: "textformat"), children: menus) + } + + /// Prints the page through the system's print sheet, or with `export` offers its PDF in + /// the share sheet, laid out as OneNote 2010 prints it on the region's paper. + private func printPage(export: Bool) { + let letter = ["US", "CA", "MX", "PR", "PH", "CL", "CO", "VE", "GT", "CR", "PA", "DO", "SV", "NI", "HN", "BZ"] + .contains(Locale.current.region?.identifier ?? "US") + let paper = letter ? CGSize(width: 612, height: 792) : CGSize(width: 595.276, height: 841.89) + let title = canvas.pageTitle ?? section.row(of: page)?.row.title ?? "" + guard let pdf = canvas.pdf(paper: paper, section: section.tab.name) else { + let alert = UIAlertController( + title: export ? "Couldn't Export the PDF" : "Couldn't Print", + message: "The page could not be laid out on paper.", preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: true) + return + } + if export { + let name = title.components(separatedBy: CharacterSet(charactersIn: "/\\:")).joined(separator: "-") + .trimmingCharacters(in: .whitespaces) + let file = FileManager.default.temporaryDirectory + .appendingPathComponent((name.isEmpty ? "Untitled" : name) + ".pdf") + do { + try pdf.write(to: file) + } catch { + return + } + let share = UIActivityViewController(activityItems: [file], applicationActivities: nil) + share.popoverPresentationController?.barButtonItem = more + present(share, animated: true) + } else { + let info = UIPrintInfo.printInfo() + info.jobName = title + let printer = UIPrintInteractionController.shared + printer.printInfo = info + printer.printingItem = pdf + printer.present(from: more, animated: true) + } + } + private func showPaper() { let navigation = UINavigationController(rootViewController: PaperViewController(canvas: canvas)) navigation.sheetPresentationController?.detents = [.medium(), .large()] diff --git a/arc/canvas.md b/arc/canvas.md index 433dd51f6adeeb3ca4f8ca0f18a3459f6ad7313c..e098a9305527713283a691dd63ea2d4c5ef2e99b 100644 --- a/arc/canvas.md +++ b/arc/canvas.md @@ -48,6 +48,17 @@ ops still reach storage as it is typed. Undoing a deletion brings back the original identities, so internal links to those paragraphs survive an undo. +Each page keeps its history while the window is open, as in OneNote 2010: the +desktop app parks a page's editor when it is left and takes it up again, through +the same reload in place, when the page opens. Between those edits the app keeps +what was done to pages and sections (new, deleted, moved and indented pages, page +and section names, sections moved), and Undo takes back whichever came last: the +open page's own step, or the last such action in its section or notebook, +showing the page it changes. Unlike OneNote, an edit doesn't end what actions Undo +can reach, so New Page, a typed title, Undo, Undo takes the title and then the +page. Each step back is computed against the section as it is then, one edit of +its own; a step whose page is gone, or holds work it didn't make, is passed over. + Input-method composition (marked text) stays inside the editor until it commits, and only then becomes ops. When another client changes the open page, the 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. New Roman or Courier New, bundled metric-compatible substitutes (Carlito, Arimo, Tinos, Cousine) stand in, under the stored font name. - **OneNote's constants are OneNote's.** New outlines take OneNote's default - width. Dragging snaps to its grid, anchored at the page's margin origin. Tags + width. Dragging snaps to its grid, anchored at the page's margin origin, unless + Snap To Grid (the shape gallery's last item, kept between launches) is off. Tags sit in a column to the left of the text with OneNote's spacing, and a tag's colour paints the whole paragraph as it does there. @@ -106,6 +118,12 @@ on the page itself, as OneNote 2010 places it: the same column at the caret's grid point, outside any outline. It selects, drags on the grid, deletes and moves with Insert Space as a picture does, and opens and saves as above. +A tag on a picture or file is stored on the object itself, as OneNote 2010 stores +the tag Ctrl+1 gives a selected one, and drawn in a column left of it, centred on +it; a click checks its box (`corpus/object-tags`). A picture's link follows on +Ctrl+click, Command+click on macOS, while a click selects it, as OneNote's +tooltip says (`corpus/picture-link`). + An outline holding only pictures or files takes a paragraph after them where a click beside them lands, as OneNote 2010 adds one when typing there. It is stored with the first edit that reaches it, and undoing back to it empty takes it @@ -114,16 +132,20 @@ out again (`corpus/object-outline`). A file printout's pages are pictures whose stored data is the printout's XPS package. The page draws the PNG OneNote rendered of each (WebPictureContainer14), framed in grey as OneNote frames them, and moves them as -pictures. Snowbound does not write XPS, so a printout page deleted and brought -back by undo returns as that picture (`corpus/printout`). +pictures. A printout page deleted and brought back by undo returns as the page it +was: its XPS package, the PNG and the properties tying it to the printout, written +back as read (`corpus/printout`). Pictures go in as OneNote 2010 pastes and inserts them. At a caret in text the paragraph splits around the picture, as around a file. On blank page the picture -lies on the page at the caret, and the caret moves to the grid row below it. A -picture takes the size its resolution gives it, or 96 dpi without one. Paste -takes files first (a picture file as its picture), then text, then a picture: -Finder offers a copied file's name and icon beside it, and other apps a picture -of copied text. +lies on the page at the caret, and the caret moves to the grid row below it. +From the title it joins the outline where the body starts, or lies two grid rows +below the page's content when none starts there. A picture takes the size its +resolution gives it, or 96 dpi without one. Paste takes files first (a picture +file as its picture, as a dropped one goes in), then a web page holding +pictures, its text and pictures in order as one undo step, then text, then a +picture: Finder offers a copied file's name and icon beside it, and other apps a +picture of copied text. ## Recordings @@ -176,6 +198,14 @@ tablet pressure on Linux. ## Tables and selections across them +Columns size as OneNote 2010 sizes them (`corpus/table-widths`). An unlocked +column fits its widest line plus 4.347 pt, never under a new column's 37.11 pt, +widening and narrowing with each edit, which stores the width in the same +revision as its text. A table stops at the outline's width and its cells wrap +from there. Dragging a column's right border resizes that column alone, the +columns after it moving with it, down to 37.11 pt; on release it is one edit +and one undo step, and the column is locked, so typing no longer fits it. + Note tags on a table sit in the tag column centred on it, and a click checks its box (`corpus/table-tags`). A selection crossing a table's edge deletes as OneNote 2010 deletes one: nothing joins across the edge, cells inside are @@ -185,6 +215,28 @@ table with them when all do. What replaces the selection goes in at its start Shift+Enter a caret inside a link, as OneNote's do; Alt+= across paragraphs makes each paragraph's part an equation, and a deletion between two equations joins them where neither seam lies inside an object (`corpus/equation-join`). +An edit replacing a selection with an end inside an equation's object (a +fraction, a script, a root) takes the whole object first, as OneNote's equation +editor selects, and across paragraphs an object so taken at its paragraph's end +takes that end too; a placeholder ("Type equation here.") is taken whole +(`corpus/equation-select`). + +## Styles and themes + +The Styles gallery is OneNote 2010's: Heading 1 to 6, Page Title, Citation, Quote, Code +and Normal, stored under OneNote's names (`h1`, `PageTitle`, `cite`, `blockquote`, `code`, +`p`). Applying one gives the paragraph the page's style object of that definition and +clears its character formatting but links, fields and language, as OneNote's does; Enter +at a paragraph's end takes its style's NextStyle, so a heading is followed by Normal. +Ctrl+Alt+1 to 6 apply the headings, and Clear Formatting at a caret applies Normal. + +A theme gives the eleven styles a look, and a page wears it as its style objects: OneNote +draws a page by its own style objects, so OneNote users see the theme under the same +names. Changing a theme moves each styled paragraph to a new style object of the same name +(style objects are read-only) in one revision per page, and a page is brought to its +theme when it opens, so a notebook-wide change costs each page only when someone looks at +it. Which theme a notebook, section or page wears lives in the notebook's `.snowbound` +folder, beside the tags' art (`corpus/styles`). ## Content the editor doesn't understand @@ -240,13 +292,17 @@ template art print across the paper; the page colour does not. The footer names section and numbers the sheets. `draw::pdf` writes the same primitives the screen paints as PDF: text in subset fonts whose ToUnicode maps come from the laid-out text, so it selects and searches, ligatures included; ink and shapes as paths; pictures and tag -icons as images. The desktop's Print hands that PDF to AppKit's print panel, the XDG print -portal or the shell's print verb for PDFs; Export as PDF saves it. +icons as images. On the desktop, Print and Export as PDF first show OneNote's Print Preview +and Settings, less the preview: the range (page, page group, section, and for a PDF the +notebook), paper, orientation, fitting to the paper's width and the footer. Print then hands +the PDF to AppKit's print panel, the XDG print portal or the shell's print verb for PDFs. +On iOS the page menu prints the page through the print sheet or shares its PDF. ## Search, dates, conflicts Smaller modules follow the same pattern of reproducing OneNote's behaviour precisely. `search` matches the way OneNote 2010 searches: word prefixes, -ignoring case and diacritics, title matches first. `date` edits the title's +ignoring case and diacritics, title matches first, and the text OneNote recognised +in pictures, which it stores in them, as OneNote's search finds it. `date` edits the title's date and time fields the way OneNote stores a changed page date. `conflict` shows conflict pages with OneNote's highlight. diff --git a/arc/sync.md b/arc/sync.md index 68e367b55c7311f0afcc1ee5d3250bdbde4cb65b..4fe269ae6f0309f53038f622a1f910c803be271b 100644 --- a/arc/sync.md +++ b/arc/sync.md @@ -285,8 +285,13 @@ version at once; nothing waits for the device that wrote it. A version of anothe one that cannot be read, is kept beside the file as `Name (Device).one` instead. On a cloud drive every read and write of a section file runs under `NSFileCoordinator`, so the -iCloud daemon never swaps a file between the stamp check and the append. A file iCloud evicted is -asked for and read later; the replica serves it meanwhile. Each publication is an upload and a +iCloud daemon never swaps a file between the stamp check and the append. A file iCloud evicted +(a `.Name.icloud` placeholder, or on macOS 14 and later a dataless file under its own name) lists +as last read, or as downloading; the host asks for it, and reads the notebook again as files +arrive. The sync thread never waits for one: the replica serves it meanwhile. Opening a section +with no replica yet waits for its file. macOS and iOS offer no public way to keep a file +downloaded, as the Finder's Keep Downloaded does; evicted files are asked for again whenever +seen. Each publication is an upload and a chance to conflict, so edits wait for a 3 second pause in typing (at most 30 seconds, `Section::set_pause`), and publish at once when the app leaves the foreground, the screen locks, the Mac's window loses focus or the app quits. A folder presenter reports other devices' @@ -305,7 +310,10 @@ which are revision stores edited through the same transaction path. Moving or renaming a file also rewrites two header fields OneNote checks on open (the parent TOC's identity and a CRC of the file's name). Without them OneNote treats the file as a stranger and re-identifies it. Deleting sends sections and -pages to `OneNote_RecycleBin`, as OneNote does. +pages to `OneNote_RecycleBin`, as OneNote does, and opening a notebook empties +what the bin has held unchanged for 60 days, as OneNote prunes it: judged by the +content's last change, the pages in one revision, binned section files deleted +with their TOC entries left (`corpus/recycle-purge`). What only Snowbound reads lives in the notebook's `.snowbound` folder, which carries the Windows hidden attribute so that OneNote never makes a section group of it. Its files are diff --git a/arc/ui.md b/arc/ui.md index 0003b469f76087a3996f7a1d86046b92de3a68d8..2823a0eb21e759c3ceb7637b07d01cb76d917422 100644 --- a/arc/ui.md +++ b/arc/ui.md @@ -13,7 +13,8 @@ with a faint highlight along their tops. The open tab lies on top, in its section's colour, and frames the page. That colour shades gently down the window and becomes the accent for the whole interface, easing to the next section's colour when you switch. Page tabs sit on the right, and the open -one joins the page with rounded inside corners. It is OneNote 2010's layout, +one joins the page with rounded inside corners; OneNote's Display options move +them to the left and the notebooks to the right. It is OneNote 2010's layout, drawn with soft shadows, concentric corner radii and a dark appearance. Call it half-skeuomorphic: the shapes that made OneNote's notebook metaphor legible, without the 2010 chrome. @@ -81,7 +82,9 @@ frame N *folds* its groups, boxes with a full and a folded form (`Spec::fold`), by priority. Only then do boxes give up room by their *strictness*, the least strict first. That one knob covers most of what flexbox is usually needed - for. + for. A box filling across a parent sized by its children stretches to what + its siblings make it, and a popup that isn't strict gives way to the window, + so a dialog is as tall as its contents up to the window and scrolls within. - **The toolbar is one row that never overflows.** Both forms of every group are built each frame and the solver picks, so a group folds in the frame the window narrows, with no widths remembered or worked out by the app. A folded diff --git a/corpus/default-font/README.md b/corpus/default-font/README.md new file mode 100644 index 0000000000000000000000000000000000000000..54e35ad0de0d00cf60df7a8a2b73861480583ef7 --- /dev/null +++ b/corpus/default-font/README.md @@ -0,0 +1,21 @@ +# Default font + +OneNote 2010 keeps Options > General > Default font (Font, Size, Font color) in +`HKCU\Software\Microsoft\Office\14.0\OneNote\Options\Editing` as `DefaultFontFace`, +`DefaultFontSize` and `DefaultFontColor` (a COLORREF DWORD; its +picker is Office's theme and standard colours, `options-font-color.png`). Observed in a lab clone on 2026-09-30, it never writes run +formatting for it: a new page's `PageTitle` quick style takes the face at 17 pt +whatever the size, and new text takes a `p` quick style in the face and size +(plain, in the font colour, no highlight or paragraph spacing). On a page whose `p` is in +another font, new text gets a second `p` beside it, the old text keeping its own. +`native/Lab.one` is that section: "Title text" (Georgia 14), "T8" (Arial 8), +"C14B14" (Calibri 14), then "Later" typed on "Title text" with Calibri 14, its +second `p`. `native/Color.one` is a page made after picking Blue: `PageTitle` and `p` +both take `#0070C0`, runs no colour of their own (`color.png`). + +`candidate/` is `crates/canvas/tests/default_font.rs`'s section, exported with +`CANVAS_DEFAULT_FONT_EXPORT`: a page created titled in Georgia and Blue, and two outlines +the editor started with the Default font Georgia 14 in Blue, each its own appended +revision. `cold/page.xml` and `cold/page.png` are a fresh OneNote 2010's read: +`PageTitle` Georgia 17 and one `p` Georgia 14, both `#0070C0`, as OneNote writes them. +`tools/test_default_font.py` checks it without a VM. diff --git a/corpus/default-font/candidate/Open Notebook.onetoc2 b/corpus/default-font/candidate/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..aa3bb97723efaa5542fa31f7bd3491638f6d83b0 Binary files /dev/null and b/corpus/default-font/candidate/Open Notebook.onetoc2 differ diff --git a/corpus/default-font/candidate/fonts.one b/corpus/default-font/candidate/fonts.one new file mode 100644 index 0000000000000000000000000000000000000000..072c1a176c0755b01bf05cf37f24462b4905538a Binary files /dev/null and b/corpus/default-font/candidate/fonts.one differ diff --git a/corpus/default-font/cold/page.png b/corpus/default-font/cold/page.png new file mode 100644 index 0000000000000000000000000000000000000000..e46132effc35d3d901838f10d550849030571621 Binary files /dev/null and b/corpus/default-font/cold/page.png differ diff --git a/corpus/default-font/cold/page.xml b/corpus/default-font/cold/page.xml new file mode 100644 index 0000000000000000000000000000000000000000..ee9cae629d3a14230733d11859c6a7f4c0fa85c3 --- /dev/null +++ b/corpus/default-font/cold/page.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/default-font/native/Color.one b/corpus/default-font/native/Color.one new file mode 100644 index 0000000000000000000000000000000000000000..7cd33a5c64b51cd06b8fc62c8997e339b65f50b1 Binary files /dev/null and b/corpus/default-font/native/Color.one differ diff --git a/corpus/default-font/native/Lab.one b/corpus/default-font/native/Lab.one new file mode 100644 index 0000000000000000000000000000000000000000..3669adb88d8aff85e1bd3fc5c64a0140f361060e Binary files /dev/null and b/corpus/default-font/native/Lab.one differ diff --git a/corpus/default-font/native/color.png b/corpus/default-font/native/color.png new file mode 100644 index 0000000000000000000000000000000000000000..f9360edfaa65a2f0ed56e3acf8f351f21254bb83 Binary files /dev/null and b/corpus/default-font/native/color.png differ diff --git a/corpus/default-font/native/options-font-color.png b/corpus/default-font/native/options-font-color.png new file mode 100644 index 0000000000000000000000000000000000000000..52b1c8d0d84c085512760fb0f127ec43ea2993e6 Binary files /dev/null and b/corpus/default-font/native/options-font-color.png differ diff --git a/corpus/object-tags/README.md b/corpus/object-tags/README.md new file mode 100644 index 0000000000000000000000000000000000000000..10300918137de05d850598bd66ec3b49cb944511 --- /dev/null +++ b/corpus/object-tags/README.md @@ -0,0 +1,27 @@ +# Tags on pictures and files + +`native/` is a notebook OneNote 2010 made in the lab: `tools/native/picture-objects.ps1` +put a picture and a file in an outline, and a picture, a file and a picture of text on the +page; each picture and file was then selected and tagged To Do with Ctrl+1 by hand. +OneNote stores each tag on the picture or file itself (NoteTagStates on jcidImageNode and +jcidEmbeddedFileNode), in an outline as on the page, though its COM read shows an outline +object's tag on its paragraph. It draws the check box in a column left of the object, +centred on it: 20.25 points out from an outline's picture, as from text, and 24.75 points +from one on the page. It recognised "GLACIER HARBOR / Invoice 4471" in the picture of text +and stored it as the picture's RichEditTextUnicode, which its search finds (the read's +`OCRText`). A Ctrl+K on a selected picture there opened no dialog; the address typed +became a link to a new page, which the section keeps. + +`candidate/` is the output of `tags_on_pictures_and_files_draw_and_check` in +`crates/canvas/tests/pictures.rs`: the page picture's, the page file's and the outline +picture's check boxes clicked, one revision each. `cold/` is a fresh OneNote 2010 read of +it: those three checked, the outline file's not, and the recognised text kept. +`tools/test_object_tags.py` checks this without a VM. Regenerate with +`CANVAS_OBJECT_TAGS_EXPORT` set to a new absolute directory while running the test, then +cold-open it with `tools/native_runner.py OUTPUT COLD --expected-pages 4 --collect-notebook --screenshots`. + +`restored/` is the output of `a_restored_picture_keeps_its_recognised_text`: the picture of +text deleted and brought back by undo, written with its text (RichEditTextUnicode), its +language and the undocumented layout of its words (0x1C001D61) as read, and its cold read, +whose OCRData equals the native read's, words and positions alike +(`CANVAS_RECOGNIZED_EXPORT`). diff --git a/corpus/object-tags/candidate/Open Notebook.onetoc2 b/corpus/object-tags/candidate/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..e1692fd9fce225b307caec2c9991379898d29991 Binary files /dev/null and b/corpus/object-tags/candidate/Open Notebook.onetoc2 differ diff --git a/corpus/object-tags/candidate/files.one b/corpus/object-tags/candidate/files.one new file mode 100644 index 0000000000000000000000000000000000000000..970c722b4c8f1b0651b4bdef40b8263ef655679f Binary files /dev/null and b/corpus/object-tags/candidate/files.one differ diff --git a/corpus/object-tags/cold/commands.jsonl b/corpus/object-tags/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..17b08bd5ce6958fe50c8d35be112e2ca8d9db73c --- /dev/null +++ b/corpus/object-tags/cold/commands.jsonl @@ -0,0 +1,3 @@ +{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-A6B430A7 -ExpectedPages 4 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 4 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} diff --git a/corpus/object-tags/cold/machine.json b/corpus/object-tags/cold/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..a82aef96f84ca600d8cc912b36832313b420f86a --- /dev/null +++ b/corpus/object-tags/cold/machine.json @@ -0,0 +1 @@ +{"name": "m6-a6b430a7", "hostname": "ONE-M6-A6B430A7"} diff --git a/corpus/object-tags/cold/notebook/Open Notebook.onetoc2 b/corpus/object-tags/cold/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..94133037d877e7c1e994e2f74e623c4e7039e6b3 Binary files /dev/null and b/corpus/object-tags/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/object-tags/cold/notebook/files.one b/corpus/object-tags/cold/notebook/files.one new file mode 100644 index 0000000000000000000000000000000000000000..c5fcc0072d49af2ff91765a0f625639e6cf00f04 Binary files /dev/null and b/corpus/object-tags/cold/notebook/files.one differ diff --git a/corpus/object-tags/cold/read/32a01b6d0a94deebc53c688868b14b6fccc4e18b3b64ea052823e3856cf35b89.attachment b/corpus/object-tags/cold/read/32a01b6d0a94deebc53c688868b14b6fccc4e18b3b64ea052823e3856cf35b89.attachment new file mode 100644 index 0000000000000000000000000000000000000000..3e82e7de059f9ddb683cf651a2c12114e2beb1bd --- /dev/null +++ b/corpus/object-tags/cold/read/32a01b6d0a94deebc53c688868b14b6fccc4e18b3b64ea052823e3856cf35b89.attachment @@ -0,0 +1 @@ +Floating file, stored at page level. diff --git a/corpus/object-tags/cold/read/33fdf6e43c05ed910d335eda3fa269ccbd1415336c17a4a5357d009ee495b3a6.attachment b/corpus/object-tags/cold/read/33fdf6e43c05ed910d335eda3fa269ccbd1415336c17a4a5357d009ee495b3a6.attachment new file mode 100644 index 0000000000000000000000000000000000000000..8265454ede447c16d88088298dde04213d50d0be --- /dev/null +++ b/corpus/object-tags/cold/read/33fdf6e43c05ed910d335eda3fa269ccbd1415336c17a4a5357d009ee495b3a6.attachment @@ -0,0 +1 @@ +notes for tagging diff --git a/corpus/object-tags/cold/read/environment.json b/corpus/object-tags/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..14b40864988951ca7fca4b3701042d0b14cc263d --- /dev/null +++ b/corpus/object-tags/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-A6B430A7", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/object-tags/cold/read/hierarchy.xml b/corpus/object-tags/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..e97d3b0a528e3e01541f8c26cd6268ec5f24a38b --- /dev/null +++ b/corpus/object-tags/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/object-tags/cold/read/page-000.navigation.json b/corpus/object-tags/cold/read/page-000.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..fdd4663bcd809095dbccb3a5bd8337dd95a9e8d0 --- /dev/null +++ b/corpus/object-tags/cold/read/page-000.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Pictures and files - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/object-tags/cold/read/page-000.png b/corpus/object-tags/cold/read/page-000.png new file mode 100644 index 0000000000000000000000000000000000000000..6c372a14829d9eeaec22d90e0f68cc6fbfc6ff63 Binary files /dev/null and b/corpus/object-tags/cold/read/page-000.png differ diff --git a/corpus/object-tags/cold/read/page-000.xml b/corpus/object-tags/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..c7dca86145203ef723921f0a9cb072af40435661 --- /dev/null +++ b/corpus/object-tags/cold/read/page-000.xml @@ -0,0 +1,194 @@ + +iVBORw0KGgoAAAANSUhEUgAAAKAAAAB4CAIAAAD6wG44AAACWklEQVR4nO3dy1EkQRAE0WYMsRCh +DoiAGIiDCBwQYsVaFcCgKyK93M8cJvNZ9vAzeHp5/3cZt0f6Bdi9CQxPYHgCwxMYnsDwBIYnMDyB +4QkMT2B4AsMTGJ7A8ASGJzA8geEJDE9geM8Xt6/Xt+9/8Pr8uIg9YX7p7kec55DPBv5zVB72SOBt +rgDpScBB17nSM4CraGcxtwPX0k5h7gUeQdvPXPqNjnG6V+trrrvgzjXNPeWuCwboXmVTtFxw1VJI +p1xxwUjdq2OuPHDDFsDTPQ6fHz9jEvgE3fikMeBzdLPzZoBP0w1OHQA+Uzc1+27gk3UjG8h/mWQc +YM93/x72Aasb2YaPaHibgD3f1E68YHg7gD3f4Ga8YHi3A3u+2f14wfAEhncvsM/n+Ja8YHgCwxMY +3o3AvgE37MoLhicwPIHhCQxPYHgCwxMYnsDwBIYnMLwH+w8YTOm+XXnB8ASGJzC8e4F9G45vyQuG +JzC824F9Smf34wXD2wHsEQc34wXD2wTsEad24gXD2wfsEUe2sfWCNd6/Bx/R8HYDe8Rr74/JAxd8 +svHaPnvmEX2m8UpMHXsPPs14heZNfpJ1jvHKTRr+LPoE4xWdMf9lEtt4pafLAzdsATxXy7+2g/3V +h1VAW3TBhXvBTNF1wdNPeTXRNl5w86aGvubSC551yquSdgZwOfMqpp0EXMi86mnnATdIryGus4H3 +S69prhDgW7HXWFQm8C/JF4LzLGDr/UaH/VUCwxMYnsDwBIYnMDyB4QkMT2B4AsMTGJ7A8ASGJzA8 +geEJDE9geAJf7P4Dry+9j2KmlKMAAAAASUVORK5CYII=iVBORw0KGgoAAAANSUhEUgAAAKAAAAB4CAIAAAD6wG44AAACWklEQVR4nO3dy1EkQRAE0WYMsRCh +DoiAGIiDCBwQYsVaFcCgKyK93M8cJvNZ9vAzeHp5/3cZt0f6Bdi9CQxPYHgCwxMYnsDwBIYnMDyB +4QkMT2B4AsMTGJ7A8ASGJzA8geEJDE9geM8Xt6/Xt+9/8Pr8uIg9YX7p7kec55DPBv5zVB72SOBt +rgDpScBB17nSM4CraGcxtwPX0k5h7gUeQdvPXPqNjnG6V+trrrvgzjXNPeWuCwboXmVTtFxw1VJI +p1xxwUjdq2OuPHDDFsDTPQ6fHz9jEvgE3fikMeBzdLPzZoBP0w1OHQA+Uzc1+27gk3UjG8h/mWQc +YM93/x72Aasb2YaPaHibgD3f1E68YHg7gD3f4Ga8YHi3A3u+2f14wfAEhncvsM/n+Ja8YHgCwxMY +3o3AvgE37MoLhicwPIHhCQxPYHgCwxMYnsDwBIYnMLwH+w8YTOm+XXnB8ASGJzC8e4F9G45vyQuG +JzC824F9Smf34wXD2wHsEQc34wXD2wTsEad24gXD2wfsEUe2sfWCNd6/Bx/R8HYDe8Rr74/JAxd8 +svHaPnvmEX2m8UpMHXsPPs14heZNfpJ1jvHKTRr+LPoE4xWdMf9lEtt4pafLAzdsATxXy7+2g/3V +h1VAW3TBhXvBTNF1wdNPeTXRNl5w86aGvubSC551yquSdgZwOfMqpp0EXMi86mnnATdIryGus4H3 +S69prhDgW7HXWFQm8C/JF4LzLGDr/UaH/VUCwxMYnsDwBIYnMDyB4QkMT2B4AsMTGJ7A8ASGJzA8 +geEJDE9geAJf7P4Dry+9j2KmlKMAAAAASUVORK5CYII=https://example.invalid/picture]]>iVBORw0KGgoAAAANSUhEUgAAAoAAAACgCAIAAAATnEprAAAlRUlEQVR4nO3deVhTx/4/8BMIqyCI +FBcUl6qIUkFRKyggWFFcwFb0apVK3RW9aN24vS7V1lZLq9Za9bogxV0piEUQtSLiVqkoi4i2oggi +sqnsa/J7fk+ehyffM5OQhISThPfrL53MmXMyjvmcM2cWnlAoZAAAAKB16bTy+QAAAAABGAAAgBt4 +AgYAAOAAAjAAAAAHEIABAAA4gAAMAADAAQRgAAAADiAAAwAAcAABGAAAgAMIwAAAABxAAAYAAOAA +AjAAAAAHEIABAAA4gAAMAADAAQRgAAAADiAAAwAAcAABGAAAgAMIwAAAABxAAAYAAOAAAjAAAAAH +EIABAAA4gAAMAADAAQRgAAAADiAAAwAAcAABGAAAgAMIwAAAABxAAAYAAOAAvxXO8fDhw7i4uHv3 +7j1+/Pjly5cVFRV1dXWmpqbm5uZWVlZDhgwZOnTouHHjrK2tW+Fi1FxZWVlMTMz169fT0tKeP3/+ +7t272tpaY2Pjzp0729raOjs7+/r6Dhw4kOvLBOBMWVnZpUuXEhMTHz58mJ2d/ebNm8rKSj09PVNT +UxsbG1tbWxcXF29v7969e+MfCdSdUGUaGhqOHDkyaNAgWS5DV1d30qRJV65ckaXkqKgosoQ5c+ao +7rsIhcIhQ4aQJ+3atWtDQ4NSyn/06JG/v7+BgUGzdTVixIi4uDhZyhw3bhx5+O3bt1nZNm3axCiJ +gYEBeRlff/217CXo6OgYGhq2b9++R48eTk5Ofn5+GzduTEhIqK+vV0o9S7qekJAQGQ9v164d61hd +XV1uW1Sr1bDsJ+LxeHw+38jIyNLSsm/fvu7u7kuXLj137lxlZaVQUWlpabNnzzYyMpLlApydnSMi +IgQCgVpVIIA4VQXgv/76i/r70ix/f/+SkhJ1C8CpqamSLjg6OrqFhdfU1KxZs4bPl683Ytq0aW/f +vtW+ACxJ9+7dDx8+3MKqVp8ArNwW1Wo13PITWVlZ/fLLLzLGxSaVlZWBgYE8Hk/e040YMSItLU19 +KhBAnEreAe/du/fDDz9MSUlR4NijR4+6uLjk5+cz6uTw4cOSPjp48GBLSs7Pz3d1dQ0JCWloaJDr +wLNnzw4fPvzFixdM25Cbmztv3ryZM2fKW1HqSXUtSs1ruLCwMDAwcMaMGbW1tbJfmIuLyy+//PL/ +nxjkdOfOnQ8//DA8PJxRPS1rotAKlB+A161bFxgY2NjYqHAJjx8/dnd3LykpYdRDXV3d8ePHJX0a +Fxf38uVLxUrOy8tzc3NLTk5W7PAnT554eHiUlpYybcapU6cCAwMZDae6FqUpNXzmzJm5c+fKkjMv +L8/d3V1Kh0Gzqqur58yZs2vXLqZVaEcTBY0MwDt37vz+++9bXs4///yzcOFCRj1ER0dLuRtobGw8 +cuSIAsVWVlZOmjTp6dOnLbm27OzsTz/9lGlLDhw4EB8fz2gyFbUozarhEydOnDx5Unqeurq6qVOn +Pnv2rOWnW7lyZbOnUxYtaKLQOpQZgBMSElavXk39iMfjTZgwYf/+/RkZGUVFRXV1dYWFhVeuXPn3 +v/9tampKPSQyMvLMmTOMGggNDZWeQfTiR95ig4KCJN3Xjx49+uDBg48fPy4vL6+srMzKytq3b5+9 +vT01c3x8vJpUVKvZsGEDo8lU1KI0roaDg4Ol99YGBQXdvXuX+pG5ufnixYsvXrz47Nmz6urqkpKS +jIyMvXv3enh4SCpt7ty5WVlZTKvQ9CYKrUSoJDU1Nf369aOeYvjw4cnJyZIOzMvLGzNmDPVABwcH +zgdh5eXl6ej8n9sUR0fHnj17ss4eHx8vV7HXr1+nfuUOHTpERkZSD2loaPj666+p41D69etHHdXS +kkFYO3fuFCqJjIOeBAJBY2NjXV1dVVVVUVFRfHz8Z599Rq0lhmEyMzNVfT0qGoSlihbVajUsy4nE +z1JSUpKenr579+4uXbpQzyJl4kNSUpKka5szZ05hYaGkA2NjY3v06EE90MXFhdsKBBCntCfgXbt2 +PXnyhEz39fVNTEwcOnSopAOtra1jY2PHjx9PfpSamnr16lWGU2FhYQKBQDzF19d3ypQprGyHDh2S +q9j169eTiZaWlteuXfv444+ph+jq6q5fv/7HH38kP3ry5MnFixcZDcfj8XR0dPT09ERzV7y8vH79 +9df9+/dTM0u6g1F/KmpR6lPD4mexsLCwt7dfvnx5ampqt27dyMy3b9+WVE5wcDA1/auvvgoLC3vv +vfckHejt7X3z5s3+/fuTH926devcuXOMotpIE4VWo5wAXF9fv3v3bjJ96NChERERhoaG0g/X19c/ +duyYlZUV+VF0dDTDqbCwMFaKr68vGSOjo6OLiopkLDMlJYX6n3Pfvn3NTpteuXLl2LFjyXQpg3o0 +2qJFiwYPHkymU+/2NIIqWpT61/B777335ZdfkumvXr2i5o+Li7t58yaZvmDBAllmzVlbW8fHx1Nf +b23bto1RKu1roqBhATgiIoKcOKSvrx8eHi7j9NaOHTuuXbuWTOd2LENiYuI///wjntK9e/fBgweP +GjWKdQNeV1f366+/ylgsNeeYMWP8/PxkOXzjxo1k4oULF7R18sOwYcPIxLKyMkYDqahFaUQNU7vB +JP0+UIehWVtb//DDDzKezsbG5rvvviPT//zzz4yMDEaptKmJgkYGYDLxk08+sbOzk70Qf39/CwsL +JyenGTNmbNiw4ejRo3fu3Ll16xajToNlfH19RUvhTJ48WeE+w5iYGDJx3rx5Mh4+atSoPn36fPDB +B1OnTg0ODj58+HBSUlJWVpa8S3loCuqNRfv27RkNpKIWpRE1TG2f5Mtv0cQh6v+R5cuXy3VVCxYs +6NixI5nekl5orW+i0JqU8JNdX19/+fJlMl3eeURWVlbqM/eXYZjy8nLyxqLpXd3HH3/M+jF9/Phx +UlKSq6ur9GJzcnKys7NZiXp6eqIfYhn9/fffTNsgEAhu3LhBpmviyuEqalGaUsPXrl0jE93d3cnE +u3fvVldXsxJ1dXWljHii0tfXnz179k8//cRKv3z5MnUQhmK0qYmC5j0BP3jwoLy8nJVoZGSk6h8O +VTt16lRVVZV4irm5uZubm+jPY8eONTExUWANo/v375OJ/fv3NzY2btn1aqdvv/2W+i5NE1uXilqU +RtRwZmbm1q1bWYmDBw+mLlh7584dMnHw4MGShlJL4eXlRSampKSwxsG1hDY1UdC8APzw4UMy0dHR +UdN7RMnewgkTJujp6Yn+bGBg4O3tzcoQERHx9u1b6cU+evSITJRxywrtJhAIGhoaKioqCgoKMjIy +zp496+3tTZ1P2adPHycnJ0bTqKhFqWENi2bNVVdXFxUVJSUlffnll05OTqz+LR6PFxISQj2cGs8U +W1ueOjyqoqLi+fPnCpSm9U0UWpuwxdatW0cWO3/+fKHKtMI8YGqYPH36tHieEydOkHn27NkjvWTq +MnVr164VqkArb8bg6+urupXuxYWFhbWkWpR+PbLMA1Zdi2rNGlbiiTZs2CDp61AXBpClHqgsLCzI +0q5du8ZJBQKIU8ITMPXFrbm5OaNda+Xr6+uzJitPnDhRX19f3j5D6twSScuBAemTTz6ZM2eOxtWM +6lqUxtWwrq7u1q1bt2zZIilDYWEhmUgdTiULagAuKChgVEZDmyi0PiUEYOpoezMzMymHrF+/niez +VltFvUlDQ8OxY8dYiZ6enqxhje3btyfXvUtNTf3rr7+kFE6OLmEYhnz5B1Q+Pj7kP436U2mL0qwa +1tPTCw8Pp84JblJZWSnvT4oU1NHI1FO05SYKmhqAa2pqKOX+38X2NEtsbCx5g0wdpUxdtUr6Iwt1 +nyhtncKrRBYWFnv27ImKipJxP/a206I0q4br6+tnzZrl7u4uaZFnST8pCo9SpH4d2TdDbCNNFDih +hDBJLo2r6ZPQycEyPB6PnKYp+g0lbzVOnjwp5f7awMCATKyoqFD0YrWclZWVj49PeHj4ixcvAgMD +NfTGTqUtShNr+Pr16yNHjqQunydpxrDCNUDO0ZD037AtN1HghBIGKlPfX2puAC4sLLxw4QIrcejQ +odRZfZ07dx4xYgRrtZDy8vJTp05JWliD2iGGAEzVuXPniIiIkSNHMppM1S1KQ2u4oaEhKCjI0NCQ +XDCA+gT57t07xU5E/S1S1qw/7WiiwBUl3KxR11jPy8tjNFN4eDjZISxllQxqn6GUNYy6du1KJipl +x1PlUmA3JLkWGBJtNVNfX19SUnL37t0vv/yS7EopKChwd3eXMlpHiVqyGxK3LYrzGqbuGlRbW1te +Xp6fn//gwYOzZ8/Onj1bV1eXPHbFihXkltjUjRaoI7OaJRQKi4uLyfROnTppXBMF7aOEAEzddSQl +JYXRTNRFaKWMGluzZg2Z/86dO5LWm33//fdlnEvdFvD5fAsLi2HDhm3dujU5OZm8mWtsbNy0aVNg +YCC3++Oqc4tSwxrm8Xj6+vomJiZdunRxcHDw8/M7evRoUlIS2f1TXV1N7o5AvaenrmDTrL///pva +vUQ9RZttoqDBAdjBwYFMfPny5evXryUd8s0338g+R7Y13blzJzMzUylFSRo4Q10Z4J9//pGr0z4g +IMDHx+fAgQPkHhiay87OLi4ujto3uHfv3tWrVzMaqBValKbUsLOzM3XljdOnT7NGJg4cOJDMdu/e +PQVOSg3bRkZGvXv3VqA0rWyioNkBuF+/ftTbSeoODRo3WEZhx44do460HDRoEDlJurGxkbq6CFVt +bW1UVNTvv/++aNEia2trJyenTZs2JScna8ENuL29PXXDY4ZhduzYgRbV8rG73Nawv78/j8djJZaX +l2dlZYmnUNeQysjIUGAJdOprEUdHR2p/eNtsosAh5QzYo664pMRg1jqqqqpOnz6trNJKS0t/++03 +Mp3P51OrS/a9544dOyb+uJySkrJlyxZXV1fNHfgmbvHixR999BH1o4ULF2rWE3/rtCgNqmEjIyPq ++13WHC03NzdyQRIFflLevHlDDcDUHbXbZhMFbQjA/v7+ZGJKSopm3Q9GREQoN4ZJ6jOkrpKTkJAg +y89rVVXVt99+S6Z7e3srvFKBujl48CB1oNObN2+WLl3KaI5Wa1GaUsN1dXWy7HjWrl076mqU+/fv +lyu8bdu2jTqlWK6dx7S7iYI2BGB3d/d+/fqR6YGBgdSVFyWh/m9pNUp/ZCd3XxcZN26cra0tmb50 +6VLp7wtFr8nJ3QwZhpk9ezajLXr27Ll582bqR9HR0ZGRkYyGaLUWpSk1fOnSJepCNOSMLOqcq7dv +3y5cuFDGVy13796l9hU7Ojoqtq+DVjZR4JbS5oxT15YrLCz09PSUMhqrSV1d3apVqzjstc7Ozr5+ +/Tor0dDQ8N27d7JMTREIBOTW4kKhkDp7REdHh7r4e2Fh4ejRo+Pj46lXWFlZGRAQEBYWRn40YMAA +6twVzbVixQpJm8msWLFCdatSaGiL0ogazs3NXbFiBZlubm7et29fVqKvry+ZyDDMhQsXFi5c2Oxm +gmlpaZMmTaIGe+rmMW2ziYL2BGB/f3/qcOiMjIwRI0bExsZKOrCxsfHo0aMDBw7csWMHh8OIQkND +ybNPnjyZum4GicfjUXcLDwsLoy4zOW3atEmTJpHpRUVF48ePnzBhwvHjx589e1ZdXV1RUZGamrpt +2zZbW9vw8HDq2bds2aJl6+/o6uqGhoY2bdUnLjc3V+l712hBi1LDGm5sbBTtSCgapjBkyBByyq+o +TsghUXw+n9w/WOTQoUOTJk2Ssp9geHj46NGjqX1vQ4YMmT59OqMMWtBEgXtC5bl3756UBd7c3Nx+ +/vnn9PT04uLimpqaZ8+eXb58OSgoqHv37vKuCCH7gGHpevToISqwsbGRehnnzp2T/etnZ2eTIzwZ +homMjKTmLywsJB9xFODn56cm2xFK2heS+mMky8IXGzdupJ5CX18/KytLqCiFr0eEfPlHbkfYyi2q +1WpY6XGFx+OJBvBTSXlZa2RkFBAQEBMT8/z585qamtLS0vT09J07d0rZhVdPT+/BgwfUE6lbE4U2 +QpkBWCgUHjhwgFG2EydOqDoAx8XFkZ926NChtrZWrq/v5uZGluPt7S0pf2Zmpiwr8kjRp0+ft2/f +amsArqur++CDD6hn8fLyEqpxAG7lFtVqNaz0ALxo0SIpl1daWkrtiFbMwYMHJZ1I3ZootBFK7rdc +sGDBjh07lFWaoaHh/v37Z86cyagY9d3ztGnTqHMhpAgICCAT4+Pjc3Nzqfnt7OwSExMV/omxt7dP +TEzUmsHPJD09vdDQUOqUzUuXLqnzUBeuWpRm1XCfPn22b98uJUOHDh3i4uJsbGxafq4tW7bMnz+f +UTbNbaKgDpT/4nDlypXh4eEt32Hezc0tJSVl0aJFjIqVlpaeP3+eTJ81a5a8RU2bNo18PBIIBFIG +l9na2iYnJyuwffenn356/fp16srS2mTo0KGrVq2ifvTFF19QN1fmHLctSlNq2MHB4caNG83ePr7/ +/vtJSUlSOpabpaen98svv2zYsIFRDU1soqAmVDJyx9/fPzU1VdJc9WY5OzvHxMQkJiba2dkxqkdd +sqp79+6urq7yFmViYjJ16lQyPTQ0VMq4TTMzs7CwsJs3b3p7ezc7lorH440ZM+aPP/44fvx4hw4d +mDZg8+bN1FlbOTk51CnRnOO8Ral5DRsZGQUFBSUlJcn4/sXGxubmzZvBwcHUEU/SOTg43Lx5U9Vz +czWuiYKaUNXQ2V69el2+fPnGjRs+Pj4ydrtZWVktWbIkOTn51q1bEydOZDhdK3/mzJnU8S/N+vzz +z8nEFy9eSJpc1MTFxSU2NjYnJ+fnn3+ePn26vb29ubk5n883NDTs3Lmzvb39rFmzdu/e/fTp0ytX +rnh6ejJthqGhYWhoKPXWJCQkhDqwlltq0qI4rGEdHR0+n29kZNS+fXtLS8tu3brZ2tqOHDly7ty5 +oaGheXl5u3btkquTzMDA4Lvvvnv06NGSJUtkPHDw4MFHjx69d+/esGHDGBXTuCYKaoLXCjN/ysrK +Lly48Oeff6alpWVnZ797966iooLP55uamnbr1q1v375OTk5ubm7Dhg1TeIFWAGgjampqLl26lJiY ++ODBg2fPnhUVFVVXV+vq6pqYmIgivbOzs7e3N3WXNoA2F4ABAACARatWbwAAANAUCMAAAAAcQAAG +AADgAAIwAAAABxCAAQAAOIAADAAAwAEEYAAAAA4gAAMAAHAAARgAAIADCMAAAAAcQAAGAADgAAIw +AAAABxCAAQAAOIAADAAAwAEEYAAAAA4gAAMAAHAAARgAAIADCMAAAAAcQAAGAADgAAIwAAAABxCA +AQAAOIAADAAAwAEEYAAAAA4gAAMAAHAAARgAAIADCMAAAAAcQAAGAADgAAIwAACAxgbg8ePH8wh3 +7txRSuFaY/Xq1WQt1dTUMG3YmjVrWBUSERHROgWamJjwWmbFihVyXZufn5/44d26dWvJNwUATYcn +YODMrVu3duzYoc4FKlF2dva5c+e4vgoAUCMIwMCNqqqqgIAAgUCgtgUq17JlyxobG7m+CgBQIwjA +wI3g4OC///5bnQtUol27dsXFxXF9FQCgXhCAgQMJCQl79uxR5wKV6MyZM6tWreL6KgBA7SAAQ2sr +Ly+fO3euUChU2wJlYWZmNm/evGaz7dmzZ+bMmWrbMQ4AHEIAbj0//PCDkGBoaMi0MatWrXr+/Lk6 +F9gsPp8fFRX1wQcfSMlTUVHh7++/fPlyRF8AoEIAhlYVHx9/8OBBdSiwoqJCKJuTJ0+yjt26dauH +h4eUwuPi4uzt7Y8dO6bAhQFAG8Fn1EZ5eXl4ePj58+dTU1NLS0vNzMysra09PT1nzJgxfPhwbq+t +qqoqLi4uNjb2/v37L168KCsrMzExsbKy6tGjh5eX18SJE/v376+6s9++fTsuLi4hISE/P7+wsLCu +rs7MzKxLly6Ojo6enp5Tp041MTGRvbS8vLyoqKiYmJjs7OxXr17xeLxOnTqJqnry5MlDhgxR3Rd5 ++/atLN22HBZIevbs2aJFi8RTvLy81qxZQ80sEAji4+NDQkISEhJUelUAoA2EyjBu3Diy5Nu3b7Oy +sX7IGIb573//K/ooLCzMwsJC0kV+/PHHeXl5rNJWr15N5jx58mSzV2tnZ8c6qnPnzg0NDdTMlZWV +W7ZskXJtIhMmTEhPT5d+XupInOrqaimHJCQkNHvz0a5duy1bttTU1DT7xcvKytasWaOnpyelNA8P +j/v37wtVw9/fX/p3YRjm7NmzHBbIUl9fP2LECPHSTE1Nc3JyJOVPTk5mZGZtba3whQGAFuA+AAsE +gqVLlzb7a9WrV6/c3Fzx0h49ekRmmzRpkvRLTUlJIY9au3YtNfPdu3f79Okj4++pjo7O1q1blRWA +BQLBunXrZDw1wzDDhw8vKiqScvanT5++//77Mn6RPXv2CJWNXIbC1ta2JfFS6QWStm3bxipNes1I +CsDTp0/38vJiJSIAA7Rx3AfgoKAgRjaenp6sAl1cXFh59PT0iouLpVzqF198QZb86NEjMufFixeN +jY0ZOc2dO1fSw7RcAXj27NnyntrBwaGqqopaWlZWVufOneUqTfrNhLyKi4s7deokXr6+vv7NmzcV +jpdKL5CUk5PDagAODg6NjY1yBWA+n79582aBQODr68v6CAEYoI3jOAAPGDCAkcfVq1fFCzx8+DCZ +Z9++fZKus7GxsWvXrqz8zs7OZM709HS5XqyKW7duXQsD8E8//aTYqRcvXkyWVlNTM2jQIHmL4vF4 +CQkJQiWZPn06q/xvvvmmvLxc4Xip9AJJU6ZMYRXVbIWwArC9vX1ycrLoIwRgAFCvANzExsbm0KFD +L1++rK6uTktLk/RujxVgysvLyTDp6uoq6TqvXLlClnngwAFWtvr6ent7ezKngYHBunXr0tPTq6qq +CgsLIyMjWS8Im0RFRSkcgB8/fmxgYEDmdHd3j4uLKy0trampSUtLCwwM1NFhD2LX1dXNzMxkFUgd +MdS9e/fQ0NBXr17V1NRkZGSsWLFCV1eXladXr16VlZXCFjt9+jSr5GHDhtXX1yscL5VeoCxNpdm3 +G+IB2MbG5uDBg/X19U0fIQADgDoGYEdHx5KSElbmJUuWkDnt7e1Z2ebOncvKw+PxJA2TCQgIYGU2 +NjZ+9+4dK9vPP/9MntrS0vLBgwesnI2NjcuWLSMz29jY1NbWKhaAFyxYQGZbtGiRQCBg5dy+fTuZ +c968eeJ5SktLyY50Ozu7169fs0r77bffeDweK2dYWJiwZQoKCjp27ChepoGBgeguQbF4qfQCqZyd +nVnl3Lt3r9mjUlJSxo4de+rUKfJfHwEYANQuABsaGubn55NllpSUGBkZsTK3a9eOlY362m/btm1k +gdXV1e3bt2fl9Pf3Z2VraGjo2bMnKxuPx0tKSqJ+d4FA4O3tTV7D//73PwUCMPVb9+/fv66ujnpq +sm/ZzMxMvMzvv/+ePKmkWELe9Li5uQlbhgw8ISEhoo8Ui5dKL5BErtvs4+Oj0LeXdtl4BwzQxnG/ +EMfnn3/epUsXMt3CwmLo0KGsxMrKStYGui4uLuQc3OPHj5MF/v7772VlZeTZWSlXrlwhl1WaNWvW +qFGjqNfP4/H27NlD9gbv37+fkd/ly5erq6tZicuXL6fOHeLxeH5+fj179hw3blxQUNDevXuvXr2a +mZkpvroWuRuuq6urpMm+y5cvZ6UkJSUVFBQwigoPD4+OjhZPcXFxoY6D46pAqq+++oqVQp3zBgDQ +EtwHYOrTswj5JCqKwawUshc6PT09IyODlUguS9SrV6/Ro0ezEs+fP0+elNot3KR3795jxoxhJT54 +8KC4uJiR061bt8jESZMmScq/YcOGZ8+eXbx4cdeuXUuWLPHw8BAfZVZTU/PgwQPWIW5ubpJKs7Oz +s7S0FE8RCoX3799nFPLy5UvWEHdjY+OwsDDyZoWrAqlu3779559/iqc4Ojq6uroq8RQAAGoRgKVM +TjU3NycTGxoaWCmfffYZn8+X/hBcWlp68eJFVp6AgADyreft27dZKYaGhuR8JxZPT09WilAovHbt +GiMnch6LqampjY0No5D79+/X1dWxEqn9DU26devGSklNTVXs7PPnz3/79q14yrZt2/r27atYaaoo +kGrv3r2slMDAQOWeAgBALQIwazanuHbt2pGJ5KY3nTp1Ip8RRUtiNf31zJkzrFDE4/HmzJlDlp+Z +mclKGTBgABngWRwcHMjEnJwcRk6vX79uNiLKLj8/n0xctmwZTzLyiTkrK0uBUx84cIB1xzN69Gjq +gDWuCqQqLi4+e/aseIqhoSE55QkAQBsCMDkwqkmzYa8JuSBwTk6O+Pgs8q3wmDFjevTowUqsrKys +ra1lJbLG3FJR8xQVFTFyevPmDStF4enI1NIUQL44b9bz589ZL01NTEyOHDlC9jdwVaAk4eHhrAYw +efJkKU0UAECDA7C+vr6kj8iZqZJ4e3uTPasnTpygBmNJw6+o/duSHsRlyaNA6CLH8bbkBee7d++Y +FquqqpIrv2huGOuL/Pjjj9Q3+pwUKAU5w3jGjBlKPwsAAPcBWEdHRykPMbq6umR/ckREhGgr1lOn +TrE6rs3MzD755BMZ4yg57ItUUVFBJja7hQOJfN6VN/6Joy7oIS95LyA/P5/cC2jRokVkd7epqSl5 ++LRp05oyLF68WBUFSpKbm8t6B6+npzd27Fh5vj0AgIYEYCV2IZJjoYuKikSDiiMjI1kfzZw5U3yu +ThM+n0/2N5aUlDR7duqA5/fee4+RU4cOHZT4FGtmZkYmRkZGyjVT7fr163KdlHxJ30JKL1CSiIgI +1rlGjRpFDeoAANrQBa0sffv2JSfYREdH5+fnk0OLqf3PIv369WOlZGZmUrumxaWlpZGJvXv3Zlo8 +JC0vL498LS0jcuFr6jgvEImNjWVVBbmFEQCAsmhPAKYOxTp//nxMTAzrsWbgwIFSNtl1dHRkpdTU +1FCn54r7448/WCl8Pt/d3Z2R07Bhw1gpAoGAGt1F8vPzLS0tR44cOW/evJCQkPPnzz958qTpdoH8 +LqJlsOS9qragvr6enIEmafUVAICW06oA7Ofnx+pAfvLkyY4dO2R//JX00HPw4EEph2RnZ5MvKYcP +H67A6NmRI0eSiTExMZLy//HHHyUlJbdu3QoNDV27dq2vr6+trW1UVJTo044dO5JP4b///nt9fb28 +F6b1/vrrL9bLfn19fXItNgAAZdGqAGxsbEyOWX38+LH4X/l8vvStdr29vclXp8ePH79x4wY1v1Ao +XLZsmWi0lzjFpqiOHTuWXAt63759kgZUHzp0iJXC5/PF7yGmTZvGyvD69WtJy2SGhoZ27drVw8Nj +8eLFO3fujI2Nffr0aWNjI9MGJCUlsVIGDRpEHSgAAKAc3G7GoKurK6XYTZs2kcW+evVKyiGsRQRJ +vr6+zX4d6q4Jcu2GZGtrS+7cLuNuSAsXLiSz+fj4kPsxULdDnjp1qniehw8fkiPdjI2Nr127xirt +1atX5MRoGXcBUoxyt+9tYYGffvop68CAgACh8mAzBgBg0aonYFHHL3UrXxn7n0XWr19vZWXFSiwu +Lv7www//85//ZGRk1NTUFBcXR0VFjRw5cs+ePaycurq6e/fuVXj+7qpVq8gHr/Pnzw8fPjwyMrKk +pKSuri4zM3PlypXUFarXrVsn/tcBAwaQoaWqqsrLyys4OPjRo0e1tbX5+fnHjx8fNWoUuXTX2LFj +Je3coGXIBb8GDhzI0bUAQNsg1K4nYKFQSL70bWJlZSW+R7oUcXFxsi8DwvLtt99Sy5TxCVjShsSy +mD9/Pllabm4udT5SswwNDbOysoQqoz5PwAKBgJwCHhUVJVQePAEDgJY/AYu2+JW0upa/v7+My1uO +Hz/+0KFDsq+F2SQoKCg4OJhpmWXLllHXqZbOwcFh586dZHq3bt1OnDhB3dBQCh6Pd+jQIVtbW6YN +KCgoIJdbsba25uhyAKBN0MIAbGlp6ePjo3D/c5OAgIC4uDjZf4X5fP7u3bt37dqllNVFjhw5snbt +WtnzOzs7JyQkSFo4esKECVFRUbI/BxsZGf3666+zZs1i2gbqOirUWdQAAMqihQGYOiFY9HpY3rd6 +H330UVZW1saNG1m75JKmTJny8OFDckN7hfF4vO3bt1+9etXJyUl6zo4dO4aEhCQlJZGraImbOHFi +amrqv/71r2ZfTo8bNy45Odnf359pM6i7VsiyBjgAgMLk7mLVCF5eXt27d8/NzVX48beJiYnJ5s2b +//Of/8THx8fGxqakpGRnZ5eVlbVr165Tp049e/YcN27c5MmTlb4rrYiHh0dycvLNmzcvXLiQmJj4 +8uXLoqIigUBgbm5ubW09dOhQT0/PKVOmyLjmc48ePU6dOrV9+/azZ88mJiY+evSooKCgurra2NjY +wsJiwIABI0aMmD59up2dHdPGUAMw5iABgErxWm2hXQAAANDyLmgAAAA1hwAMAADAAQRgAAAADiAA +AwAAcAABGAAAgAMIwAAAABxAAAYAAOAAAjAAAAAHEIABAAA4gAAMAADAAQRgAAAADiAAAwAAcAAB +GAAAgAMIwAAAABxAAAYAAOAAAjAAAAAHEIABAAA4gAAMAADAAQRgAAAADiAAAwAAcAABGAAAgAMI +wAAAABxAAAYAAOAAAjAAAAAHEIABAAA4gAAMAADAAQRgAAAADiAAAwAAcAABGAAAgAMIwAAAAEzr ++38UfRlkE+m5vQAAAABJRU5ErkJggg== diff --git a/corpus/object-tags/cold/read/page-001.navigation.json b/corpus/object-tags/cold/read/page-001.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..a3ec3aa40d7375c559da1ef012a98cc97da4412c --- /dev/null +++ b/corpus/object-tags/cold/read/page-001.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Untitled page - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/object-tags/cold/read/page-001.png b/corpus/object-tags/cold/read/page-001.png new file mode 100644 index 0000000000000000000000000000000000000000..9a2e38b5b0096c3324a63f647b36dfcd71051025 Binary files /dev/null and b/corpus/object-tags/cold/read/page-001.png differ diff --git a/corpus/object-tags/cold/read/page-001.xml b/corpus/object-tags/cold/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..e25e8789bd598fef360b19f213bf7853e060b43b --- /dev/null +++ b/corpus/object-tags/cold/read/page-001.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/object-tags/cold/read/page-002.navigation.json b/corpus/object-tags/cold/read/page-002.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..fe822f91dcc10651bf314b0515da171fe0c1b116 --- /dev/null +++ b/corpus/object-tags/cold/read/page-002.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "https://example.invalid/picture - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/object-tags/cold/read/page-002.png b/corpus/object-tags/cold/read/page-002.png new file mode 100644 index 0000000000000000000000000000000000000000..a648c751c828a297fe76c1376bc8661970bb4a74 Binary files /dev/null and b/corpus/object-tags/cold/read/page-002.png differ diff --git a/corpus/object-tags/cold/read/page-002.xml b/corpus/object-tags/cold/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..df2bbd9821dbb6b6bb4f2cae664af43f0a1c77ad --- /dev/null +++ b/corpus/object-tags/cold/read/page-002.xml @@ -0,0 +1,3 @@ + +https://example.invalid/picture]]> diff --git a/corpus/object-tags/cold/read/page-003.navigation.json b/corpus/object-tags/cold/read/page-003.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..a3ec3aa40d7375c559da1ef012a98cc97da4412c --- /dev/null +++ b/corpus/object-tags/cold/read/page-003.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Untitled page - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/object-tags/cold/read/page-003.png b/corpus/object-tags/cold/read/page-003.png new file mode 100644 index 0000000000000000000000000000000000000000..cffd0534bb9b5b385b640aea97a85e5d16346076 Binary files /dev/null and b/corpus/object-tags/cold/read/page-003.png differ diff --git a/corpus/object-tags/cold/read/page-003.xml b/corpus/object-tags/cold/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..bb1a6d0af5c8d5244780673f5b1ba1505678fbc9 --- /dev/null +++ b/corpus/object-tags/cold/read/page-003.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/object-tags/cold/read/payloads.json b/corpus/object-tags/cold/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..fdcb9f97d728cd3c99dd1bc204e1db870bdaadda --- /dev/null +++ b/corpus/object-tags/cold/read/payloads.json @@ -0,0 +1,34 @@ +[ + { + "sha256": "33fdf6e43c05ed910d335eda3fa269ccbd1415336c17a4a5357d009ee495b3a6", + "name": "notes.txt", + "object": "{FCE9E77A-D017-4FFB-945E-0F0A0257D459}{48}{B0}", + "kind": "InsertedFile", + "page": "{5BFA1C0D-BE94-4F85-9BBD-51672E337B30}{1}{B0}", + "bytes": 18 + }, + { + "sha256": "33fdf6e43c05ed910d335eda3fa269ccbd1415336c17a4a5357d009ee495b3a6", + "name": "notes.txt", + "object": "{FCE9E77A-D017-4FFB-945E-0F0A0257D459}{59}{B0}", + "kind": "InsertedFile", + "page": "{5BFA1C0D-BE94-4F85-9BBD-51672E337B30}{1}{B0}", + "bytes": 18 + }, + { + "sha256": "32a01b6d0a94deebc53c688868b14b6fccc4e18b3b64ea052823e3856cf35b89", + "name": "float.txt", + "object": "{C2A94E46-18C9-06D4-1EC8-8D5328B30769}{40}{B0}", + "kind": "InsertedFile", + "page": "{62908E22-534B-06F1-04E8-8159D324438A}{1}{B0}", + "bytes": 38 + }, + { + "sha256": "32a01b6d0a94deebc53c688868b14b6fccc4e18b3b64ea052823e3856cf35b89", + "name": "float.txt", + "object": "{08585F32-BA6A-042A-288B-8FC98DC9C068}{18}{B0}", + "kind": "InsertedFile", + "page": "{62908E22-534B-06F1-04E8-8159D324438A}{1}{B0}", + "bytes": 38 + } +] \ No newline at end of file diff --git a/corpus/object-tags/cold/run.json b/corpus/object-tags/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..0c9ac735dc6b3929983d0b21a8754fa4012bca0d --- /dev/null +++ b/corpus/object-tags/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/pictab/candidate", + "expected_pages": 4, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35" + } +} diff --git a/corpus/object-tags/cold/scripts/cold.ps1 b/corpus/object-tags/cold/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/object-tags/cold/scripts/cold.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } +$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' +if ($CloneHost) { + if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { + throw 'The disposable clone hostname does not match this machine.' + } + New-Item "$key\Options\Paths" -Force | Out-Null + New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null +} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or + -not (Test-Path 'C:\one-tests\profile-original-cache')) { + throw 'Park the personal OneNote profile before resetting the test cache.' +} +$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' +$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) +if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } +New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null +if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } +if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } +New-Item "$key\OpenNotebooks" | Out-Null +New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null diff --git a/corpus/object-tags/cold/scripts/read.ps1 b/corpus/object-tags/cold/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a42366b32e8baaf13dae92e9472cae7a071220cc --- /dev/null +++ b/corpus/object-tags/cold/scripts/read.ps1 @@ -0,0 +1,144 @@ +param( + [Parameter(Mandatory=$true)][string]$Root, + [int]$ExpectedPages = -1, + [switch]$UseCurrentCache, + [switch]$Pdf, + [switch]$KeepOpen, + [string]$CloneHost = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +$notebook = Join-Path $root 'notebook' +$output = Join-Path $root 'read' +if (Test-Path $output) { throw 'Choose a new read destination.' } +if ($UseCurrentCache) { + if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { + throw 'Park the personal OneNote profile before reading test notebooks.' + } +} else { + & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost +} +New-Item -ItemType Directory -Path $output | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +$failure = $null +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $process = Get-Process ONENOTE + @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; + powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | + ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 + $sections = @() + foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { + $id = '' + $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) + $sections += $id + } + $deadline = [DateTime]::UtcNow.AddSeconds(300) + $previous = '' + $lastChange = '' + $stableSince = [DateTime]::UtcNow + $settled = $false + do { + $pages = @{} + foreach ($section in $sections) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$xml = $hierarchy + foreach ($node in $xml.SelectNodes('//*[@path]')) { + if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { + throw 'OneNote opened a section outside the copied notebook.' + } + } + foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { + $id = $node.GetAttribute('ID') + $content = '' + $app.GetPageContent($id, [ref]$content, 1, 1) + $pages[$id] = $content + } + } + $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) + if ($signature -ne $previous) { + $lastChange = $previous + $previous = $signature + $stableSince = [DateTime]::UtcNow + } + if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or + ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and + ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { + [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) + $index = 0 + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + $index++ + } + $hierarchy = '' + $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) + throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." + } + $index = 0 + $payloads = @() + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + if ($Pdf) { + $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) + $app.NavigateTo($id, '', $false) + $app.Publish($id, $pdfPath, 3, '') + if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } + } + [xml]$page = $pages[$id] + foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { + $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) + # A paragraph's file is named by its paragraph, one on the page by itself. + $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode } + $payloads += @{ page = $id; object = $owner.GetAttribute('objectID'); + kind = $file.LocalName; name = $file.GetAttribute('preferredName'); + sha256 = $hash; bytes = $bytes.Length } + } + $index++ + } + [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) + $all = '' + $app.GetHierarchy($notebookId, 4, [ref]$all, 1) + [xml]$finalTree = $all + $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) + if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { + throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' + } + [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) + Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." +} catch { + $failure = $_ + [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + throw +} finally { + try { + try { + if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } + if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { + $app.CloseNotebook($notebookId, $false) + } + } catch { + if ($null -eq $failure) { throw } + [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + } + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + if (-not $UseCurrentCache -and -not $CloneHost) { + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + } +} diff --git a/corpus/object-tags/cold/source.json b/corpus/object-tags/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..404a8db443a8cbb9ad71cfdce0cf2b26e3f400bb --- /dev/null +++ b/corpus/object-tags/cold/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 1604, + "sha256": "f3a3df7f649fd730cc071103b4ab50fa7bdf3c4df0c4296ae0c3fb640ebee1ad", + "mtime_ns": 1790808646958031593 + }, + { + "path": "files.one", + "bytes": 59048, + "sha256": "7222f2c26057540dd2a537b337e5667d8140e4fdb2de41d091b09b39fdce5edc", + "mtime_ns": 1790808646957690287 + } +] diff --git a/corpus/object-tags/cold/teardown.json b/corpus/object-tags/cold/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/object-tags/cold/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/object-tags/native/commands.jsonl b/corpus/object-tags/native/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..3296bfd224c0ed79ddc61ef379163b0db4c9aff5 --- /dev/null +++ b/corpus/object-tags/native/commands.jsonl @@ -0,0 +1,7 @@ +{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\author.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-F27645BE", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-F27645BE -ExpectedPages -1 -UseCurrentCache -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 3 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Rename-Item C:\\one-tests\\runs\\capture\\read before-read\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-F27645BE -UseCurrentCache -ExpectedPages -1", "exit": 0, "stdout": "Read 1 sections and 4 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} diff --git a/corpus/object-tags/native/machine.json b/corpus/object-tags/native/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..9bb6fa485c5499156b072b623ad599e0a6734dc7 --- /dev/null +++ b/corpus/object-tags/native/machine.json @@ -0,0 +1 @@ +{"name": "m6-f27645be", "hostname": "ONE-M6-F27645BE"} diff --git a/corpus/object-tags/native/notebook/Open Notebook.onetoc2 b/corpus/object-tags/native/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..fcb77261b617e8f3642da9bb5fa16150a744da01 Binary files /dev/null and b/corpus/object-tags/native/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/object-tags/native/notebook/files.one b/corpus/object-tags/native/notebook/files.one new file mode 100644 index 0000000000000000000000000000000000000000..9b9289ace3c2fea44975f23043362d897e469e19 Binary files /dev/null and b/corpus/object-tags/native/notebook/files.one differ diff --git a/corpus/object-tags/native/notebook/notes.txt b/corpus/object-tags/native/notebook/notes.txt new file mode 100644 index 0000000000000000000000000000000000000000..8265454ede447c16d88088298dde04213d50d0be --- /dev/null +++ b/corpus/object-tags/native/notebook/notes.txt @@ -0,0 +1 @@ +notes for tagging diff --git a/corpus/object-tags/native/notebook/ocr.png b/corpus/object-tags/native/notebook/ocr.png new file mode 100644 index 0000000000000000000000000000000000000000..cb9c1b4bef6aca8377810f2a23fe330f3d9713fe Binary files /dev/null and b/corpus/object-tags/native/notebook/ocr.png differ diff --git a/corpus/object-tags/native/notebook/photo.png b/corpus/object-tags/native/notebook/photo.png new file mode 100644 index 0000000000000000000000000000000000000000..345db618cf2a756521ab7f100deb49b639e00b0b Binary files /dev/null and b/corpus/object-tags/native/notebook/photo.png differ diff --git a/corpus/object-tags/native/read/32a01b6d0a94deebc53c688868b14b6fccc4e18b3b64ea052823e3856cf35b89.attachment b/corpus/object-tags/native/read/32a01b6d0a94deebc53c688868b14b6fccc4e18b3b64ea052823e3856cf35b89.attachment new file mode 100644 index 0000000000000000000000000000000000000000..3e82e7de059f9ddb683cf651a2c12114e2beb1bd --- /dev/null +++ b/corpus/object-tags/native/read/32a01b6d0a94deebc53c688868b14b6fccc4e18b3b64ea052823e3856cf35b89.attachment @@ -0,0 +1 @@ +Floating file, stored at page level. diff --git a/corpus/object-tags/native/read/33fdf6e43c05ed910d335eda3fa269ccbd1415336c17a4a5357d009ee495b3a6.attachment b/corpus/object-tags/native/read/33fdf6e43c05ed910d335eda3fa269ccbd1415336c17a4a5357d009ee495b3a6.attachment new file mode 100644 index 0000000000000000000000000000000000000000..8265454ede447c16d88088298dde04213d50d0be --- /dev/null +++ b/corpus/object-tags/native/read/33fdf6e43c05ed910d335eda3fa269ccbd1415336c17a4a5357d009ee495b3a6.attachment @@ -0,0 +1 @@ +notes for tagging diff --git a/corpus/object-tags/native/read/environment.json b/corpus/object-tags/native/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..a3468069a8dcb39647aae991dcd24404065441bb --- /dev/null +++ b/corpus/object-tags/native/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-F27645BE", + "cold": false, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/object-tags/native/read/hierarchy.xml b/corpus/object-tags/native/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..8c7ce81b0013a365654efb96ec6c881585004796 --- /dev/null +++ b/corpus/object-tags/native/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/object-tags/native/read/page-000.xml b/corpus/object-tags/native/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..702e7d4df59db9ea29d25c15f0eae292fe0cddaa --- /dev/null +++ b/corpus/object-tags/native/read/page-000.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/object-tags/native/read/page-001.xml b/corpus/object-tags/native/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..16bd93e03e94d49ba5ccf91f3dcfce71e23e9bec --- /dev/null +++ b/corpus/object-tags/native/read/page-001.xml @@ -0,0 +1,194 @@ + +iVBORw0KGgoAAAANSUhEUgAAAKAAAAB4CAIAAAD6wG44AAACWklEQVR4nO3dy1EkQRAE0WYMsRCh +DoiAGIiDCBwQYsVaFcCgKyK93M8cJvNZ9vAzeHp5/3cZt0f6Bdi9CQxPYHgCwxMYnsDwBIYnMDyB +4QkMT2B4AsMTGJ7A8ASGJzA8geEJDE9geM8Xt6/Xt+9/8Pr8uIg9YX7p7kec55DPBv5zVB72SOBt +rgDpScBB17nSM4CraGcxtwPX0k5h7gUeQdvPXPqNjnG6V+trrrvgzjXNPeWuCwboXmVTtFxw1VJI +p1xxwUjdq2OuPHDDFsDTPQ6fHz9jEvgE3fikMeBzdLPzZoBP0w1OHQA+Uzc1+27gk3UjG8h/mWQc +YM93/x72Aasb2YaPaHibgD3f1E68YHg7gD3f4Ga8YHi3A3u+2f14wfAEhncvsM/n+Ja8YHgCwxMY +3o3AvgE37MoLhicwPIHhCQxPYHgCwxMYnsDwBIYnMLwH+w8YTOm+XXnB8ASGJzC8e4F9G45vyQuG +JzC824F9Smf34wXD2wHsEQc34wXD2wTsEad24gXD2wfsEUe2sfWCNd6/Bx/R8HYDe8Rr74/JAxd8 +svHaPnvmEX2m8UpMHXsPPs14heZNfpJ1jvHKTRr+LPoE4xWdMf9lEtt4pafLAzdsATxXy7+2g/3V +h1VAW3TBhXvBTNF1wdNPeTXRNl5w86aGvubSC551yquSdgZwOfMqpp0EXMi86mnnATdIryGus4H3 +S69prhDgW7HXWFQm8C/JF4LzLGDr/UaH/VUCwxMYnsDwBIYnMDyB4QkMT2B4AsMTGJ7A8ASGJzA8 +geEJDE9geAJf7P4Dry+9j2KmlKMAAAAASUVORK5CYII=iVBORw0KGgoAAAANSUhEUgAAAKAAAAB4CAIAAAD6wG44AAACWklEQVR4nO3dy1EkQRAE0WYMsRCh +DoiAGIiDCBwQYsVaFcCgKyK93M8cJvNZ9vAzeHp5/3cZt0f6Bdi9CQxPYHgCwxMYnsDwBIYnMDyB +4QkMT2B4AsMTGJ7A8ASGJzA8geEJDE9geM8Xt6/Xt+9/8Pr8uIg9YX7p7kec55DPBv5zVB72SOBt +rgDpScBB17nSM4CraGcxtwPX0k5h7gUeQdvPXPqNjnG6V+trrrvgzjXNPeWuCwboXmVTtFxw1VJI +p1xxwUjdq2OuPHDDFsDTPQ6fHz9jEvgE3fikMeBzdLPzZoBP0w1OHQA+Uzc1+27gk3UjG8h/mWQc +YM93/x72Aasb2YaPaHibgD3f1E68YHg7gD3f4Ga8YHi3A3u+2f14wfAEhncvsM/n+Ja8YHgCwxMY +3o3AvgE37MoLhicwPIHhCQxPYHgCwxMYnsDwBIYnMLwH+w8YTOm+XXnB8ASGJzC8e4F9G45vyQuG +JzC824F9Smf34wXD2wHsEQc34wXD2wTsEad24gXD2wfsEUe2sfWCNd6/Bx/R8HYDe8Rr74/JAxd8 +svHaPnvmEX2m8UpMHXsPPs14heZNfpJ1jvHKTRr+LPoE4xWdMf9lEtt4pafLAzdsATxXy7+2g/3V +h1VAW3TBhXvBTNF1wdNPeTXRNl5w86aGvubSC551yquSdgZwOfMqpp0EXMi86mnnATdIryGus4H3 +S69prhDgW7HXWFQm8C/JF4LzLGDr/UaH/VUCwxMYnsDwBIYnMDyB4QkMT2B4AsMTGJ7A8ASGJzA8 +geEJDE9geAJf7P4Dry+9j2KmlKMAAAAASUVORK5CYII=https://example.invalid/picture]]>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 ++38UfRlkE+m5vQAAAABJRU5ErkJggg== diff --git a/corpus/object-tags/native/read/page-002.xml b/corpus/object-tags/native/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..9630e2cb89d9de7450571d31b03a35530fa2eecf --- /dev/null +++ b/corpus/object-tags/native/read/page-002.xml @@ -0,0 +1,3 @@ + +https://example.invalid/picture]]> diff --git a/corpus/object-tags/native/read/page-003.xml b/corpus/object-tags/native/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..afc7400afe3d48ac01bd928d2c12462d65e409b5 --- /dev/null +++ b/corpus/object-tags/native/read/page-003.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/object-tags/native/read/payloads.json b/corpus/object-tags/native/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..ed14332dfef492b872ed6767c7ba8e394aa834f8 --- /dev/null +++ b/corpus/object-tags/native/read/payloads.json @@ -0,0 +1,34 @@ +[ + { + "sha256": "32a01b6d0a94deebc53c688868b14b6fccc4e18b3b64ea052823e3856cf35b89", + "name": "float.txt", + "object": "{EC8778C0-4C56-0389-38D4-AE4F82C56A97}{40}{B0}", + "kind": "InsertedFile", + "page": "{4CBEB8A4-07D4-03AC-22F4-A24579522E74}{1}{B0}", + "bytes": 38 + }, + { + "sha256": "32a01b6d0a94deebc53c688868b14b6fccc4e18b3b64ea052823e3856cf35b89", + "name": "float.txt", + "object": "{267669B4-EEF5-0177-0E97-ACD527BFAD96}{18}{B0}", + "kind": "InsertedFile", + "page": "{4CBEB8A4-07D4-03AC-22F4-A24579522E74}{1}{B0}", + "bytes": 38 + }, + { + "sha256": "33fdf6e43c05ed910d335eda3fa269ccbd1415336c17a4a5357d009ee495b3a6", + "name": "notes.txt", + "object": "{D2C7D1FC-8488-4AA6-B242-2C16A821B9A7}{48}{B0}", + "kind": "InsertedFile", + "page": "{75D42A8B-EA0B-4AD8-BDA1-727B844516CE}{1}{B0}", + "bytes": 18 + }, + { + "sha256": "33fdf6e43c05ed910d335eda3fa269ccbd1415336c17a4a5357d009ee495b3a6", + "name": "notes.txt", + "object": "{D2C7D1FC-8488-4AA6-B242-2C16A821B9A7}{59}{B0}", + "kind": "InsertedFile", + "page": "{75D42A8B-EA0B-4AD8-BDA1-727B844516CE}{1}{B0}", + "bytes": 18 + } +] \ No newline at end of file diff --git a/corpus/object-tags/native/run.json b/corpus/object-tags/native/run.json new file mode 100644 index 0000000000000000000000000000000000000000..afd06592ffe1dc43e16595a70c660c8581b482fa --- /dev/null +++ b/corpus/object-tags/native/run.json @@ -0,0 +1,19 @@ +{ + "notebook": "/private/tmp/pictab/nb", + "expected_pages": -1, + "author": "tools/native/picture-objects.ps1", + "author_timeout_seconds": 600, + "inspect": true, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "author.ps1": "9402c73adc2a48971075f1ead915ee095b5d4abf8d09cdc9fb9473697aebe4c0", + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35" + } +} diff --git a/corpus/object-tags/native/scripts/author.ps1 b/corpus/object-tags/native/scripts/author.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..c5c6bd498e29ad9acb82a7731320c5c1542a1688 --- /dev/null +++ b/corpus/object-tags/native/scripts/author.ps1 @@ -0,0 +1,37 @@ +param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost) +# A page of pictures and files, in an outline and on the page, for tagging, linking and OCR +# by hand in the clone: notebook/photo.png, notebook/ocr.png and notebook/notes.txt ride along +# with the section. +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost +$namespace = 'http://schemas.microsoft.com/office/onenote/2010/onenote' +$notebook = Join-Path $Root 'notebook' +$data = { param($name) [Convert]::ToBase64String([IO.File]::ReadAllBytes((Join-Path $notebook $name))) } +$file = Join-Path $notebook 'notes.txt' +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $sectionId = '' + $app.OpenHierarchy('files.one', $notebookId, [ref]$sectionId, 0) + $pageId = '' + $app.CreateNewPage($sectionId, [ref]$pageId, 0) + $body = '' + $body += '' + $body += '' + $body += '' + (& $data 'photo.png') + '' + $body += '' + $body += '' + $body += '' + $body += '' + (& $data 'photo.png') + '' + $body += '' + $body += '' + (& $data 'ocr.png') + '' + $xml = '' + $body + '' + $app.UpdatePageContent($xml, [DateTime]::MinValue, 1, $true) + $app.SyncHierarchy($notebookId) +} finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + [GC]::Collect() + [GC]::WaitForPendingFinalizers() +} diff --git a/corpus/object-tags/native/scripts/cold.ps1 b/corpus/object-tags/native/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/object-tags/native/scripts/cold.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } +$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' +if ($CloneHost) { + if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { + throw 'The disposable clone hostname does not match this machine.' + } + New-Item "$key\Options\Paths" -Force | Out-Null + New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null +} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or + -not (Test-Path 'C:\one-tests\profile-original-cache')) { + throw 'Park the personal OneNote profile before resetting the test cache.' +} +$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' +$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) +if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } +New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null +if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } +if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } +New-Item "$key\OpenNotebooks" | Out-Null +New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null diff --git a/corpus/object-tags/native/scripts/read.ps1 b/corpus/object-tags/native/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a42366b32e8baaf13dae92e9472cae7a071220cc --- /dev/null +++ b/corpus/object-tags/native/scripts/read.ps1 @@ -0,0 +1,144 @@ +param( + [Parameter(Mandatory=$true)][string]$Root, + [int]$ExpectedPages = -1, + [switch]$UseCurrentCache, + [switch]$Pdf, + [switch]$KeepOpen, + [string]$CloneHost = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +$notebook = Join-Path $root 'notebook' +$output = Join-Path $root 'read' +if (Test-Path $output) { throw 'Choose a new read destination.' } +if ($UseCurrentCache) { + if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { + throw 'Park the personal OneNote profile before reading test notebooks.' + } +} else { + & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost +} +New-Item -ItemType Directory -Path $output | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +$failure = $null +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $process = Get-Process ONENOTE + @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; + powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | + ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 + $sections = @() + foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { + $id = '' + $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) + $sections += $id + } + $deadline = [DateTime]::UtcNow.AddSeconds(300) + $previous = '' + $lastChange = '' + $stableSince = [DateTime]::UtcNow + $settled = $false + do { + $pages = @{} + foreach ($section in $sections) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$xml = $hierarchy + foreach ($node in $xml.SelectNodes('//*[@path]')) { + if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { + throw 'OneNote opened a section outside the copied notebook.' + } + } + foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { + $id = $node.GetAttribute('ID') + $content = '' + $app.GetPageContent($id, [ref]$content, 1, 1) + $pages[$id] = $content + } + } + $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) + if ($signature -ne $previous) { + $lastChange = $previous + $previous = $signature + $stableSince = [DateTime]::UtcNow + } + if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or + ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and + ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { + [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) + $index = 0 + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + $index++ + } + $hierarchy = '' + $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) + throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." + } + $index = 0 + $payloads = @() + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + if ($Pdf) { + $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) + $app.NavigateTo($id, '', $false) + $app.Publish($id, $pdfPath, 3, '') + if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } + } + [xml]$page = $pages[$id] + foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { + $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) + # A paragraph's file is named by its paragraph, one on the page by itself. + $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode } + $payloads += @{ page = $id; object = $owner.GetAttribute('objectID'); + kind = $file.LocalName; name = $file.GetAttribute('preferredName'); + sha256 = $hash; bytes = $bytes.Length } + } + $index++ + } + [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) + $all = '' + $app.GetHierarchy($notebookId, 4, [ref]$all, 1) + [xml]$finalTree = $all + $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) + if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { + throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' + } + [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) + Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." +} catch { + $failure = $_ + [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + throw +} finally { + try { + try { + if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } + if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { + $app.CloseNotebook($notebookId, $false) + } + } catch { + if ($null -eq $failure) { throw } + [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + } + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + if (-not $UseCurrentCache -and -not $CloneHost) { + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + } +} diff --git a/corpus/object-tags/native/source.json b/corpus/object-tags/native/source.json new file mode 100644 index 0000000000000000000000000000000000000000..2a12196a2cfb9589d4133dcf9d2038561d53a0b0 --- /dev/null +++ b/corpus/object-tags/native/source.json @@ -0,0 +1,26 @@ +[ + { + "path": "files.one", + "bytes": 21160, + "sha256": "d7d7688e86bae61505b96daf8af0d5d7adc1af3777ac13f2551cc8a339c1a182", + "mtime_ns": 1790805042299289270 + }, + { + "path": "notes.txt", + "bytes": 18, + "sha256": "33fdf6e43c05ed910d335eda3fa269ccbd1415336c17a4a5357d009ee495b3a6", + "mtime_ns": 1790805042459716143 + }, + { + "path": "ocr.png", + "bytes": 9598, + "sha256": "b81dc7dd998956abfc1394b45567140c178b7b4523d06726970f75f4a2b701db", + "mtime_ns": 1790805042459075351 + }, + { + "path": "photo.png", + "bytes": 659, + "sha256": "2a39fa803c359c78ce662012ae75b810a33b13647c0050c29176301dcad8eb3b", + "mtime_ns": 1790805042459606226 + } +] diff --git a/corpus/object-tags/native/teardown.json b/corpus/object-tags/native/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/object-tags/native/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/object-tags/restored/candidate/Open Notebook.onetoc2 b/corpus/object-tags/restored/candidate/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..920f40e2c6b85f71dc2755f9187c5211c7d64707 Binary files /dev/null and b/corpus/object-tags/restored/candidate/Open Notebook.onetoc2 differ diff --git a/corpus/object-tags/restored/candidate/files.one b/corpus/object-tags/restored/candidate/files.one new file mode 100644 index 0000000000000000000000000000000000000000..6fb0d8aeaf062502200803a3d4de259ce5984897 Binary files /dev/null and b/corpus/object-tags/restored/candidate/files.one differ diff --git a/corpus/object-tags/restored/cold/commands.jsonl b/corpus/object-tags/restored/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..cc850632a679c76627e4ca4bb69ba8bfe6a804d7 --- /dev/null +++ b/corpus/object-tags/restored/cold/commands.jsonl @@ -0,0 +1,3 @@ +{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-98295DF8 -ExpectedPages 4 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 4 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} diff --git a/corpus/object-tags/restored/cold/machine.json b/corpus/object-tags/restored/cold/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..71a7e90865672f722aaabae0e81a987e90b65fb3 --- /dev/null +++ b/corpus/object-tags/restored/cold/machine.json @@ -0,0 +1 @@ +{"name": "m6-98295df8", "hostname": "ONE-M6-98295DF8"} diff --git a/corpus/object-tags/restored/cold/notebook/Open Notebook.onetoc2 b/corpus/object-tags/restored/cold/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..7b2ca00ae65c4aed9a2e42d3792970cfd030b470 Binary files /dev/null and b/corpus/object-tags/restored/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/object-tags/restored/cold/notebook/files.one b/corpus/object-tags/restored/cold/notebook/files.one new file mode 100644 index 0000000000000000000000000000000000000000..a85e6208e30a967ee8141f5dc177c31992cff4a9 Binary files /dev/null and b/corpus/object-tags/restored/cold/notebook/files.one differ diff --git a/corpus/object-tags/restored/cold/read/32a01b6d0a94deebc53c688868b14b6fccc4e18b3b64ea052823e3856cf35b89.attachment b/corpus/object-tags/restored/cold/read/32a01b6d0a94deebc53c688868b14b6fccc4e18b3b64ea052823e3856cf35b89.attachment new file mode 100644 index 0000000000000000000000000000000000000000..3e82e7de059f9ddb683cf651a2c12114e2beb1bd --- /dev/null +++ b/corpus/object-tags/restored/cold/read/32a01b6d0a94deebc53c688868b14b6fccc4e18b3b64ea052823e3856cf35b89.attachment @@ -0,0 +1 @@ +Floating file, stored at page level. diff --git a/corpus/object-tags/restored/cold/read/33fdf6e43c05ed910d335eda3fa269ccbd1415336c17a4a5357d009ee495b3a6.attachment b/corpus/object-tags/restored/cold/read/33fdf6e43c05ed910d335eda3fa269ccbd1415336c17a4a5357d009ee495b3a6.attachment new file mode 100644 index 0000000000000000000000000000000000000000..8265454ede447c16d88088298dde04213d50d0be --- /dev/null +++ b/corpus/object-tags/restored/cold/read/33fdf6e43c05ed910d335eda3fa269ccbd1415336c17a4a5357d009ee495b3a6.attachment @@ -0,0 +1 @@ +notes for tagging diff --git a/corpus/object-tags/restored/cold/read/environment.json b/corpus/object-tags/restored/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..7ef854cd864b4fc5ea87d42d119ab49c5de70209 --- /dev/null +++ b/corpus/object-tags/restored/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-98295DF8", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/object-tags/restored/cold/read/hierarchy.xml b/corpus/object-tags/restored/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..4a50855cd8afaef867f88d959d66f71d50772a4a --- /dev/null +++ b/corpus/object-tags/restored/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/object-tags/restored/cold/read/page-000.navigation.json b/corpus/object-tags/restored/cold/read/page-000.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..fe822f91dcc10651bf314b0515da171fe0c1b116 --- /dev/null +++ b/corpus/object-tags/restored/cold/read/page-000.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "https://example.invalid/picture - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/object-tags/restored/cold/read/page-000.png b/corpus/object-tags/restored/cold/read/page-000.png new file mode 100644 index 0000000000000000000000000000000000000000..a1ed6186f92299758ca1ea16e056ac4471e9ee73 Binary files /dev/null and b/corpus/object-tags/restored/cold/read/page-000.png differ diff --git a/corpus/object-tags/restored/cold/read/page-000.xml b/corpus/object-tags/restored/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..ab326361d83d5169813548263cbe778abd44d93b --- /dev/null +++ b/corpus/object-tags/restored/cold/read/page-000.xml @@ -0,0 +1,3 @@ + +https://example.invalid/picture]]> diff --git a/corpus/object-tags/restored/cold/read/page-001.navigation.json b/corpus/object-tags/restored/cold/read/page-001.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..a3ec3aa40d7375c559da1ef012a98cc97da4412c --- /dev/null +++ b/corpus/object-tags/restored/cold/read/page-001.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Untitled page - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/object-tags/restored/cold/read/page-001.png b/corpus/object-tags/restored/cold/read/page-001.png new file mode 100644 index 0000000000000000000000000000000000000000..976b671d708cff406b9364aeaea1cf2d31e8b043 Binary files /dev/null and b/corpus/object-tags/restored/cold/read/page-001.png differ diff --git a/corpus/object-tags/restored/cold/read/page-001.xml b/corpus/object-tags/restored/cold/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..c97e943ecdc62f40b630e1da4257f5bb52059144 --- /dev/null +++ b/corpus/object-tags/restored/cold/read/page-001.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/object-tags/restored/cold/read/page-002.navigation.json b/corpus/object-tags/restored/cold/read/page-002.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..a3ec3aa40d7375c559da1ef012a98cc97da4412c --- /dev/null +++ b/corpus/object-tags/restored/cold/read/page-002.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Untitled page - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/object-tags/restored/cold/read/page-002.png b/corpus/object-tags/restored/cold/read/page-002.png new file mode 100644 index 0000000000000000000000000000000000000000..15f42d825fcf90bcf4296c18c7eb4b1ec5b09d99 Binary files /dev/null and b/corpus/object-tags/restored/cold/read/page-002.png differ diff --git a/corpus/object-tags/restored/cold/read/page-002.xml b/corpus/object-tags/restored/cold/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..ef78507f28ef04e451624b299f2fcff822585174 --- /dev/null +++ b/corpus/object-tags/restored/cold/read/page-002.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/object-tags/restored/cold/read/page-003.navigation.json b/corpus/object-tags/restored/cold/read/page-003.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..fdd4663bcd809095dbccb3a5bd8337dd95a9e8d0 --- /dev/null +++ b/corpus/object-tags/restored/cold/read/page-003.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Pictures and files - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/object-tags/restored/cold/read/page-003.png b/corpus/object-tags/restored/cold/read/page-003.png new file mode 100644 index 0000000000000000000000000000000000000000..944b5525d9648af4e9108ea6f9baad1f5bbd6c2c Binary files /dev/null and b/corpus/object-tags/restored/cold/read/page-003.png differ diff --git a/corpus/object-tags/restored/cold/read/page-003.xml b/corpus/object-tags/restored/cold/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..95e253bb034a80c6194c810dbfc15cec4176d2c9 --- /dev/null +++ b/corpus/object-tags/restored/cold/read/page-003.xml @@ -0,0 +1,194 @@ + +iVBORw0KGgoAAAANSUhEUgAAAKAAAAB4CAIAAAD6wG44AAACWklEQVR4nO3dy1EkQRAE0WYMsRCh +DoiAGIiDCBwQYsVaFcCgKyK93M8cJvNZ9vAzeHp5/3cZt0f6Bdi9CQxPYHgCwxMYnsDwBIYnMDyB +4QkMT2B4AsMTGJ7A8ASGJzA8geEJDE9geM8Xt6/Xt+9/8Pr8uIg9YX7p7kec55DPBv5zVB72SOBt +rgDpScBB17nSM4CraGcxtwPX0k5h7gUeQdvPXPqNjnG6V+trrrvgzjXNPeWuCwboXmVTtFxw1VJI +p1xxwUjdq2OuPHDDFsDTPQ6fHz9jEvgE3fikMeBzdLPzZoBP0w1OHQA+Uzc1+27gk3UjG8h/mWQc +YM93/x72Aasb2YaPaHibgD3f1E68YHg7gD3f4Ga8YHi3A3u+2f14wfAEhncvsM/n+Ja8YHgCwxMY +3o3AvgE37MoLhicwPIHhCQxPYHgCwxMYnsDwBIYnMLwH+w8YTOm+XXnB8ASGJzC8e4F9G45vyQuG +JzC824F9Smf34wXD2wHsEQc34wXD2wTsEad24gXD2wfsEUe2sfWCNd6/Bx/R8HYDe8Rr74/JAxd8 +svHaPnvmEX2m8UpMHXsPPs14heZNfpJ1jvHKTRr+LPoE4xWdMf9lEtt4pafLAzdsATxXy7+2g/3V +h1VAW3TBhXvBTNF1wdNPeTXRNl5w86aGvubSC551yquSdgZwOfMqpp0EXMi86mnnATdIryGus4H3 +S69prhDgW7HXWFQm8C/JF4LzLGDr/UaH/VUCwxMYnsDwBIYnMDyB4QkMT2B4AsMTGJ7A8ASGJzA8 +geEJDE9geAJf7P4Dry+9j2KmlKMAAAAASUVORK5CYII=iVBORw0KGgoAAAANSUhEUgAAAKAAAAB4CAIAAAD6wG44AAACWklEQVR4nO3dy1EkQRAE0WYMsRCh +DoiAGIiDCBwQYsVaFcCgKyK93M8cJvNZ9vAzeHp5/3cZt0f6Bdi9CQxPYHgCwxMYnsDwBIYnMDyB +4QkMT2B4AsMTGJ7A8ASGJzA8geEJDE9geM8Xt6/Xt+9/8Pr8uIg9YX7p7kec55DPBv5zVB72SOBt +rgDpScBB17nSM4CraGcxtwPX0k5h7gUeQdvPXPqNjnG6V+trrrvgzjXNPeWuCwboXmVTtFxw1VJI +p1xxwUjdq2OuPHDDFsDTPQ6fHz9jEvgE3fikMeBzdLPzZoBP0w1OHQA+Uzc1+27gk3UjG8h/mWQc +YM93/x72Aasb2YaPaHibgD3f1E68YHg7gD3f4Ga8YHi3A3u+2f14wfAEhncvsM/n+Ja8YHgCwxMY +3o3AvgE37MoLhicwPIHhCQxPYHgCwxMYnsDwBIYnMLwH+w8YTOm+XXnB8ASGJzC8e4F9G45vyQuG +JzC824F9Smf34wXD2wHsEQc34wXD2wTsEad24gXD2wfsEUe2sfWCNd6/Bx/R8HYDe8Rr74/JAxd8 +svHaPnvmEX2m8UpMHXsPPs14heZNfpJ1jvHKTRr+LPoE4xWdMf9lEtt4pafLAzdsATxXy7+2g/3V +h1VAW3TBhXvBTNF1wdNPeTXRNl5w86aGvubSC551yquSdgZwOfMqpp0EXMi86mnnATdIryGus4H3 +S69prhDgW7HXWFQm8C/JF4LzLGDr/UaH/VUCwxMYnsDwBIYnMDyB4QkMT2B4AsMTGJ7A8ASGJzA8 +geEJDE9geAJf7P4Dry+9j2KmlKMAAAAASUVORK5CYII=https://example.invalid/picture]]>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 ++38UfRlkE+m5vQAAAABJRU5ErkJggg== diff --git a/corpus/object-tags/restored/cold/read/payloads.json b/corpus/object-tags/restored/cold/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..59a5887e624b39a847729dd8d86d02fce18d3cb8 --- /dev/null +++ b/corpus/object-tags/restored/cold/read/payloads.json @@ -0,0 +1,34 @@ +[ + { + "sha256": "32a01b6d0a94deebc53c688868b14b6fccc4e18b3b64ea052823e3856cf35b89", + "name": "float.txt", + "object": "{6266A23A-19F7-0158-3CC8-FA92C958A150}{40}{B0}", + "kind": "InsertedFile", + "page": "{C25F625E-5275-017D-26E8-F69832CFE5B3}{1}{B0}", + "bytes": 38 + }, + { + "sha256": "32a01b6d0a94deebc53c688868b14b6fccc4e18b3b64ea052823e3856cf35b89", + "name": "float.txt", + "object": "{A897B34E-BB54-03A6-0A8B-F8086C226651}{18}{B0}", + "kind": "InsertedFile", + "page": "{C25F625E-5275-017D-26E8-F69832CFE5B3}{1}{B0}", + "bytes": 38 + }, + { + "sha256": "33fdf6e43c05ed910d335eda3fa269ccbd1415336c17a4a5357d009ee495b3a6", + "name": "notes.txt", + "object": "{5C260B06-D129-4877-B65E-78CBE3BC7260}{48}{B0}", + "kind": "InsertedFile", + "page": "{FB35F071-BFAA-4809-B9BD-26A6CFD8DD09}{1}{B0}", + "bytes": 18 + }, + { + "sha256": "33fdf6e43c05ed910d335eda3fa269ccbd1415336c17a4a5357d009ee495b3a6", + "name": "notes.txt", + "object": "{5C260B06-D129-4877-B65E-78CBE3BC7260}{59}{B0}", + "kind": "InsertedFile", + "page": "{FB35F071-BFAA-4809-B9BD-26A6CFD8DD09}{1}{B0}", + "bytes": 18 + } +] \ No newline at end of file diff --git a/corpus/object-tags/restored/cold/run.json b/corpus/object-tags/restored/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..934954c2aa295f96ba5859820ca5abbdf8dbd8de --- /dev/null +++ b/corpus/object-tags/restored/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/ocrlab/candidate", + "expected_pages": 4, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35" + } +} diff --git a/corpus/object-tags/restored/cold/scripts/cold.ps1 b/corpus/object-tags/restored/cold/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/object-tags/restored/cold/scripts/cold.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } +$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' +if ($CloneHost) { + if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { + throw 'The disposable clone hostname does not match this machine.' + } + New-Item "$key\Options\Paths" -Force | Out-Null + New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null +} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or + -not (Test-Path 'C:\one-tests\profile-original-cache')) { + throw 'Park the personal OneNote profile before resetting the test cache.' +} +$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' +$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) +if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } +New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null +if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } +if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } +New-Item "$key\OpenNotebooks" | Out-Null +New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null diff --git a/corpus/object-tags/restored/cold/scripts/read.ps1 b/corpus/object-tags/restored/cold/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a42366b32e8baaf13dae92e9472cae7a071220cc --- /dev/null +++ b/corpus/object-tags/restored/cold/scripts/read.ps1 @@ -0,0 +1,144 @@ +param( + [Parameter(Mandatory=$true)][string]$Root, + [int]$ExpectedPages = -1, + [switch]$UseCurrentCache, + [switch]$Pdf, + [switch]$KeepOpen, + [string]$CloneHost = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +$notebook = Join-Path $root 'notebook' +$output = Join-Path $root 'read' +if (Test-Path $output) { throw 'Choose a new read destination.' } +if ($UseCurrentCache) { + if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { + throw 'Park the personal OneNote profile before reading test notebooks.' + } +} else { + & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost +} +New-Item -ItemType Directory -Path $output | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +$failure = $null +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $process = Get-Process ONENOTE + @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; + powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | + ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 + $sections = @() + foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { + $id = '' + $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) + $sections += $id + } + $deadline = [DateTime]::UtcNow.AddSeconds(300) + $previous = '' + $lastChange = '' + $stableSince = [DateTime]::UtcNow + $settled = $false + do { + $pages = @{} + foreach ($section in $sections) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$xml = $hierarchy + foreach ($node in $xml.SelectNodes('//*[@path]')) { + if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { + throw 'OneNote opened a section outside the copied notebook.' + } + } + foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { + $id = $node.GetAttribute('ID') + $content = '' + $app.GetPageContent($id, [ref]$content, 1, 1) + $pages[$id] = $content + } + } + $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) + if ($signature -ne $previous) { + $lastChange = $previous + $previous = $signature + $stableSince = [DateTime]::UtcNow + } + if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or + ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and + ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { + [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) + $index = 0 + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + $index++ + } + $hierarchy = '' + $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) + throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." + } + $index = 0 + $payloads = @() + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + if ($Pdf) { + $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) + $app.NavigateTo($id, '', $false) + $app.Publish($id, $pdfPath, 3, '') + if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } + } + [xml]$page = $pages[$id] + foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { + $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) + # A paragraph's file is named by its paragraph, one on the page by itself. + $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode } + $payloads += @{ page = $id; object = $owner.GetAttribute('objectID'); + kind = $file.LocalName; name = $file.GetAttribute('preferredName'); + sha256 = $hash; bytes = $bytes.Length } + } + $index++ + } + [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) + $all = '' + $app.GetHierarchy($notebookId, 4, [ref]$all, 1) + [xml]$finalTree = $all + $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) + if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { + throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' + } + [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) + Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." +} catch { + $failure = $_ + [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + throw +} finally { + try { + try { + if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } + if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { + $app.CloseNotebook($notebookId, $false) + } + } catch { + if ($null -eq $failure) { throw } + [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + } + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + if (-not $UseCurrentCache -and -not $CloneHost) { + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + } +} diff --git a/corpus/object-tags/restored/cold/source.json b/corpus/object-tags/restored/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..8438449580b9ba3cf9896f9ac2e38a8ea718e747 --- /dev/null +++ b/corpus/object-tags/restored/cold/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 1604, + "sha256": "dc9d3f6501503965aa97e94b86766f4e9a87d279eeff4a253ff4612ca8993405", + "mtime_ns": 1790822016309856876 + }, + { + "path": "files.one", + "bytes": 67592, + "sha256": "fab6f6881081cf1b05cc375301bcfe1e7eda90a294bab3fa137108cf92b45026", + "mtime_ns": 1790822016309661293 + } +] diff --git a/corpus/object-tags/restored/cold/teardown.json b/corpus/object-tags/restored/cold/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/object-tags/restored/cold/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/picture-link/README.md b/corpus/picture-link/README.md new file mode 100644 index 0000000000000000000000000000000000000000..335bfdf1de4e32c7030d8f9441298bfeffcd29ac --- /dev/null +++ b/corpus/picture-link/README.md @@ -0,0 +1,15 @@ +# Pictures with a link + +`lab/` holds screenshots of OneNote 2010 in a disposable clone of +`corpus/m6/native-features-01`, on the page "Image png", whose picture stores a link +(WzHyperlinkUrl, the COM read's `hyperlink`). Hovering it shows the address and "Ctrl+click +to follow link" (`hover.png`); a click selects the picture (`click-selects.png`); Ctrl+click +opens the address, here an unreachable one (`ctrl-click.png`). Snowbound follows the same +link on Ctrl+click, Command+click on macOS. + +`candidate/` is the output of `a_linked_picture_keeps_its_link_through_undo` in +`crates/canvas/tests/pictures.rs`: the picture deleted and brought back by undo, published +as one revision. `cold/` is a fresh OneNote 2010 read, whose picture still links there. +`tools/test_picture_link.py` checks this without a VM. Regenerate with +`CANVAS_PICTURE_LINK_EXPORT` set to a new absolute directory while running the test, then +cold-open it with `tools/native_runner.py OUTPUT COLD --collect-notebook`. diff --git a/corpus/picture-link/candidate/Features.one b/corpus/picture-link/candidate/Features.one new file mode 100644 index 0000000000000000000000000000000000000000..2dbc9aa00b26ea9346af118b8654f1456fb78966 Binary files /dev/null and b/corpus/picture-link/candidate/Features.one differ diff --git a/corpus/picture-link/candidate/Open Notebook.onetoc2 b/corpus/picture-link/candidate/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..4c362a1db4fc70a61d84216cbea0ca32e2a4c4ee Binary files /dev/null and b/corpus/picture-link/candidate/Open Notebook.onetoc2 differ diff --git a/corpus/picture-link/cold/commands.jsonl b/corpus/picture-link/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..c26282d89d832c5548194a40e8890c509a88955d --- /dev/null +++ b/corpus/picture-link/cold/commands.jsonl @@ -0,0 +1,3 @@ +{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-70F8F401 -ExpectedPages -1", "exit": 0, "stdout": "Read 1 sections and 13 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} diff --git a/corpus/picture-link/cold/machine.json b/corpus/picture-link/cold/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..1d030a4409ef7178bdbbff02866e088bd3585bdc --- /dev/null +++ b/corpus/picture-link/cold/machine.json @@ -0,0 +1 @@ +{"name": "m6-70f8f401", "hostname": "ONE-M6-70F8F401"} diff --git a/corpus/picture-link/cold/notebook/Features.one b/corpus/picture-link/cold/notebook/Features.one new file mode 100644 index 0000000000000000000000000000000000000000..82d5101f3bf5bb57a6e69ecc221f406867b763d8 Binary files /dev/null and b/corpus/picture-link/cold/notebook/Features.one differ diff --git a/corpus/picture-link/cold/notebook/Open Notebook.onetoc2 b/corpus/picture-link/cold/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..293d223132041edc706fde5a7fd5be72939a8fb6 Binary files /dev/null and b/corpus/picture-link/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/picture-link/cold/read/4cff0039dbdf53a59f5f4e4592c29a5297ea3b7acce24415c0ae83c36c6bd7c2.attachment b/corpus/picture-link/cold/read/4cff0039dbdf53a59f5f4e4592c29a5297ea3b7acce24415c0ae83c36c6bd7c2.attachment new file mode 100644 index 0000000000000000000000000000000000000000..a959c053358a06abc072d3492e7a86d2e712d804 Binary files /dev/null and b/corpus/picture-link/cold/read/4cff0039dbdf53a59f5f4e4592c29a5297ea3b7acce24415c0ae83c36c6bd7c2.attachment differ diff --git a/corpus/picture-link/cold/read/56d4af65701c26df20bd4021eda95b6e830348ce3a746086079fe89285548dc9.attachment b/corpus/picture-link/cold/read/56d4af65701c26df20bd4021eda95b6e830348ce3a746086079fe89285548dc9.attachment new file mode 100644 index 0000000000000000000000000000000000000000..96260477f0ca60b1605e2971d8cdc146b8b4e02b Binary files /dev/null and b/corpus/picture-link/cold/read/56d4af65701c26df20bd4021eda95b6e830348ce3a746086079fe89285548dc9.attachment differ diff --git a/corpus/picture-link/cold/read/environment.json b/corpus/picture-link/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..f1ef64dd3492e1e5aae588ec1afb7a0547e15da4 --- /dev/null +++ b/corpus/picture-link/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-70F8F401", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/picture-link/cold/read/hierarchy.xml b/corpus/picture-link/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..ce8771673d5f06eccd60c12b023577b8c14bf096 --- /dev/null +++ b/corpus/picture-link/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/picture-link/cold/read/page-000.xml b/corpus/picture-link/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..f0561b8658a6ea132fabf360da397a4fd71a2bc6 --- /dev/null +++ b/corpus/picture-link/cold/read/page-000.xml @@ -0,0 +1,46 @@ + +iVBORw0KGgoAAAANSUhEUgAAAGAAAABACAMAAADS6oI8AAADAFBMVEW4t5mrt5O4qpWrqo6euo2S +uoeZs4ieqoiSqoK4nJCrnIq4kIyrkIaen4SSn36ZmH+ekH+SkHmGuoGGs395t3pounJos3CGqnx5 +qnVtqm9iqmqGnHd5nHGGkHN5kG1on2lomGdokGS4hIirhIK4d4Srd32eh3ySh3aZgHeed3eSd3G4 +a4Cra3m4YHyrYHaea3OSa22eYG+SYGmGh3CGgG55hGloh2FogF+Gd2t5d2Rtd15id1mGa2d5a2CG +YGN5YF1ta1pia1VoYFRWumlWs2dJt2I4ulo4s1hWqmRJql09qlcyqlImulEaukshs0wKukMKs0Em +qkwaqkYPqkAEqjtWnF9JnFlWkFtJkFU4n1E4mE84kEwmn0gan0IhmEMmkEMakD0KnzoKmDgKkDVW +h1hWgFZJhFE4h0k4gEdWd1NJd0w9d0Yyd0FWa09Ja0hWYEtJYEU9a0Iyaz04YDwmh0AahzohgDsK +hzIKgDAmdzsadzUPdy8EdyomazcaazEmYDMaYC0PaysEayYKYCW4VHirVHK4R3SrR22eV2ySV2aZ +UGeeR2eSR2G4O3CrO2m4MGyrMGaeO2OSO12eMF+SMFmGV2CGUF55VFloV1FoUE+GR1t5R1RtR05i +R0mGO1d5O1CGMFN5ME1tO0piO0VoMES4JGirJGK4GGSrGF6eJ1ySJ1aZIFeeGFeSGFG4DmGrDlq4 +A12rA1eeDlSSDk6eA1CSA0qGJE95JEmGGEt5GEVoJ0FoID9oGDyGDkh5DkGGA0R5Az5tDjtiDjZo +AzVWVEdJVEE4Vzk4UDdWR0NJRzw9RzYyRzFWOz9JOzhWMDtJMDU9OzIyOy04MCwmVzAaVyohUCsK +VyIKUCAmRysaRyUPRx8ERxomOycaOyEmMCMaMB0POxsEOxYKMBVWJDdJJDFWGDNJGC04Jyk4ICc4 +GCRWDjBJDilWAyxJAyY9DiMyDh44Ax0mJyAaJxohIBsmGBsaGBUKJxIKIBAKGA0mDhgaDhImAxQa +Aw4PDgwEDgcKAwZYdbPqAAAAAWJLR0QAiAUdSAAAAAxjbVBQSkNtcDA3MTIAAAADSABzvAAABjJJ +REFUWEedkImbTgUcha9ChZl2RUKR0TKTtGiRsZUSEpI0tKm0GoW0WSOjMmNpL82oFG1apJJltNDC +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 +AElFTkSuQmCC + diff --git a/corpus/picture-link/cold/read/page-001.xml b/corpus/picture-link/cold/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..73fe4f193326218fe3a6ff765e0a99551663f335 --- /dev/null +++ b/corpus/picture-link/cold/read/page-001.xml @@ -0,0 +1,6 @@ + +iVBORw0KGgoAAAANSUhEUgAAAGAAAABACAIAAABqVuVZAAAAwUlEQVR4nO3ZwQ2DQBDF0Hkk/dec +CuASwXCwtBVY/mYBM3OMznEC4TsfMx1nEALk2o8ACZB/GpJBAiSD3PcgbmICJIM0MVvX/RokQDJI +E1ODvPOrS5EWIBmkialB1ntcpAVIBmli1rvTRVGAZJAmZj1AvawKkAzSxNQg6zEu0gIkgzQx69Hp +oihAMkgT85LTn1UBkkGamBpkvcdFWoBkkCZmvTtdFAVIBmli1gPUy6oAySBNTA2yHuMiLUAyyPMT ++wHXVRp4qDc0HQAAAABJRU5ErkJggg== diff --git a/corpus/picture-link/cold/read/page-002.xml b/corpus/picture-link/cold/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..2aef42b979042ade456190a5c69f04aea99678f2 --- /dev/null +++ b/corpus/picture-link/cold/read/page-002.xml @@ -0,0 +1,6 @@ + +iVBORw0KGgoAAAANSUhEUgAAAGAAAABACAIAAABqVuVZAAAAwUlEQVR4nO3ZwQ2DQBDF0Hkk/dec +CuASwXCwtBVY/mYBM3OMznEC4TsfMx1nEALk2o8ACZB/GpJBAiSD3PcgbmICJIM0MVvX/RokQDJI +E1ODvPOrS5EWIBmkialB1ntcpAVIBmli1rvTRVGAZJAmZj1AvawKkAzSxNQg6zEu0gIkgzQx69Hp +oihAMkgT85LTn1UBkkGamBpkvcdFWoBkkCZmvTtdFAVIBmli1gPUy6oAySBNTA2yHuMiLUAyyPMT ++wHXVRp4qDc0HQAAAABJRU5ErkJggg== diff --git a/corpus/picture-link/cold/read/page-003.xml b/corpus/picture-link/cold/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..2fd399d83bf4359c150ea3d67830ce2c5545f28e --- /dev/null +++ b/corpus/picture-link/cold/read/page-003.xml @@ -0,0 +1,9 @@ + +iVBORw0KGgoAAAANSUhEUgAAAGAAAABACAIAAABqVuVZAAAAAXNSR0IArs4c6QAAAAlwSFlzAAAO +xAAADsQBlSsOGwAAARlJREFUeF7t2VFHQ2EcBvD3PWetVqvVam1ERESMiIiIiL7/J5p9gD1dvF51 +8eNcn4uf53n2P1ZLKUOpnkMCtYx0kkAt00F8gkAtsxFQBJpPAEWgxRGgCLScAopAq2NAEWhzAigC +3c0ARaD7U0AR6OEMUAR6nAOKQE/ngCLQ8wWgCLRdAIpAL5eAItDrFaAI9LYEFIHerwFFoI8bQBHo +cwUoAn3dAopA32tAEehnAygKjPu/xjyHBAY0WQDQL+0BBKhtYSVIgiSo6w+xiqmYiqnYX34J2SAb +ZINskA1qa4EESZAEdW1B15e7g9xBbf2VIAmSICPtDmprgQRJkAR1bUHXl7uD3EFt/ZUgCZIgI+0O +amuBBEnQP07QDo1OW207QPoWAAAAAElFTkSuQmCC + diff --git a/corpus/picture-link/cold/read/page-004.xml b/corpus/picture-link/cold/read/page-004.xml new file mode 100644 index 0000000000000000000000000000000000000000..73a40d4afe578cd8667f5500e8e22f4b8116bf1c --- /dev/null +++ b/corpus/picture-link/cold/read/page-004.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/picture-link/cold/read/page-005.xml b/corpus/picture-link/cold/read/page-005.xml new file mode 100644 index 0000000000000000000000000000000000000000..7299aeae1d5b627b427b32b17c1aec9f3a8d6414 --- /dev/null +++ b/corpus/picture-link/cold/read/page-005.xml @@ -0,0 +1,6 @@ + +iVBORw0KGgoAAAANSUhEUgAAAGAAAABACAIAAABqVuVZAAAAwUlEQVR4nO3ZwQ2DQBDF0Hkk/dec +CuASwXCwtBVY/mYBM3OMznEC4TsfMx1nEALk2o8ACZB/GpJBAiSD3PcgbmICJIM0MVvX/RokQDJI +E1ODvPOrS5EWIBmkialB1ntcpAVIBmli1rvTRVGAZJAmZj1AvawKkAzSxNQg6zEu0gIkgzQx69Hp +oihAMkgT85LTn1UBkkGamBpkvcdFWoBkkCZmvTtdFAVIBmli1gPUy6oAySBNTA2yHuMiLUAyyPMT ++wHXVRp4qDc0HQAAAABJRU5ErkJggg== diff --git a/corpus/picture-link/cold/read/page-006.xml b/corpus/picture-link/cold/read/page-006.xml new file mode 100644 index 0000000000000000000000000000000000000000..bdc6ba80634759a819c2742d6ae17158ace2e370 --- /dev/null +++ b/corpus/picture-link/cold/read/page-006.xml @@ -0,0 +1,4 @@ + +Inherited strike and spacing]]>مرحبا بالعالم]]> diff --git a/corpus/picture-link/cold/read/page-007.xml b/corpus/picture-link/cold/read/page-007.xml new file mode 100644 index 0000000000000000000000000000000000000000..5898c8243515a9bd549c125458e5a256a0e7d621 --- /dev/null +++ b/corpus/picture-link/cold/read/page-007.xml @@ -0,0 +1,6 @@ + +iVBORw0KGgoAAAANSUhEUgAAAGAAAABACAIAAABqVuVZAAAAwUlEQVR4nO3ZwQ2DQBDF0Hkk/dec +CuASwXCwtBVY/mYBM3OMznEC4TsfMx1nEALk2o8ACZB/GpJBAiSD3PcgbmICJIM0MVvX/RokQDJI +E1ODvPOrS5EWIBmkialB1ntcpAVIBmli1rvTRVGAZJAmZj1AvawKkAzSxNQg6zEu0gIkgzQx69Hp +oihAMkgT85LTn1UBkkGamBpkvcdFWoBkkCZmvTtdFAVIBmli1gPUy6oAySBNTA2yHuMiLUAyyPMT ++wHXVRp4qDc0HQAAAABJRU5ErkJggg== diff --git a/corpus/picture-link/cold/read/page-008.xml b/corpus/picture-link/cold/read/page-008.xml new file mode 100644 index 0000000000000000000000000000000000000000..cf2684097540ee215500b4d13fd7680c7a7214bf --- /dev/null +++ b/corpus/picture-link/cold/read/page-008.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/picture-link/cold/read/page-009.xml b/corpus/picture-link/cold/read/page-009.xml new file mode 100644 index 0000000000000000000000000000000000000000..a17dcc41090a8393c2762d39193735a35b620fec --- /dev/null +++ b/corpus/picture-link/cold/read/page-009.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/picture-link/cold/read/page-010.xml b/corpus/picture-link/cold/read/page-010.xml new file mode 100644 index 0000000000000000000000000000000000000000..4a8416f6e32f41ce115a91f21b98bbde0cec24c6 --- /dev/null +++ b/corpus/picture-link/cold/read/page-010.xml @@ -0,0 +1,300 @@ + +iVBORw0KGgoAAAANSUhEUgAAAGAAAABACAIAAABqVuVZAAAAAXNSR0IArs4c6QAAAARnQU1BAACx +jwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAEImSURBVHheDbiBV2GL+v+/f9V1q+uk46TjpDFp +TBqDMUhoh3Zoh3Zoh3Zoh3YYgzE0Jo1J46TjpOOk61Yf32pZ1bKqZVXLqpZV/W2/8y8863nez/v1 +AgAAaAL+vxagiQQ0twIt7cC/yACpA/h3J9BKBdq6gPZu4D90gNwD/NQLdDABSh/Q2Q/8zAaoA8Av +g0AXF6DxgG4B8KsQoIuA3yRAjxRgyIBeBfACBJhK4KUa6IMAlgbo1wGvYICtB14bgQEE4JiAQQvw +BgW4VuCtHeBhAN8BCFzAOxwQuoH3XkBEAGIfIAkAQ0FAGgKGI4AsCshjgGIFGEkA4CowugYoU4Bq +HVBvAGMZANoExrcATQ7QbgO6HWCiAMC7wOQeoC8Bhn3AeAhMlQHkCJg+AUwVwHwKWM6BmSqAXgCz +V4C1BtiuAfstMFcHsDtg/gFwNADnI+B6BhYAoPn/a2puamluJjW3tDb/q72ZRG7+d0dza2dzG7W5 +vav5P93NZHrzTz3NHb3NFGZzZ1/zz/3NVHbzLwPNXYPNNG5zN6/5V0EzXdj8m6i5R9LMkDb3yppf +KJqZYPNLZXOfupkFNfdrml/pmtlw82t984CxmYM0D5qa31iauWjzW2szz97Mx5oFjuZ3rmYh3vze +3SzyNouJZomveSjQLA02D4eaZZFmebRZEWseWWkGE82jq83KtWZVqlm93jy20Qxlmsc3mzVbzdpc +s267eWKnGS40T+426/eaDaVm437z1GEzUm6ePmo2nTSbK82W0+aZ82a02jx70Wy9arbVmu3XzXO3 +zVi9ef6u2fHQ7Gw0ux6bF56bcQAgNTX9MxtSC4n0r1YSqZ30bzKptYPU1klqp5L+00Uid5N+opM6 +ekiUXlInk/RzH4naT/qFTeoaINEGSd1c0q88El1A+k1I6hGRGBJSr5T0QkZiKkgvQVKfksRSk/oh +0isNia0jvYZJA3oSx0gaREhvTCSuhfQWJfGsJL6dJMBI7xwkoYv0HieJ3CSxlyQhSEM+kjRAGg6S +ZCGSPEJSREkjMRK4QhpNkJSrJNUaSZ0ija2ToA3SeIak2SRpt0i6HGlimwTvkCYLJP0uybBHMpZI +U/sk5JA0XSaZjkjmE5KlQpo5JaHnpNkqyXpBsl2R7DXS3DUJuyXN10mOO5LzgeRqkBYeSfgzaREA +2pqb2lpa2v5FaiO1tv27va2V3NbW0dbe2fYfahu5q+2n7rYOehulp62zt+1nZhu1r+2X/rYudhtt +oK17sO1Xbhud1/aboK1H2MYQtfVK2l5I25iytpeKtj6wjaVs61e3vYLa2Jq217q2AbiNo28bNLa9 +Qdq4pra3ljYe2sa3tgnsbe+wNqGj7b2rTYS3id1tEm/bENEm9bUNB9pkwTZ5qE0RaRuJtoGxttGV +NmWiTbXapl5rG0u1Qett4xttmkybdrNNt9U2kWuDt9smd9r0hTbDbptxr22q1Ibst00ftpnKbeaj +NstJ20ylDT1tmz1vs1bbbBdt9qu2uVobdt02f9vmqLc579pcD20LjTb8sW3xuc0NAOSWJvK/Wsgk +EvnfreTWdnIbmdzeQf5PJ5lMJf/URe7oJlPo5M4e8s+9ZCqT/EsfuaufTGOTuwfIvw6S6Vzybzxy +j4DMEJJ7ReQXEjJTSn4pI/cpyCyQ3K8kv1KT2RD5tYY8oCNzYPKgnvzGSOYi5LcmMs9C5qNkgZX8 +zk4WYuT3DrLIRRbjZImbPOQlSwnysI8sC5DlQbIiRB6JkMEoeTRGVq6QVQmyepU8tkaGUuTxdbJm +g6zNkHWb5IktMpwjT26T9TtkQ4Fs3CVP7ZGREnl6n2w6JJvLZMsReeaEjFbIs6dk6znZViXbL8hz +V2SsRp6/Jjtuyc462XVHXngg4w3y4iPZ/Uz2AADlX00UUgvl3yRKayulrZ3STqb8p4NC7qT8RKV0 +dFEo3ZROOuXnHgq1l/ILk9LVR6H1U7rZlF8HKPRBym9cSg+PwhBQeoWUFyIKU0J5KaX0ySgsBaUf +pLxSUthqymuIMqChcHSUQZjyRk/hGilvEQrPROFbKAKU8s5KEdop7zGKyEERuygSnDLkpki9lGGC +IvNR5AGKIkgZCVHACGU0SlHGKKoVijpBGVulQGuU8RRFs07RblB0GcrEJgXeokzmKPptimGHYixQ +pnYpyB5lukQx7VPMhxRLmTJzREFPKLMVivWUYjun2KuUuQsKdkWZr1Ec1xTnLcVVpyzcUfAHymKD +4n6keJ4pXgCgkpqo/26htpKoba3U9nbqf/7ZnA7qT53UDiqV0kXt7Kb+TKdSe6i/9FK7mFRaH7W7 +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 +B583DmKZg/jmwcrWwZfcQWL74OvOwWrhILl7sLZ38K10kNo/+H54sF4+SB8dbJwc/KgcZE4Pfj8/ +2KweZC8Otq4O/qgd5K4P/rw92K4f5O8Odh4O/mocFB4P/n4+2AWAsqSpPNRSlpLKw61lWXtZTi4r +OsojnWWQWh7tKiu7yyp6Wd1THustQ8zyeF9Z01/Wssu6gfLEYBnmlid5Zb2gbBCWjaLylKSMSMvT +srJJUTaDZYuyPKMuo1B5VlO26so2uGzXl+eMZQwpz5vKDkvZiZZd1vKCvYxj5UVH2e0qe/Cy111e +8pYJorzsK/sCZX+wHAiVP0T+0Tnlj7FyaKUcTpQjq+VPa+Voqvx5vRzbKMcz5ZXN8petciJX/rpd +Xt0pJwvltd3yt71yqlT+vl9ePyyny+WNo/KPk3KmUv79tLx5Xs5Wy1sX5T+uyrla+c/r8vZtOV8v +79yV/3ooFxrlvx/Lu8/lIgAcDzUdS1uOh0nHstZjefuxgnw80nEMdh6PUo+VXceq7mM1/Xis5xjq +PR5nHmv6jrX9xzr28cTAMTx4PMk91vOODYJjo/B4SnSMSI6npccm2bFZcWwBj2eUx6j6eBY6tmqO +bbpjO3w8pz/GjMfzyLHDdOy0HLvQ4wXrMW4/XsSO3Y5jj+vYix8vuY8J7/EycezzHfsDx4Hg8YfQ +cTBy/DF6HIodh1eOI4njT6vH0bXjz6nj2PpxfON4JXP8ZfM4sXX8NXe8un2c3DleKxx/2z1O7R1/ +Lx2v7x+nD483ysc/jo4zJ8e/V443T4+z58db1eM/Lo5zV8d/1o63r4/zt8c79eO/7o4LD8d/N453 +H4+Lz8d7AFCRNlWGWyoyUkXeWlG0V0bIFbCjMtpZUVIrqq6KursyRq9APZXx3oqGWdH2VXT9lQl2 +BR6oTA5W9NyKgVcxCipTwgoiqkxLKiZpxSyrWBSVGbCCKiuz6ooVqtg0FbuuMgdXMH1l3lhxIBWn +qeKyVBbQCm6tLNorbqzicVS8rsoSXiHclWVvxUdU/L5KIFD5EKwEQ5WPkUooWgnHKpGVyqdEJbpa ++bxWiaUq8fXKykblS6aS2Kx83aqs5irJ7craTuVboZLarXzfq6yXKun9ysZh5Ue5kjmq/H5S2axU +sqeVrfPKH9VK7qLy51Vlu1bJX1d2bit/1SuFu8rfD5XdRqX4WNl7rvwXAM6Gm85kLWdy0pmi9Wyk +/Qwkn412nCk7z1TUM3XX2Vj3GUQ/G+850/SeaZlnur6zif4zmH02OXCmHzwzcM+MvLMpwRkiPJsW +nZkkZ2bpmUV2NqM4Q8GzWeWZVX1mg87smrM53RkGn83rzxzGMydy5jKdLVjOcPRs0Xrmtp95sDOv +42zJdUbgZ8vuM5/3zE+cBXxnHwJnweDZx9BZKHIWjp5FYmefVs6iibPPq2extbN46mxl/ezLxlki +c/Z182x16yyZO1vbPvu2c5YqnH3fPVvfO0uXzjb2z34cnmXKZ78fnW2enGUrZ1unZ3+cn+WqZ39e +nG1fneVrZzvXZ3/dnhXqZ3/fne0+nBUbZ3uPZ/99PisBQFXWVJW3VBWk6khrFWyvjpKryo6qqrOq +plbHuqpQd3WcXtX0VLW9VR2zOtFXhfurk+yqfqBqGKwaudUpXhURVKeFVZOoapZULdLqjKyKKqqz +YNWqrNrUVTtUndNUMV11Hq469FWnsepCqgumKm6pLqJVt7XqsVe9WHXJUSVc1WW86nNX/d5qgKh+ +8FWDgerHYDUUqoYj1Ui0+ilWja5UPyeqsX9k11p1JVX9sl5NbFS/Zqqrm9XkVnUtV/22XU3tVL8X +quu71fRedaNU/bFfzRxWfy9XN4+q2ZPqVqX6x2k1d179s1rdvqjmr6o7tepf19XCbfXvenX3rlp8 +qO41qv99rJaeq/8DgEt506Wi5XKEdAm2Xo62XyrJl6qOS3Xn5Rj1Euq6HO++1NAvtT2Xut7LCeYl +3Hc52X+pZ18aBi6Ng5dT3EuEdzktuDQJL82iS4vkckZ6icouZxWXVvDSpry0qy/noEtMczmvu3TA +l079pct4uYBc4qbLRculG730WC+99ssl7JJwXC67Ln34pd99GfBefiAug77Lj4HLUPAyHLqMRC4/ +RS+jscvPK5exxGV89XJl7fJL6jKxfvl143I1c5ncvFzbuvyWu0xtX37fuVwvXKZ3Lzf2Ln+ULjP7 +l78fXm6WL7NHl1snl39ULnOnl3+eX25XL/MXlztXl3/VLgvXl3/fXu7WL4t3l3sPl/9tXJYeL//3 +fLkPADVFU22kpQaSaqOtNWV7TUWuqTtqY501iFob76ppumtaek3XU5vorcHM2mRfTd9fM7BrxoHa +1GAN4dameTWToGYW1iyi2oykhkprs7KaVVGzgTW7sjanrmFQbV5Tc+hqTrjm0tcWjDUcqS2aam5L +zYPWvNbakr1GYLVlR83nqvnxWsBd++CtBYnaR18tFKiFg7VIqPYpUotGa59jtdhKLZ6orazWvqzV +/lGBX9drqxu1ZKa2tln7tlVL5Wrft2vrO7V0obaxW/uxV8uUar/v1zYPa9lybeuo9sdJLVep/Xla +2z6v5au1nYvaX1e1Qq3293Vt97ZWrNf27mr/faiVGrX/Pdb2n2sHAHAz0nQDttyMkm6UrTeq9hs1 ++Was4wbqvBmn3mi6brTdNzr6zUTPDdx7M8m80ffdGPpvjOybqYEbZPBmmntj4t2YBTcW4c2M6AaV +3MxKb6yyG5vixg7ezClvMPXNPHTj0Nw4dTcu+GZBf4MbbxaRG7fpxmO58aI3S9Ybwn6zjN34HDd+ +100Av/ngvgl6bz4SNyHfTThwEwnefArdRCM3n6M3sdhNfOVmJXHzZfUmsXbzNXWzun6T3LhZy9x8 +27xJbd18z92sb9+kd242Cjc/dm8yeze/l24292+yhzdb5Zs/jm5yJzd/Vm62T2/y5zc71Zu/Lm4K +Vzd/1252r2+Ktzd79Zv/3t2UHm7+17jZf7w5eL45BIA62FQfbakrSXVVa13dXh8j16GO+nhnXUOt +a7vquu76BL0O99Qne+t6Zt3QVzf216fYdWSgPj1YN3HrZl7dIqjPCOuoqD4rqVuldZusblfU58A6 +pqzPq+sOqO7U1F26+gJcx/X1RWPdjdQ9prrXUl9C64S1vmyv+7C631EPuOof8HrQXf/orYeIethX +jwTqn4L1aKj+OVKPRevxWH1lpf4lUU+s1r+u1VdT9eR6fW2j/i1TT23Wv2/V13P19HZ9Y6f+o1DP +7NZ/36tvlurZ/frWYf2Pcj13VP/zpL5dqedP6zvn9b+q9cJF/e+r+m6tXryu793W/1uvl+7q/3uo +7zfqB4/1w+f6/wHA/WjTvbLlXkW6V7fej7XfQ+T78Y57Tee9lnqv67qf6L6H6feTPff63nsD897Y +dz/Vf4+w76cH7k2D92buvYV3PyO4R4X3s6J7q+TeJr23y+7nFPcYeD+vvHeo753QvUtzv6C7x+H7 +Rf2923jvQe69pvslyz2B3i9b7332ez92H3Dcf3DdB/H7j+77kPc+TNxHfPefAvfR4P3n0H0sch+P +3q/E7r+s3CcS919X71fX7pOp+7X1+28b96nM/ffN+/Wt+3TufmP7/sfOfaZw//vu/ebefbZ0v7V/ +/8fhfa58/+fR/fbJfb5yv3N6/9f5faF6//fF/e7VfbF2v3d9/9/b+1L9/n939/sP9weN+8PH+/97 +vi8DQEPZ1FC1NNSkxlhrA2pvjJMbmo6GtrOhozYmuhpwd2OS3tD3NAy9DSOzMdXXQPob0+yGaaBh +HmxYuI0ZXgMVNGaFDauoYZM07NLGnKyBKRrzYMOhbDjVDRfUWNA0cF1jEW649Q2PseFFGkumBmFp +LKMNn7XhtzcCWOODoxF0NT7ijZC7EfY2IkTjk68RDTQ+BxuxUCMeaaxEG19ijcRK42uisbraSK41 +1lKNb+uN1Ebje6axvvmPJW1s5Bo/thuZncbvhcbmbiO719gqNf7Yb+QOG3+WG9tHjfxJY6fS+Ou0 +UThv/F1t7F40ileNvVrjv9eN0m3jf/XG/l3j4KFx2Gj832Oj/Nz4fwDwpGp6Urc8jZGeoNan8fYn +DflJ2/Gk63yaoD7BXU+T3U96+pOh58nY+zTFfEL6nqb7n0zsJ/PAk2XwaYb7hPKeZgVPVuGTTfRk +lzzNSZ8w2dO84skBPjmVTy710wL0hGueFnVPbvjJo3/yGp+WkCfC9LRsefKhT37rU8D+9AF7Cjqe +PrqeQvhT2P0U8T59Ip6ivqfPgadY8CkeelqJPH2JPiViT19XnlYTT8nVp7W1p2+pp9T60/eNp/XM +U3rzaWPr6UfuKbP99PvO02bhKbv7tLX39EfpKbf/9Ofh03b5KX/0tHPy9FflqXD69Pf50271qXjx +tHf19N/aU+n66X+3T/v1p4O7p8OHp/9rPJUfn/7f89PR/w8QCy14LJdV2gAAAABJRU5ErkJggg== diff --git a/corpus/picture-link/cold/read/page-011.xml b/corpus/picture-link/cold/read/page-011.xml new file mode 100644 index 0000000000000000000000000000000000000000..0dbeb78e8136843b048456d4579c2e500f293069 --- /dev/null +++ b/corpus/picture-link/cold/read/page-011.xml @@ -0,0 +1,21 @@ + +/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a +HBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIy +MjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCABAAGADASIA +AhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQA +AAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3 +ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWm +p6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEA +AwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSEx +BhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElK +U1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3 +uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDwhYfa +plh9qsrD7VMsPtUqRFOqVVh9qmWH2qysPtUyw+1UpHbTqlZYfaplh9qsrD7VMsPtVqR206pVWGpl +h9qsrDUyw+1UpHbTqlZYalWH2q0sNSrD7VSkdtOqVlh9qmWH2qysPtUyw+1UpHdTqnHrD7VMsPtV +lYfaplh9q81SPyqnVKqw+1TLD7VZWH2qZYfaqUjtp1SssPtUyw+1WVh9qmWH2qlI7adUqrDUyw+1 +WVhqZYfarUjtp1SssNSrD7VaWGpVh9qpSO2nVKyw+1TLD7VZWH2qZYfaqUjup1Tj1h9qmWH2qysP +tUyw+1eapH5VTqlVYfaplh9qsrD7VMsPtVKR206pWWH2qZYfarKw+1TLD7VSkdtOqVVhqZYfarKw +1MsPtVqR206pWWGpVh9qtLDUqw+1UpHbTqlZYfaplh9qsrD7VMsPtVKR206px6w+1TLD7VZWH2qZ +YfavNUj8rp1SqsPtUyw+1WVh9qmWH2qlI7adUrLD7VMsPtVlYfaplh9qpSO2nVKqw1MsPtVlYamW +H2q1I7adUrLDUqw+1WlhqVYfaqUjtp1SssPtUyw+1WVh9qmWH2qlI7adU//Z + diff --git a/corpus/picture-link/cold/read/page-012.xml b/corpus/picture-link/cold/read/page-012.xml new file mode 100644 index 0000000000000000000000000000000000000000..aa928b73f9cc37cec4b61721eddafabca2aea400 --- /dev/null +++ b/corpus/picture-link/cold/read/page-012.xml @@ -0,0 +1,6 @@ + +iVBORw0KGgoAAAANSUhEUgAAAGAAAABACAIAAABqVuVZAAAAwUlEQVR4nO3ZwQ2DQBDF0Hkk/dec +CuASwXCwtBVY/mYBM3OMznEC4TsfMx1nEALk2o8ACZB/GpJBAiSD3PcgbmICJIM0MVvX/RokQDJI +E1ODvPOrS5EWIBmkialB1ntcpAVIBmli1rvTRVGAZJAmZj1AvawKkAzSxNQg6zEu0gIkgzQx69Hp +oihAMkgT85LTn1UBkkGamBpkvcdFWoBkkCZmvTtdFAVIBmli1gPUy6oAySBNTA2yHuMiLUAyyPMT ++wHXVRp4qDc0HQAAAABJRU5ErkJggg== diff --git a/corpus/picture-link/cold/read/payloads.json b/corpus/picture-link/cold/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..206003e10fb990670c5a5c23b760886be2259278 --- /dev/null +++ b/corpus/picture-link/cold/read/payloads.json @@ -0,0 +1,18 @@ +[ + { + "sha256": "4cff0039dbdf53a59f5f4e4592c29a5297ea3b7acce24415c0ae83c36c6bd7c2", + "name": "Fictitious attachment.txt", + "object": "{8FA38498-A15A-08DC-3759-A172FAAA260D}{40}{B0}", + "kind": "InsertedFile", + "page": "{95B9BD1C-79B9-0611-2445-33C3814DE07A}{1}{B0}", + "bytes": 27 + }, + { + "sha256": "56d4af65701c26df20bd4021eda95b6e830348ce3a746086079fe89285548dc9", + "name": "silence.wav", + "object": "{8FA38498-A15A-08DC-3759-A172FAAA260D}{44}{B0}", + "kind": "MediaFile", + "page": "{95B9BD1C-79B9-0611-2445-33C3814DE07A}{1}{B0}", + "bytes": 16044 + } +] \ No newline at end of file diff --git a/corpus/picture-link/cold/run.json b/corpus/picture-link/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..e384f9aedf79668a780b1b05ae2cfe70d6a8d9e0 --- /dev/null +++ b/corpus/picture-link/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/linklab/candidate", + "expected_pages": -1, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35" + } +} diff --git a/corpus/picture-link/cold/scripts/cold.ps1 b/corpus/picture-link/cold/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/picture-link/cold/scripts/cold.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } +$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' +if ($CloneHost) { + if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { + throw 'The disposable clone hostname does not match this machine.' + } + New-Item "$key\Options\Paths" -Force | Out-Null + New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null +} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or + -not (Test-Path 'C:\one-tests\profile-original-cache')) { + throw 'Park the personal OneNote profile before resetting the test cache.' +} +$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' +$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) +if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } +New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null +if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } +if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } +New-Item "$key\OpenNotebooks" | Out-Null +New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null diff --git a/corpus/picture-link/cold/scripts/read.ps1 b/corpus/picture-link/cold/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a42366b32e8baaf13dae92e9472cae7a071220cc --- /dev/null +++ b/corpus/picture-link/cold/scripts/read.ps1 @@ -0,0 +1,144 @@ +param( + [Parameter(Mandatory=$true)][string]$Root, + [int]$ExpectedPages = -1, + [switch]$UseCurrentCache, + [switch]$Pdf, + [switch]$KeepOpen, + [string]$CloneHost = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +$notebook = Join-Path $root 'notebook' +$output = Join-Path $root 'read' +if (Test-Path $output) { throw 'Choose a new read destination.' } +if ($UseCurrentCache) { + if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { + throw 'Park the personal OneNote profile before reading test notebooks.' + } +} else { + & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost +} +New-Item -ItemType Directory -Path $output | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +$failure = $null +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $process = Get-Process ONENOTE + @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; + powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | + ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 + $sections = @() + foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { + $id = '' + $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) + $sections += $id + } + $deadline = [DateTime]::UtcNow.AddSeconds(300) + $previous = '' + $lastChange = '' + $stableSince = [DateTime]::UtcNow + $settled = $false + do { + $pages = @{} + foreach ($section in $sections) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$xml = $hierarchy + foreach ($node in $xml.SelectNodes('//*[@path]')) { + if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { + throw 'OneNote opened a section outside the copied notebook.' + } + } + foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { + $id = $node.GetAttribute('ID') + $content = '' + $app.GetPageContent($id, [ref]$content, 1, 1) + $pages[$id] = $content + } + } + $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) + if ($signature -ne $previous) { + $lastChange = $previous + $previous = $signature + $stableSince = [DateTime]::UtcNow + } + if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or + ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and + ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { + [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) + $index = 0 + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + $index++ + } + $hierarchy = '' + $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) + throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." + } + $index = 0 + $payloads = @() + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + if ($Pdf) { + $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) + $app.NavigateTo($id, '', $false) + $app.Publish($id, $pdfPath, 3, '') + if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } + } + [xml]$page = $pages[$id] + foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { + $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) + # A paragraph's file is named by its paragraph, one on the page by itself. + $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode } + $payloads += @{ page = $id; object = $owner.GetAttribute('objectID'); + kind = $file.LocalName; name = $file.GetAttribute('preferredName'); + sha256 = $hash; bytes = $bytes.Length } + } + $index++ + } + [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) + $all = '' + $app.GetHierarchy($notebookId, 4, [ref]$all, 1) + [xml]$finalTree = $all + $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) + if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { + throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' + } + [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) + Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." +} catch { + $failure = $_ + [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + throw +} finally { + try { + try { + if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } + if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { + $app.CloseNotebook($notebookId, $false) + } + } catch { + if ($null -eq $failure) { throw } + [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + } + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + if (-not $UseCurrentCache -and -not $CloneHost) { + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + } +} diff --git a/corpus/picture-link/cold/source.json b/corpus/picture-link/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..7fd8f82384e6661feee4a47d4dcf5c1bf00e4c08 --- /dev/null +++ b/corpus/picture-link/cold/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Features.one", + "bytes": 123080, + "sha256": "d1cc5e4deb98245d742488199c676ad1a44edc808a219d9bd9d1856d5b2ccac0", + "mtime_ns": 1790809064276439170 + }, + { + "path": "Open Notebook.onetoc2", + "bytes": 1604, + "sha256": "be341d46f14d6242c0e2933e88941f157d2071df895730d01be03156592a808c", + "mtime_ns": 1790809064276690168 + } +] diff --git a/corpus/picture-link/cold/teardown.json b/corpus/picture-link/cold/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/picture-link/cold/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/picture-link/lab/click-selects.png b/corpus/picture-link/lab/click-selects.png new file mode 100644 index 0000000000000000000000000000000000000000..9d8b011edef2d65056b830ae81f169f62e9ba9b8 Binary files /dev/null and b/corpus/picture-link/lab/click-selects.png differ diff --git a/corpus/picture-link/lab/ctrl-click.png b/corpus/picture-link/lab/ctrl-click.png new file mode 100644 index 0000000000000000000000000000000000000000..b7af962ec0de4d3dbd7c148ef46dfaebeea5ef8d Binary files /dev/null and b/corpus/picture-link/lab/ctrl-click.png differ diff --git a/corpus/picture-link/lab/hover.png b/corpus/picture-link/lab/hover.png new file mode 100644 index 0000000000000000000000000000000000000000..33372975d211a4546fb219370207dc3346fd5278 Binary files /dev/null and b/corpus/picture-link/lab/hover.png differ diff --git a/corpus/printout/README.md b/corpus/printout/README.md index b04d364e2740906d9cf5cd24acf1ad755fb7ad2e..4c67098f9f7418eff887b0c9a3d5b8ca13451095 100644 --- a/corpus/printout/README.md +++ b/corpus/printout/README.md @@ -11,9 +11,13 @@ frames printout pages. `candidate/` is the output of `printouts_show_onenotes_rendering_and_move_as_pictures` in `crates/canvas/tests/printouts.rs`: one printout page moved down, the other deleted and the deletion undone, and text typed before the citation, published as one revision. -Snowbound does not write XPS packages, so the undone page returns as the PNG OneNote -showed of it. `cold/` is a fresh OneNote 2010 read: the moved page is still a printout, -the restored one a picture. `tools/test_printout.py` checks this without a VM. +The undone page is written back as it was read: its XPS package as the PictureContainer, +under the file-data type OneNote gives XPS packages (0x8003A), the PNG beside it as the +WebPictureContainer14, IsPrintout, its page number, and the undocumented properties tying +it to its package, kept as stored; without that type or those properties OneNote reads +the page back but draws nothing there. `cold/` is a fresh OneNote 2010 read (with its PDF +export): both pages are printouts and both draw, the moved one lower down; the restored +page's package is a copy, which OneNote counts as a second (`xpsFileIndex="1"`). `tools/test_printout.py` checks this without a VM. Regenerate with `SNOWBOUND_PRINTOUT_EXPORT` set to a new absolute directory while running the test, then cold-open it with `tools/native_runner.py OUTPUT COLD --expected-pages 2 --collect-notebook --screenshots`. diff --git a/corpus/printout/candidate/Open Notebook.onetoc2 b/corpus/printout/candidate/Open Notebook.onetoc2 index 90da353c9de9888642b5d42f22eb53e74962833d..aa0d13e395fee99241c070b9aa4de239840b1477 100644 Binary files a/corpus/printout/candidate/Open Notebook.onetoc2 and b/corpus/printout/candidate/Open Notebook.onetoc2 differ diff --git a/corpus/printout/candidate/Printouts.one b/corpus/printout/candidate/Printouts.one index d2e66594be0d9f0b2ec85053feb723d819eabd7b..1dc30e24c8fb8bc385378dbfc7a2d978bed53ccf 100644 Binary files a/corpus/printout/candidate/Printouts.one and b/corpus/printout/candidate/Printouts.one differ diff --git a/corpus/printout/cold/commands.jsonl b/corpus/printout/cold/commands.jsonl index 06aa4959935e4e5b9720e0342b9fe633deb3d447..f0a3fed7bc5d9bab6e0c7618328652faf143eacf 100644 --- a/corpus/printout/cold/commands.jsonl +++ b/corpus/printout/cold/commands.jsonl @@ -1,3 +1,3 @@ {"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} -{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-B402FE00 -ExpectedPages 2 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 2 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -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} {"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} diff --git a/corpus/printout/cold/machine.json b/corpus/printout/cold/machine.json index 41d3b5748eadd4f55fb2f0f51ffb9e06b59334b5..788d866dc93520912f84fc593afe1ef247df1c43 100644 --- a/corpus/printout/cold/machine.json +++ b/corpus/printout/cold/machine.json @@ -1 +1 @@ -{"name": "m6-b402fe00", "hostname": "ONE-M6-B402FE00"} +{"name": "m6-02da9a86", "hostname": "ONE-M6-02DA9A86"} diff --git a/corpus/printout/cold/notebook/Open Notebook.onetoc2 b/corpus/printout/cold/notebook/Open Notebook.onetoc2 index e052f7f02ea4094221dca80760b88f7952848e6b..06f6f9ff0096ce4a43d1692fefec85a84c6e2e7a 100644 Binary files a/corpus/printout/cold/notebook/Open Notebook.onetoc2 and b/corpus/printout/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/printout/cold/notebook/Printouts.one b/corpus/printout/cold/notebook/Printouts.one index 8c35ff854862c80e3fc852dfdbcb9bc452ed76d1..1771d7d482b0e1c65b0c8c3bc3ba70c45a037335 100644 Binary files a/corpus/printout/cold/notebook/Printouts.one and b/corpus/printout/cold/notebook/Printouts.one differ diff --git a/corpus/printout/cold/read/environment.json b/corpus/printout/cold/read/environment.json index b60b7a9a6d1208e3110d998f20a286622b5034bc..2fca9bd584b1768eab2fc70c2e870314639b6724 100644 --- a/corpus/printout/cold/read/environment.json +++ b/corpus/printout/cold/read/environment.json @@ -1,7 +1,7 @@ { "powershell": "5.1.14409.1005", "schema": "xs2010", - "hostname": "ONE-M6-B402FE00", + "hostname": "ONE-M6-02DA9A86", "cold": true, "onenote": "14.0.4763.1000" } diff --git a/corpus/printout/cold/read/hierarchy.xml b/corpus/printout/cold/read/hierarchy.xml index c8ca7950adf00d326e81c9763e6b67748cc78a5d..34becafb75b05530eb705dcb923afa61a0fe4fca 100644 --- a/corpus/printout/cold/read/hierarchy.xml +++ b/corpus/printout/cold/read/hierarchy.xml @@ -1,2 +1,2 @@  - + diff --git a/corpus/printout/cold/read/page-000.navigation.json b/corpus/printout/cold/read/page-000.navigation.json index 61f3843fae261a8867978a4dbc42f1463a422ab2..86a0d5f7e25a7ab73646345e7d3471637334f39e 100644 --- a/corpus/printout/cold/read/page-000.navigation.json +++ b/corpus/printout/cold/read/page-000.navigation.json @@ -6,7 +6,7 @@ "h": 600, "error": null, "win": { - "title": "Pasted shape - Microsoft OneNote", + "title": "Printout - Microsoft OneNote", "class": "Framework::CFrame", "dialog": false } diff --git a/corpus/printout/cold/read/page-000.pdf b/corpus/printout/cold/read/page-000.pdf new file mode 100644 index 0000000000000000000000000000000000000000..34af7b58708f24feb76c9db05bcce6c5dc308944 Binary files /dev/null and b/corpus/printout/cold/read/page-000.pdf differ diff --git a/corpus/printout/cold/read/page-000.png b/corpus/printout/cold/read/page-000.png index 713354cb41d1fe4527d207f8f6ae13bb71d379ce..d3cb2874926d3f31b90e675be86f353c04b84ea3 100644 Binary files a/corpus/printout/cold/read/page-000.png and b/corpus/printout/cold/read/page-000.png differ diff --git a/corpus/printout/cold/read/page-000.xml b/corpus/printout/cold/read/page-000.xml index e048f48128f2ab49aad3817203b548ebc69f12cf..d0bb55a39af938921d9a469f018e89b2cf438cc1 100644 --- a/corpus/printout/cold/read/page-000.xml +++ b/corpus/printout/cold/read/page-000.xml @@ -1,74 +1,526 @@  -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 +file://C:\work\printout\inputs\two-pages.docx>]]>iVBORw0KGgoAAAANSUhEUgAAAzAAAAQgCAIAAAC2Gy5ZAAAAAXNSR0IArs4c6QAAAAlwSFlzAAAO +xAAADsQBlSsOGwAANTtJREFUeF7t1sENACAMAzFg/51bMcV93AUiOX3kzsxxBAgQIECAAAECncDr +oiUTIECAAAECBAh8AYPMHxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBP +gAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECA +AAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwA +AQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAAB +AgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI +4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAEC +BAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIE +DDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQI +ECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQ +iAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsL +EE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAg +QIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI +/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECA +AAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAW +MMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8 +AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAAB +AgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMD +BAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIE +CBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAg +iwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQI +ECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQ +MMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAg +QIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBA +IBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywu +QDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECA +AAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg +8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAAB +AgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFY +wCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADx +BAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIE +CBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wP +ECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQ +IEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGD +LC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMg +QIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBA +wCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CA +AAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAA +gVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4 +APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAAB +AgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGD +zA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIE +CBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRi +AYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLE +EyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQ +IEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/ +QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBA +gACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUM +srgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+A +AAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAA +AYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AAB +AgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAEC +BGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMji +AsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIE +CBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQM +Mj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQ +IECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCI +BQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQ +T4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBA +gAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8 +AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAA +AQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYw +yOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwB +AgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAEC +BAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wME +CBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQI +EIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCL +CxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQ +IECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAw +yPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBA +gAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAg +FjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5A +PAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAA +AQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDz +AwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAEC +BAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjA +IIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEE +CBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQI +EDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8Q +IECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAg +QCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMs +LkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBA +gAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDA +IPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAA +AQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACB +WMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA +8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAEC +BAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPM +DxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQI +ECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIB +gywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQT +IECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAg +QMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9A +gAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECA +AIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyy +uADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AA +AQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAAB +g8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAEC +BAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIE +YgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOIC +xBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQI +ECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwy +P0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAg +QIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgF +DLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBP +gAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECA +AAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwA +AQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAAB +AgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI +4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAEC +BAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIE +DDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQI +ECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQ +iAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsL +EE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAg +QIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI +/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECA +AAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAW +MMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8 +AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAAB +AgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMD +BAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIE +CBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAg +iwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQI +ECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQ +MMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAg +QIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBA +IBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywu +QDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECA +AAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg +8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAAB +AgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFY +wCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADx +BAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIE +CBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wP +ECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQ +IEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGD +LC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMg +QIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBA +wCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CA +AAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAA +gVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4 +APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAAB +AgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGD +zA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIE +CBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRi +AYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLE +EyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQ +IEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/ +QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBA +gACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUM +srgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+A +AAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAA +AYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AAB +AgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAEC +BGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMji +AsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIE +CBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQM +Mj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQ +IECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCI +BQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQ +T4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBA +gAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8 +AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAA +AQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYw +yOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwB +AgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAEC +BAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wME +CBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQI +EIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCL +CxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQ +IECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAw +yPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBA +gAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAg +FjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5A +PAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAA +AQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDz +AwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAEC +BAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjA +IIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEE +CBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQI +EDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8Q +IECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAg +QCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMs +LkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBA +gAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDA +IPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAA +AQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACB +WMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA +8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAEC +BAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPM +DxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQI +ECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIB +gywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQT +IECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAg +QMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9A +gAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECA +AIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyy +uADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AA +AQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAAB +g8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAEC +BAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIE +YgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOIC +xBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQI +ECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwy +P0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAg +QIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgF +DLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBP +gAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECA +AAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwA +AQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAAB +AgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI +4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAEC +BAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIE +DDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQI +ECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQ +iAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsL +EE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAg +QIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI +/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECA +AAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAW +MMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8 +AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAAB +AgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMD +BAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIE +CBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAg +iwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQI +ECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQ +MMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAg +QIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBA +IBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywu +QDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECA +AAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg +8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAAB +AgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFY +wCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADx +BAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIE +CBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wP +ECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQ +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 +b7w9fTfueZOd73/e+no85cQ4n3a+6vxw83zPK8yV7fpH5xHPfP71T7///e9/8IMffLyR9vS5j5BP +b/zDH/7wz3me+eaP9+SennzFOH+ueM9QPP3M/8l3H1+8dZ9BgAABAgT+JwXOOyX+vLDAxz/+8Ve8 +4hV//vOfn37aq171qp/85CfnI+fj5++//e1vz9/PKvrqV796fdr54PnPM27O1/7pT386fz+fc/5+ +febXvva1Z/7++Krr216f/NGPfvSZbOeD58/1wfNNTrbHJ5//en38RHo89Ap/BTh/vz7/+vv1oGdO +uJ57Hfs457rxvCyfCfP0m5/vdgU7u+rxlEfC88FH7Mejrw9e2R5f/swj/FcCBAgQILAg4B2yF1+/ +3/72t7/yla+88Y1vfPp+0nnb7AMf+MD54te97nVf+tKXfv7zn5+3gn7wgx987GMfe/pe2je/+c3z +487Xv/7154PnZ4gf+chHzmden/DlL3/5+qni+973vvPrWefLz9tUj297/tF56POG+/Wvf319/Hzh +eaPregvqfIdPf/rTz/v5jwCf+tSnzuf/8+ecEfahD33oCnOF/M1vfvP0nHPjj370oxf+5u94xzvO +J5yNdX5gesSuT37zm998PfGsrg9/+MPXB9/73vde77cdioNw4VyY3iR7XmQfJECAAIGXvYBB9pIq +Plvn/FbWWTbvfOc7z2g48+IMoMdP3z73uc+d8f7Xv/71n38H/0y0pw84W+R85jOPfO1rX3u9e3R9 +25cU6F8/6Xzh+c9//ob/1nc4P3h93u9/5tEPf/jDxznXhHrec14g2xltj3/6+Onk+ano9cSzus4j +rk/4whe+cIba+csvfvGLz3/+84/nvvTDfSYBAgQIEHiZCRhk/45Cr3dxzmy63tS5fs3/+vOZz3zm +Na95zT9/r8fbQtc/+uUvf/kCz7u+7Uv/czbTS//kF/jMN73pTSfn03POpc97zgt8k8fvip1fC/vs +Zz97fbczYa8vOT/0fEzY827cJfme97znvEP29Lkv/C8i/Jcc65sQIECAAIGggEH2IqWcefH4nf3z +I8Xz2W9/+9uv97Se+Z+NuN4i+u53v3t9x/OL8+c/z1ti512i6yPny7/3ve+9//3v/7ce+fTbnk8+ +b7w972eed+muj3/xi188Q+rfO+Me3/Pxe/rnnbPz9tUzPy58es4ZW2dFPW+Yc901xa7Dz6I63/Yx +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 diff --git a/corpus/printout/cold/read/page-001.navigation.json b/corpus/printout/cold/read/page-001.navigation.json index 86a0d5f7e25a7ab73646345e7d3471637334f39e..61f3843fae261a8867978a4dbc42f1463a422ab2 100644 --- a/corpus/printout/cold/read/page-001.navigation.json +++ b/corpus/printout/cold/read/page-001.navigation.json @@ -6,7 +6,7 @@ "h": 600, "error": null, "win": { - "title": "Printout - Microsoft OneNote", + "title": "Pasted shape - Microsoft OneNote", "class": "Framework::CFrame", "dialog": false } diff --git a/corpus/printout/cold/read/page-001.pdf b/corpus/printout/cold/read/page-001.pdf new file mode 100644 index 0000000000000000000000000000000000000000..42949e1e0e1fad0e232632f9a995c839bd46ed76 Binary files /dev/null and b/corpus/printout/cold/read/page-001.pdf differ diff --git a/corpus/printout/cold/read/page-001.png b/corpus/printout/cold/read/page-001.png index 5865baf7c33b8ee492af2900abb11b5dd99a4a30..65c62a11e38681ee5382adcbb738a8b02b620fae 100644 Binary files a/corpus/printout/cold/read/page-001.png and b/corpus/printout/cold/read/page-001.png differ diff --git a/corpus/printout/cold/read/page-001.xml b/corpus/printout/cold/read/page-001.xml index fcdc47760913a58a9bf0c0d30b661a0b1fd0d0af..69cf8a35bf8af36f728f9b46fa61d554d65a6108 100644 --- a/corpus/printout/cold/read/page-001.xml +++ b/corpus/printout/cold/read/page-001.xml @@ -1,526 +1,74 @@  -file://C:\work\printout\inputs\two-pages.docx>]]>iVBORw0KGgoAAAANSUhEUgAAAzAAAAQgCAIAAAC2Gy5ZAAAAAXNSR0IArs4c6QAAAAlwSFlzAAAO -xAAADsQBlSsOGwAANTtJREFUeF7t1sENACAMAzFg/51bMcV93AUiOX3kzsxxBAgQIECAAAECncDr -oiUTIECAAAECBAh8AYPMHxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBP -gAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECA -AAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwA -AQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAAB -AgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI -4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAEC -BAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIE -DDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQI -ECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQ -iAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsL -EE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAg -QIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI -/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECA -AAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAW -MMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8 -AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAAB -AgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMD -BAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIE -CBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAg -iwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQI -ECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQ -MMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAg -QIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBA -IBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywu -QDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECA -AAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg -8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAAB -AgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFY -wCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADx -BAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIE -CBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wP -ECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQ -IEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGD -LC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMg -QIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBA -wCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CA -AAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAA -gVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4 -APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAAB -AgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGD -zA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIE -CBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRi -AYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLE -EyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQ -IEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/ -QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBA -gACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUM -srgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+A -AAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAA -AYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AAB -AgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAEC -BGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMji -AsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIE -CBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQM -Mj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQ -IECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCI -BQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQ -T4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBA -gAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8 -AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAA -AQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYw -yOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwB -AgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAEC -BAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wME -CBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQI -EIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCL -CxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQ -IECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAw -yPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBA -gAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAg -FjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5A -PAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAA -AQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDz -AwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAEC -BAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjA -IIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEE -CBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQI -EDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8Q -IECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAg -QCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMs -LkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBA -gAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDA -IPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAA -AQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACB -WMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA -8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAEC -BAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPM -DxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQI -ECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIB -gywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQT -IECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAg -QMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9A -gAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECA -AIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyy -uADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AA -AQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAAB -g8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAEC -BAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIE -YgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOIC -xBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQI -ECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwy -P0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAg -QIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgF -DLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBP -gAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECA -AAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwA -AQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAAB -AgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI -4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAEC -BAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIE -DDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQI -ECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQ -iAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsL -EE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAg -QIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI -/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECA -AAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAW -MMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8 -AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAAB -AgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMD -BAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIE -CBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAg -iwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQI -ECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQ -MMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAg -QIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBA -IBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywu -QDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECA -AAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg -8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAAB -AgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFY -wCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADx -BAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIE -CBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wP -ECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQ -IEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGD -LC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMg -QIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBA -wCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CA -AAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAA -gVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4 -APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAAB -AgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGD -zA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIE -CBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRi -AYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLE -EyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQ -IEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/ -QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBA -gACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUM -srgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+A -AAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAA -AYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AAB -AgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAEC -BGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMji -AsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIE -CBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQM -Mj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQ -IECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCI -BQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQ -T4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBA -gAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8 -AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAA -AQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYw -yOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwB -AgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAEC -BAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wME -CBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQI -EIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCL -CxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQ -IECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAw -yPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBA -gAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAg -FjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5A -PAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAA -AQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDz -AwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAEC -BAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjA -IIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEE -CBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQI -EDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8Q -IECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAg -QCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMs -LkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBA -gAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDA -IPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAA -AQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACB -WMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA -8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAEC -BAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPM -DxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQI -ECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIB -gywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQT -IECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAg -QMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9A -gAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECA -AIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyy -uADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AA -AQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAAB -g8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAEC -BAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIE -YgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOIC -xBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQI -ECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwy -P0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAg -QIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgF -DLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBP -gAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECA -AAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwA -AQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAAB -AgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI -4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAEC -BAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIE -DDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQI -ECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQ -iAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsL -EE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAg -QIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI -/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECA -AAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAW -MMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8 -AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAAB -AgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMD -BAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIE -CBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAg -iwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQI -ECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQ -MMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAABAgQIEIgFDLK4APEECBAgQIAAAYPMDxAg -QIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFYwCCLCxBPgAABAgQIEDDI/AABAgQIECBA -IBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADxBAgQIECAAAGDzA8QIECAAAECBGIBgywu -QDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIECBAwyPwAAQIECBAgQCAWMMjiAsQTIECA -AAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wPECBAgAABAgRiAYMsLkA8AQIECBAgQMAg -8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQIEAgFjDI4gLEEyBAgAABAgQMMj9AgAAB -AgQIEIgFDLK4APEECBAgQIAAAYPMDxAgQIAAAQIEYgGDLC5APAECBAgQIEDAIPMDBAgQIECAAIFY -wCCLCxBPgAABAgQIEDDI/AABAgQIECBAIBYwyOICxBMgQIAAAQIEDDI/QIAAAQIECBCIBQyyuADx -BAgQIECAAAGDzA8QIECAAAECBGIBgywuQDwBAgQIECBAwCDzAwQIECBAgACBWMAgiwsQT4AAAQIE -CBAwyPwAAQIECBAgQCAWMMjiAsQTIECAAAECBAwyP0CAAAECBAgQiAUMsrgA8QQIECBAgAABg8wP -ECBAgAABAgRiAYMsLkA8AQIECBAgQMAg8wMECBAgQIAAgVjAIIsLEE+AAAECBAgQMMj8AAECBAgQ -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 -b7w9fTfueZOd73/e+no85cQ4n3a+6vxw83zPK8yV7fpH5xHPfP71T7///e9/8IMffLyR9vS5j5BP -b/zDH/7wz3me+eaP9+SennzFOH+ueM9QPP3M/8l3H1+8dZ9BgAABAgT+JwXOOyX+vLDAxz/+8Ve8 -4hV//vOfn37aq171qp/85CfnI+fj5++//e1vz9/PKvrqV796fdr54PnPM27O1/7pT386fz+fc/5+ -febXvva1Z/7++Krr216f/NGPfvSZbOeD58/1wfNNTrbHJ5//en38RHo89Ap/BTh/vz7/+vv1oGdO -uJ57Hfs457rxvCyfCfP0m5/vdgU7u+rxlEfC88FH7Mejrw9e2R5f/swj/FcCBAgQILAg4B2yF1+/ -3/72t7/yla+88Y1vfPp+0nnb7AMf+MD54te97nVf+tKXfv7zn5+3gn7wgx987GMfe/pe2je/+c3z -487Xv/7154PnZ4gf+chHzmden/DlL3/5+qni+973vvPrWefLz9tUj297/tF56POG+/Wvf319/Hzh -eaPregvqfIdPf/rTz/v5jwCf+tSnzuf/8+ecEfahD33oCnOF/M1vfvP0nHPjj370oxf+5u94xzvO -J5yNdX5gesSuT37zm998PfGsrg9/+MPXB9/73vde77cdioNw4VyY3iR7XmQfJECAAIGXvYBB9pIq -Plvn/FbWWTbvfOc7z2g48+IMoMdP3z73uc+d8f7Xv/71n38H/0y0pw84W+R85jOPfO1rX3u9e3R9 -25cU6F8/6Xzh+c9//ob/1nc4P3h93u9/5tEPf/jDxznXhHrec14g2xltj3/6+Onk+ano9cSzus4j -rk/4whe+cIba+csvfvGLz3/+84/nvvTDfSYBAgQIEHiZCRhk/45Cr3dxzmy63tS5fs3/+vOZz3zm -Na95zT9/r8fbQtc/+uUvf/kCz7u+7Uv/czbTS//kF/jMN73pTSfn03POpc97zgt8k8fvip1fC/vs -Zz97fbczYa8vOT/0fEzY827cJfme97znvEP29Lkv/C8i/Jcc65sQIECAAIGggEH2IqWcefH4nf3z -I8Xz2W9/+9uv97Se+Z+NuN4i+u53v3t9x/OL8+c/z1ti512i6yPny7/3ve+9//3v/7ce+fTbnk8+ -b7w972eed+muj3/xi188Q+rfO+Me3/Pxe/rnnbPz9tUzPy58es4ZW2dFPW+Yc901xa7Dz6I63/Yx -Q7/1rW9dX3U+eH5L7Npe3/nOd64PnvfJzjtkjyX3+P6H7um/J/EiDfnHBAgQIEDgZSCw8Ity/8kb -z++2X79K//g1/Mcvwj8+/vQX/K8PPn5l/vr9/We+/Hzw8ev/T38H//qd+vPn+s335/2l/vOFj294 -nXa+6rwUH2c+80v9jyRPP+3xoOtfMnj816c5r9/lf3zk6SOuZ52QJ8zjc65fzz9/Hh85n/D4qscH -zzd8/PsH1+/yP/5c/zLB+a8PnP9kd76cAAECBAj8fyHwLy/9N5BeBuvzZXDC+Y2r80v9kR/tnal6 -/kWB6+ePL/zn/A9knP8Jt/OD3evTzntgv/rVryJXvFh2/5wAAQIECPy3C/iR5X87sQccgfMvN7zl -LW+5KM7PKP9j/w8EJAkQIECAwMtVwCB7uTbbuuuPf/zj+S2061+oPP8DIp03+VpM0hAgQIDAqoAf -Wa42724CBAgQIEAgI+AdskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGD -bLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBb -bd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFab -dzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebd -TYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcT -IECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQI -ECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIE -CBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAEC -BAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAAB -AhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBA -RsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJAR -MMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQM -skwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMs -U4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtU -IQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUI -QoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQ -IECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQI -ECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIE -CBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAEC -BAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAAB -AqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDA -qoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAq -YJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoY -ZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZ -avPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbba -vLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbv -bgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbub -AAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZA -gAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAg -QIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQ -IEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQI -EMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIE -MgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGM -gEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNg -kGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhk -mSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmm -CkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlC -EAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCE -AAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFA -gAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAg -QIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQ -IEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQI -EFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIE -VgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFV -AYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXA -IFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDI -Vpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV -5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5 -dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7d -BAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcB -AgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAA -AQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECA -AAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBA -gEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAg -kBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhk -BAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkB -gyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAg -y1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgy -VQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwV -ghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4Ug -BAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgB -AgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAA -AQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECA -AAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBA -gMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAg -sCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAis -Chhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsC -Btlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBB -ttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCt -Nu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvN -u5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPu -JkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJ -ECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIE -CBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAEC -BAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAAB -AgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAA -gYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAg -I2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgI -GGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIG -WaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGW -qUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWq -EIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoE -IUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEI -ECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIE -CBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAEC -BAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAAB -AgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAA -gVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBg -VcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgV -MMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUM -stXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNs -tXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt -3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3 -NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1N -gAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMg -QIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQ -IECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQI -ECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIE -CGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAEC -GQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBG -wCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEw -yDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyy -TBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxT -hSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1Qh -CAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhC -gAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAg -QIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQ -IECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQI -ECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIE -CKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAEC -qwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCq -gEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpg -kK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhk -q827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq -8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8 -uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9u -AgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sA -AQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCA -AAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBA -gACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAg -QCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQ -yAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQy -AgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyA -QZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQ -ZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZ -KgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYK -QQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQ -AgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQA -AQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCA -AAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBA -gACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAg -QGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQ -WBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRW -BQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUB -g2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAg -W23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhW -m3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm -3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3 -EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0E -CBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwEC -BAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAAB -AgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAA -AQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECA -QEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQ -ETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQE -DLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGD -LFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDL -VCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJV -CEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWC -ECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAE -CBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAEC -BAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAAB -AgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAA -AQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECA -wKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCw -KmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwK -GGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG -2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG2 -2ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02 -724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827 -mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4m -QIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQ -IECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQI -ECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIE -CBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAEC -BDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACB -jIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAj -YJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgY -ZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZ -pgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZap -QhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQ -hAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQh -QIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQ -IECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQI -ECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIE -CBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAEC -BFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACB -VQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBV -wCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUw -yFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy -1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1 -eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e -3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3 -AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2A -AAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBA -gAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAg -QIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQ -IJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQI -ZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZ -AYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbA -IMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDI -MlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJM -FYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOF -IAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEI -AQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKA -AAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBA -gAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAg -QIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQ -ILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQI -rAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKr -AgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqA -QbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQ -rTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSr -zbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz -7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7 -CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724C -BAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwAB -AgQIEMgIGGSZKgQhQIAAAQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAA -AQIEMgIGWaYKQQgQIECAAIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECA -AIGMgEGWqUIQAgQIECBAYFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBA -ICNgkGWqEIQAAQIECBBYFTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDI -CBhkmSoEIUCAAAECBFYFDLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDIC -BlmmCkEIECBAgACBVQGDbLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBB -lqlCEAIECBAgQGBVwCBbbd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBl -qhCEAAECBAgQWBUwyFabdzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkq -BCFAgAABAgRWBQyy1ebdTYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpB -CBAgQIAAgVUBg2y1eXcTIECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhAC -BAgQIEBgVcAgW23e3QQIECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAAB -AgQIEFgVMMhWm3c3AQIECBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgIGGSZKgQhQIAA -AQIEVgUMstXm3U2AAAECBAhkBAyyTBWCECBAgAABAqsCBtlq8+4mQIAAAQIEMgIGWaYKQQgQIECA -AIFVAYNstXl3EyBAgAABAhkBgyxThSAECBAgQIDAqoBBttq8uwkQIECAAIGMgEGWqUIQAgQIECBA -YFXAIFtt3t0ECBAgQIBARsAgy1QhCAECBAgQILAqYJCtNu9uAgQIECBAICNgkGWqEIQAAQIECBBY -FTDIVpt3NwECBAgQIJARMMgyVQhCgAABAgQIrAoYZKvNu5sAAQIECBDICBhkmSoEIUCAAAECBFYF -DLLV5t1NgAABAgQIZAQMskwVghAgQIAAAQKrAgbZavPuJkCAAAECBDICBlmmCkEIECBAgACBVQGD -bLV5dxMgQIAAAQIZAYMsU4UgBAgQIECAwKqAQbbavLsJECBAgACBjIBBlqlCEAIECBAgQGBVwCBb -bd7dBAgQIECAQEbAIMtUIQgBAgQIECCwKmCQrTbvbgIECBAgQCAjYJBlqhCEAAECBAgQWBUwyFab -dzcBAgQIECCQETDIMlUIQoAAAQIECKwKGGSrzbubAAECBAgQyAgYZJkqBCFAgAABAgRWBQyy1ebd -TYAAAQIECGQEDLJMFYIQIECAAAECqwIG2Wrz7iZAgAABAgQyAgZZpgpBCBAgQIAAgVUBg2y1eXcT -IECAAAECGQGDLFOFIAQIECBAgMCqgEG22ry7CRAgQIAAgYyAQZapQhACBAgQIEBgVcAgW23e3QQI -ECBAgEBGwCDLVCEIAQIECBAgsCpgkK02724CBAgQIEAgI2CQZaoQhAABAgQIEFgVMMhWm3c3AQIE -CBAgkBEwyDJVCEKAAAECBAisChhkq827mwABAgQIEMgI/F9mGeSSDxi9rAAAAABJRU5ErkJggg== +iVBORw0KGgoAAAANSUhEUgAAAKMAAABuCAIAAADEeDS9AAAAAXNSR0IArs4c6QAAD+tJREFUeF7t +XX1QVNcVT1ahKgSKgLhM3TVSCBT8AlvwAwgaTIJJBpgkpPmwzaBt1T+EzqgzHeNkEtuppDPqH+pk +otNUTao2FcavTMWPRFKzpATUYFCQhF0nriBgVJQUtOlvOY/ry+6y+/btvt33du+ObLK79+Pc83vn +3HPPvfecB7///vsHguU1MHi3+ZIVozFbe3pu3Mb/DA7ea26/InF8kePGJBviUTgjKTE8bLQxcXxs +dITEuuov9qBGke67899WcxdQtFh7rN03zd/09Ny8owS7I8eGp0xOAOQG/fjMVIN24dcM0tbuG40t +ly3WXqDb2tHZ1z+gBK5S2gwbPXpqsj7FkGDQx0D6k40TpNQKeBlVI2229ja2WJouXG780iJFZB8a +6E++btPeGdfMYf+7q++7jn/SWdwXPqZ1fCLKn48zDIwKs0bEWCNj3FaH0KcnJ0LcgXpm2iS35QNV +QHVIY3499Xmb6YuOxi/NrgU3s/Mr440u482u5F4r3mP7bynExMaEpJ6xkeboCU0JSeao+J6xD7no +CGBnT304PyvZqB+vED3ymlUL0lDOAPhY/cXmS84NqPB7dzM729O7LSm9V4w3rwFjeQP2vlZf+NjW +mMTmeMOlmMTGhCkjAY95vTAntTAnTSWQBxhp6OdaU0ut6QImYEcMoHszui3p3ebMq18lX5dqQnuP +pUctQMND6JsSpuDdqbbXx0bnz0ounj89sJAHDGkAfPiT5vpzHY5szb98PvfyeUiwR7OsR/AoVBjy +DSmvm5Tx8aT0gVGj7XrJ+GliccF0CDqWcAoR4KJZfyONabj6xJma42ccLSwCGO+RA/3+Z4RvewTM +psRHTk1Kx79b4WPFjcOCK8z5WdkTWX4Wcf8h3Wbu2nW4Horajqc5Vy4+1nE2OAB2+rhAqx9Jyqqd +PMNOyiHiryz6RV5Wsm8fspFa8wfSwHh7zWkYXGIiYCovam8objNpTkXLAwZ2XO3k6XtT58GGF7cA +r9ySkrl+wFtZpJ1iDCEuam8o7Dgrj2VarwWtfiRpFlAXDwRrs/LiuYoux5VCGkb11n2n7OQYKnpZ +04cBXCCp5ymB7bY7vWB/So5YpQPpZc/lQasrQafvkcY2w85D9TsPfjZ49y6jGBiXn61V7UpJCc5K +adMp3iUFM5aX5UWO+5GUFqSX8THSpnNfV/211tpzg2MsHQNHvGGfLy/LL5k/Q3ojbkv6DGk4uTa8 +e1S8PobTY+V/DuDdLRG8ADgAvN/KLsVCnHEDanzliwW+Uua+QRpL5I27TzJ1jZ0GzMclrSYOoacc +gL22adbTYvt88VPZy57P87Qdx/LeIo194qp3j4pXyVg7VTQcDALvh/fMldcCbLQ9abk7phUyYw1i +/cbyp/Rx0fIapFpeIY1F1NqtB5nLGkb12tP7uLr2Bg9WFy70quxSiDh9g5l79asLsV8iu3H5SNtp +bIjy6vr92HGSTQqv6MiBnRkF22Y+yb5flJu++tcL5bnNZSK9/p0jh+vOEwWYlVfV7w9ZT4jSD2hz +nGFd7ktslww+ta1/+KWMNZjHSGO5vHpzNbOxoavfqHsvRDyaSoM6UvvwpK6f8zwzy7HzvWnVs55O +254hDfur8i8fsMMCsK6hsQM1/lDrd2/avE2znqFRx0aN27jqOY+OsHmANFbMFW99wOyv8nO1S87W +hhq7AzteeMvXzykjmxw22oaKUumucqlIA+YVf9zLnF+wsWGCBXbYodk7rPF1uS/SnjdOqVZVFOdM +e1gKKyQhjeMDS19/j2CGdf1m3Xt5lwVzTEofvIxvOdAWk1i5oJzOr0GyYaBJUePukcbcvPxPf2+z +XCOYN3z0N2w7+pZ03pqnHIApvqLwd2SQA+ztr7/i9gSLznUfsLTXbK7mMHuKhNLlsdjZeGIH1rfo +CGelK6s+wPTqulM3SGNBhZsT1ATMbC7NSkMovX14JLccfZvAxsQKKwra10V1V0hvr/43WzcDZm6C +SYfBPyWx3/9G3fvklwTYb77zoRyksWjGaQKqubj5JN+Y8g94nvYCLQvLiWrhhM/huuaRWnAu05ie +1205RLuQ8IJhC9JTCnh5v3EAYL/Q8gl1twHHQEaYsJ0jjY1IWlNhGoCz029E847kcWBZ05GUoTsu +EM51Ww85bcQJ0tAAbPdiZcNB7tOWx31/1sJUzSZsTLswsBx7t0caenvb3o+pHEwwboX5EzBv+oIp +vrzpCLUAA4tCQohf9khj15nu0dAJIW/65nX9zIGylk+YDt+x/7QrpCHQuw7WUwlsYCh3I9nPLAid +7laZhK3F6pNncCJoRJnetq+OBBoY82WVFp8PLJRwtJ4oxw0p50hDs//z+Bn6jZ8T0iLMgjIe3kq2 +hZY49zUbyP15GiY3LaCh6/lWlXaRhuOMiXX1yfuX3+4jXXNc+Pbl5pPaHSen3GZjDYv1p2e/Zka4 +gDS2MZirhD0RnGsa5QDEms5iQ0kfM7XQKASka04KM/Si9s/5SV6NAiwmG/eW6eP+E4KqtiENAf+o +4RL9wEoEwWhDeQhweZHE2mL1DYWDsiGN6G7MFuMXX4Pj+QDMzL95vN52RsiG9JftV2l4CCgTHOPk +owAH2LGRVkungDSLlptxjd+ADZ6HhKnnL9qs8H7q8HexQ3Cb8ctzwYPzAw+wsKmYms1XenX4Y5M0 +vwobTEhjLLThYTPFLlh0+KMPiNgYZOPkw5nZ2U5MaLN06W4NHyiceNtJ5E7OLE1zAFGSif7uG7d1 +vd8Koe4jB77T9Kg48Y4coDPCeN2+M6Dr6xcA5rvRwfesRA4KSPdcv61jHnBujgUf0uwM4K07/bru +b4UDR7HfKRXzPvg4qKERsRs9usGBexqim5PqKQfYfpVOPyGKKveF/SAMtact8vLq5ABdvsVNa11Y +2Cgi0S7guDrp5lR5xAEESKHycdEROvzRBykZgzzqhhcOOAduhY8hGiIjwnWx0ZGC9h7+NuD0cQJ8 +xQFrhJDJ6aGIMfe199Xhb33VDW8n4BxArjeiQR8XpUsxCMkCKP0bfwUTB5DMj4Zj0Mfq0ocjxCOW +nWPin2AadgiOpTneSKNGJkbM0xFI3YUPgNkcpY1knCGImYwhA1BIL1VMMU6wnS7KSNbT58aJU2S0 +yKuokwPNcUZS0gg1ijijNqTTkwSkkaFTnURzqmRwgMktBBrVbUjnTBNE+dPER9haW0bTvIqqOHDM +KCRqmplq0+E2pBG0DAJOU7VdHidVkc6Jkc4BJN6jFA9DyRVTBaTxH5bGBUm7pDfHS6qWA8ivSLQh +hyZFfhdu6yDsP5zg+AxrzS4dn2oHwwkbiQNDmRVn0K8l8wUdLiAN22zhbCHlQ01yDmeipjlwOCmL +Wd0seOz9W7VF86bS8JCCj4u1dpEGxrvSC4j+RbkZbCD3kUZM8Oxpk/EDioqzfGh3zKFJ+baZRbQn +DYeYOIveD2IXrXgun7iDlA8w3kKTU5oeNTCGSqYhVLxcIM7C8wOkodORPpPKbf7505oec2gSv3nW +M2yGtstpbR+PrLx0DhnhrTGJSPERmvzS6Kihhpk75LWlRXajsEcaGx5LSudQoa0zi7hpphXUKdMS +UQtXiWO+BidxQ194PIvyo0IPrMlfzLcyNQE28p7S+TAkWFr50nxHmp0gjWl87dInSYdDpmHLaWKo +oUwklPbhYefm2t8UQTFLQhqF4Alf82ohld6TNu+UKClyKDNUnWOnxNVEGwzqkZIqOZFpqoNFN3nG +8Xot9yVk7lHnOEOcKkzPv19QTie4kQURK6uRGDIi0qiAtKjsOAoSNPFjwmp7qmBCAReskkAYZtvX +lj7pIo2tK6ThDN+4+llse6EhqIjK+eV891o9YNvs5Ud/xc4PIRke2dFyZJombGTKZNYZniBuiqsE +bCS4ZHnIYUG7zXnoSqZpSHhS8LywPc3KBUu4ZAcWbAgbls7MSbLs+TzxToZMmaZqeF6YKd6YMGXJ +Eyv4nB0osCFmUNpsTfXC47MWP5UthRj3eS1ZKwgLvX449xYCKGw8voMHJJTCYh+WAcwrFv6WTDC8 +sKZaPbwYdtuLB0ijLQQHXzucR4tnM3XLXN8WEGctRcvlJXOWlMyV3oVnSKNdhButfOsfyJpJffB8 +49J57U1JcdpptAMTTMrcLO7RY6RRGak81myqYVnHMzu/QvY0HhDHGyBd1B06GFIETyWV8Si7uLdI +oz5S6iHXGoUTxmsoVd77PJOtz8HGvgM2mdiOInYv/lxR4nrdPBINcmSatbVt36mdh4SsS/gSiU6h +zHkgeF/hvTOjYMe0QubAwMkCeMHgzpLXvldIo0ukdVi35QDlXsILmdgqGg5y4ZYHBquFXYaqnFLm +/4LGrny5QHwoTEb73iKNLpHgeu3WAyxTNb4p7Di7suEAn7ll4IF11K70RyHNrC72LaoqSuCslNGa +D+Zpx16x2t60+zizyTFzQ5Mj/56X9IVUdRjYcH7RyU56wSuC1ZSLfQvp/PGBTLPOEK5w276PWZ5b +fI+ow8jow7NvucUDR8B2TC+E/5GVxKFs7CV6L8qsQV8iTY1i5t7w7lEk+mB9AG/k4IJKdzvgECzg +iDEM7GVl+Z4ul92yzvdIU5ewybfvP00x4umFGJbQ5zzNMWOII8b4CQ7O5WV5sg1sF3grhTS6hDLf +fegz5Mq0w7u4zVTaagrlgLTYhqpJmS3W1WDXotz08pK5+jhbJBIlXgoiTeQ6xRtr7sKOM8WtppDK +/AHHNW43YhtKbHP5AWMCQnGkXeCNn4A08AbqQexvgesDt59wMZ0dHGAiq7Qci3WDn5CmLrHyrjW1 +7PnX52J7Dd8D5tlXLiJbF1JqBo1WJ4DrJmXg3e6gDk7nFS+YDpvL6XFdJVS3/2Tajno4zJFJ8+in +F8VTOJWBfw2rMvzTqOPFBcAYHW6zlhbMsLsxpRC0ds36Vabt+iYRrz5xps1yzXG0cKxilwzZYfCu +ctSHIn8ZESsIwZ/s7CwaF4S4cHZq8fzpyhlcbh+XQCLNiMPOGM44nGq8hLW4U4oZ6pjXWcYBt2NT +tADclvBLNyVMaZyYxBzUdj3CkYkz84gs4kMHiOxBqQJpRj0MdUB+rP7CSJDTpA68gb3xZheyBMEt +44epnaTWHB1viZqAAKttMXoX4dCxq/hY9iNQ0QGUYMcHQl1IM/qQgxFgI+Fm04XLLlCn8tDtxps2 +/Z/SeyVisN/2KAwl6MSj4KnaxyYSIOwZG0lbwhSKzxwVb7cucuQjwnxlpRlnpv4kM82ghN9Dtiiz +iipF2m5gABu5+oB6a0cn20TxfvBetgDXdIohQc3oigeoDaTFFMOOazV3tVk6r3bfQr5dv2EPnYwo +2Zh6M1MN+vgoVWlmKY+s9pB2HBVUffMlW1o/s7UHM/3g4D3Ks2z+pocdkZDCC2z4Tx0KlmucGDv+ +x+OwV4hgybaPieP9ufCVQqqMMv8HQ/kTlxt5peMAAAAASUVORK5CYII= diff --git a/corpus/printout/cold/read/payloads.json b/corpus/printout/cold/read/payloads.json index 1973a2e9f9b9a482fd4b86897627e4103866cb04..c967dc0801ed642802fdd2dd7352e507d25f30d5 100644 --- a/corpus/printout/cold/read/payloads.json +++ b/corpus/printout/cold/read/payloads.json @@ -2,9 +2,9 @@ { "sha256": "e15f2722a8d11f9e68ac5eff54320f7c4ef5540fb6eb858223e31b983fa1bed7", "name": "two-pages.docx", - "object": "{DB7C4F71-B073-0121-2D07-84DA732F3A82}{23}{B0}", + "object": "{5B8B92BB-9FEB-06D8-085F-BB1AEF8C3FD6}{23}{B0}", "kind": "InsertedFile", - "page": "{DC91B969-8F8E-0E38-22C6-17772EFD8803}{1}{B0}", + "page": "{5C6664A3-A016-09C1-079E-28B7B25E8D57}{1}{B0}", "bytes": 12589 } ] \ No newline at end of file diff --git a/corpus/printout/cold/run.json b/corpus/printout/cold/run.json index 569608b03b8438b13f9f79044d5a6ea8191630d5..c95beb285e86d3609dfe66bbe82f5522e0c7f55e 100644 --- a/corpus/printout/cold/run.json +++ b/corpus/printout/cold/run.json @@ -1,5 +1,5 @@ { - "notebook": "/private/tmp/unsup-lab/printout-candidate", + "notebook": "/private/tmp/printlab/candidate", "expected_pages": 2, "author": null, "author_timeout_seconds": 600, diff --git a/corpus/printout/cold/source.json b/corpus/printout/cold/source.json index 62181874431e571489e154622f1c1b3ac870956a..1d663d3c9680c7878cbdf7c6d5a7cc295ba309e0 100644 --- a/corpus/printout/cold/source.json +++ b/corpus/printout/cold/source.json @@ -2,13 +2,13 @@ { "path": "Open Notebook.onetoc2", "bytes": 1612, - "sha256": "29bbfd869154aea1720f98cf7c7cc40387313b00c8dfdb61d159f968312367b4", - "mtime_ns": 1790744275764608328 + "sha256": "fe8a117e418bb9aea7565bd92f01e03f87d763ba60bb5df24ab85b428dbd4f08", + "mtime_ns": 1790820802979688144 }, { "path": "Printouts.one", - "bytes": 268208, - "sha256": "5b48da81b1b374da9af23b4dd664b01b100c34be71aef721907f4365afe5577e", - "mtime_ns": 1790744275764329165 + "bytes": 443488, + "sha256": "2be9306af1295605fd3deab3dfd16de2bfdeeecd6b696b48b4a6104b8d997c06", + "mtime_ns": 1790820802979090020 } ] diff --git a/corpus/recycle-purge/README.md b/corpus/recycle-purge/README.md new file mode 100644 index 0000000000000000000000000000000000000000..f4178b3bde82eab42cbcbf3d87145ef5d05cb67d --- /dev/null +++ b/corpus/recycle-purge/README.md @@ -0,0 +1,19 @@ +# Recycle bin purge + +OneNote 2010 deletes for good what its Notebook Recycle Bin has held "after 60 +days" (`DaysToKeepRecycledItems`), a few minutes after it starts. Observed in a lab +clone on 2026-09-30, with the clock moved to age pages and sections: + +- it judges by when the content last changed, not when it was deleted: pages + created 70 days back and deleted a minute before were purged, a page deleted + that day but changed that day was kept; +- a page goes from `OneNote_DeletedPages.one` in one appended revision; +- a binned section goes as a file once none of its pages changed in 60 days, its + file's own times aside; the bin's TOC keeps listing it, unchanged. + +`candidate/` is the notebook +`the_recycle_bin_forgets_pages_and_sections_unchanged_for_sixty_days` in +`crates/notebook/tests/structure.rs` exports with `NOTEBOOK_PURGE_EXPORT`: a page +and a section deleted into the bin, then purged as 61 days on. `cold/` is a fresh +OneNote 2010's read: Deleted Pages empty, the section gone, no repair, every file +left as written. `tools/test_recycle_purge.py` checks it without a VM. diff --git a/corpus/recycle-purge/candidate/New Section 1.one b/corpus/recycle-purge/candidate/New Section 1.one new file mode 100644 index 0000000000000000000000000000000000000000..e9b0df4a741c3602037c7b329a32cab52d2a2f48 Binary files /dev/null and b/corpus/recycle-purge/candidate/New Section 1.one differ diff --git a/corpus/recycle-purge/candidate/OneNote_RecycleBin/OneNote_DeletedPages.one b/corpus/recycle-purge/candidate/OneNote_RecycleBin/OneNote_DeletedPages.one new file mode 100644 index 0000000000000000000000000000000000000000..04cd731a750a890d7f5d7552971ac0313dc161e3 Binary files /dev/null and b/corpus/recycle-purge/candidate/OneNote_RecycleBin/OneNote_DeletedPages.one differ diff --git a/corpus/recycle-purge/candidate/OneNote_RecycleBin/Open Notebook.onetoc2 b/corpus/recycle-purge/candidate/OneNote_RecycleBin/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..8b1ba4c1fdab49f8f379fbce769fa8617a658c90 Binary files /dev/null and b/corpus/recycle-purge/candidate/OneNote_RecycleBin/Open Notebook.onetoc2 differ diff --git a/corpus/recycle-purge/candidate/Open Notebook.onetoc2 b/corpus/recycle-purge/candidate/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..124f7a366627fd46ffdbb9b63237df1be7b1f9a0 Binary files /dev/null and b/corpus/recycle-purge/candidate/Open Notebook.onetoc2 differ diff --git a/corpus/recycle-purge/cold/hierarchy.xml b/corpus/recycle-purge/cold/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..f10a245d20c7fd79d99532d6b261e468979ac558 --- /dev/null +++ b/corpus/recycle-purge/cold/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/recycle-purge/cold/recycle-bin.png b/corpus/recycle-purge/cold/recycle-bin.png new file mode 100644 index 0000000000000000000000000000000000000000..f4f78ea1863b04f396f6fead703bf22eb8143ce1 Binary files /dev/null and b/corpus/recycle-purge/cold/recycle-bin.png differ diff --git a/corpus/section-color/README.md b/corpus/section-color/README.md new file mode 100644 index 0000000000000000000000000000000000000000..3b40b39d998ba1d55610f8eb8cf2e1ebf52de9b1 --- /dev/null +++ b/corpus/section-color/README.md @@ -0,0 +1,19 @@ +# Section and notebook colour + +`native` is what OneNote 2010 did on the VM (2026-09-30). A section tab's context menu +ends with Section Color, sixteen named colours and None (`section-color-menu.png`); +`colors.txt` is each as the hierarchy XML reports it, in the menu's order. Picking one +appends a revision to the section's own file setting its metadata's `0x14001cbe` +(COLORREF; `teal.one`), and None sets `0xFFFFFFFF` (`none.one`); the table of contents is +left byte for byte. Notebook Properties (`notebook-properties.png`, from the notebook's +Rename… or Properties…) offers the same sixteen without None (`notebook-colors.png`) and +appends a revision to the root `Open Notebook.onetoc2` setting its root's `0x14001cbe` +(`before.onetoc2`, `after.onetoc2`). Its Display name is written to neither file: OneNote +keeps it in its local `OneNoteOfflineCache.onecache`, so the hierarchy's `nickname` +changes and `name`, the folder, stays. + +`candidate` is `crates/snowbound/src/menus.rs`'s notebook, exported with +`SNOWBOUND_SECTION_COLOR_EXPORT`: a section per colour and None, each made then coloured +with `Notebook::set_section_color`, and the notebook coloured Teal with +`Notebook::set_color`. `cold` is a fresh OneNote 2010's read: every section and the +notebook in the colour it was given. `tools/test_section_color.py` checks it without a VM. diff --git a/corpus/section-color/candidate/Apple.one b/corpus/section-color/candidate/Apple.one new file mode 100644 index 0000000000000000000000000000000000000000..b787c8d09b1810e8797c84d01e19bbc197f5fb9f Binary files /dev/null and b/corpus/section-color/candidate/Apple.one differ diff --git a/corpus/section-color/candidate/Blue Mist.one b/corpus/section-color/candidate/Blue Mist.one new file mode 100644 index 0000000000000000000000000000000000000000..5ab1b3f6e79ea98db8d9fb42f782bb93484fb455 Binary files /dev/null and b/corpus/section-color/candidate/Blue Mist.one differ diff --git a/corpus/section-color/candidate/Blue.one b/corpus/section-color/candidate/Blue.one new file mode 100644 index 0000000000000000000000000000000000000000..bee962892538a1cc8b21ee6c565f012f35160ad0 Binary files /dev/null and b/corpus/section-color/candidate/Blue.one differ diff --git a/corpus/section-color/candidate/Cyan.one b/corpus/section-color/candidate/Cyan.one new file mode 100644 index 0000000000000000000000000000000000000000..82ba5cb5cf2c0aad11ab6f667f8e6e69939499b8 Binary files /dev/null and b/corpus/section-color/candidate/Cyan.one differ diff --git a/corpus/section-color/candidate/Green.one b/corpus/section-color/candidate/Green.one new file mode 100644 index 0000000000000000000000000000000000000000..d7277aaf103ee2affd7778697cf16f890c136e6a Binary files /dev/null and b/corpus/section-color/candidate/Green.one differ diff --git a/corpus/section-color/candidate/Lemon.one b/corpus/section-color/candidate/Lemon.one new file mode 100644 index 0000000000000000000000000000000000000000..354ad0e82fd4939b6c75a4b2c14bd3510a0d969e Binary files /dev/null and b/corpus/section-color/candidate/Lemon.one differ diff --git a/corpus/section-color/candidate/Magenta.one b/corpus/section-color/candidate/Magenta.one new file mode 100644 index 0000000000000000000000000000000000000000..3e33155208bb8e977a5c78792ae78b76d2513cab Binary files /dev/null and b/corpus/section-color/candidate/Magenta.one differ diff --git a/corpus/section-color/candidate/New Section 1.one b/corpus/section-color/candidate/New Section 1.one new file mode 100644 index 0000000000000000000000000000000000000000..ada9be84863be6afa0ec05b954e1f63001d62e71 Binary files /dev/null and b/corpus/section-color/candidate/New Section 1.one differ diff --git a/corpus/section-color/candidate/None.one b/corpus/section-color/candidate/None.one new file mode 100644 index 0000000000000000000000000000000000000000..f9e09b94ae65e0d5e345638e8fd817ab721515e5 Binary files /dev/null and b/corpus/section-color/candidate/None.one differ diff --git a/corpus/section-color/candidate/Open Notebook.onetoc2 b/corpus/section-color/candidate/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..908f29936d3ac29bc3b3cb14cfbab85d3b1ba76f Binary files /dev/null and b/corpus/section-color/candidate/Open Notebook.onetoc2 differ diff --git a/corpus/section-color/candidate/Orange.one b/corpus/section-color/candidate/Orange.one new file mode 100644 index 0000000000000000000000000000000000000000..711817ec4b3ab9edff5065e76291e78efadb130b Binary files /dev/null and b/corpus/section-color/candidate/Orange.one differ diff --git a/corpus/section-color/candidate/Purple Mist.one b/corpus/section-color/candidate/Purple Mist.one new file mode 100644 index 0000000000000000000000000000000000000000..474bd09b5161f06701ac4780d79b98afe79393d5 Binary files /dev/null and b/corpus/section-color/candidate/Purple Mist.one differ diff --git a/corpus/section-color/candidate/Purple.one b/corpus/section-color/candidate/Purple.one new file mode 100644 index 0000000000000000000000000000000000000000..8f1c23884c40e475ae65ee84ec99b689dd861291 Binary files /dev/null and b/corpus/section-color/candidate/Purple.one differ diff --git a/corpus/section-color/candidate/Red Chalk.one b/corpus/section-color/candidate/Red Chalk.one new file mode 100644 index 0000000000000000000000000000000000000000..eec06b1e124581a4cbc27c06b30a74559c19d1a1 Binary files /dev/null and b/corpus/section-color/candidate/Red Chalk.one differ diff --git a/corpus/section-color/candidate/Red.one b/corpus/section-color/candidate/Red.one new file mode 100644 index 0000000000000000000000000000000000000000..655cad74b1d95d1744b9e69f2180789c2c751348 Binary files /dev/null and b/corpus/section-color/candidate/Red.one differ diff --git a/corpus/section-color/candidate/Silver.one b/corpus/section-color/candidate/Silver.one new file mode 100644 index 0000000000000000000000000000000000000000..daba64a266d8837c8f8a941ccc4c5157cb719ecb Binary files /dev/null and b/corpus/section-color/candidate/Silver.one differ diff --git a/corpus/section-color/candidate/Tan.one b/corpus/section-color/candidate/Tan.one new file mode 100644 index 0000000000000000000000000000000000000000..04f5afecd23716f32893fc6ba74ceb81f1f0d45a Binary files /dev/null and b/corpus/section-color/candidate/Tan.one differ diff --git a/corpus/section-color/candidate/Teal.one b/corpus/section-color/candidate/Teal.one new file mode 100644 index 0000000000000000000000000000000000000000..9d56e7288feb72ce27eafb8063ede4925ff5f318 Binary files /dev/null and b/corpus/section-color/candidate/Teal.one differ diff --git a/corpus/section-color/candidate/Yellow.one b/corpus/section-color/candidate/Yellow.one new file mode 100644 index 0000000000000000000000000000000000000000..1b8c50819b1f7a70821678d19e75452986b1ce0a Binary files /dev/null and b/corpus/section-color/candidate/Yellow.one differ diff --git a/corpus/section-color/cold/commands.jsonl b/corpus/section-color/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..7fa3aa60141e8fc7b2503e45bb57b5262d65ead3 --- /dev/null +++ b/corpus/section-color/cold/commands.jsonl @@ -0,0 +1,3 @@ +{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-35D6278F -ExpectedPages -1 -KeepOpen", "exit": 0, "stdout": "Read 18 sections and 18 pages.\r\n", "stderr": "", "error": null} +{"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} diff --git a/corpus/section-color/cold/machine.json b/corpus/section-color/cold/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..66a80ed662b18a5858c91b75a9c3b502ba9a2b5e --- /dev/null +++ b/corpus/section-color/cold/machine.json @@ -0,0 +1 @@ +{"name": "m6-35d6278f", "hostname": "ONE-M6-35D6278F"} diff --git a/corpus/section-color/cold/read/environment.json b/corpus/section-color/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..e5367d56aa664d3521b8e3f2fdca1a4da115b23f --- /dev/null +++ b/corpus/section-color/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-35D6278F", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/section-color/cold/read/hierarchy.xml b/corpus/section-color/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..16fd1d9270eca4e1a3abf8d251f9703e2a5db082 --- /dev/null +++ b/corpus/section-color/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/section-color/cold/read/page-000.navigation.json b/corpus/section-color/cold/read/page-000.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..a3ec3aa40d7375c559da1ef012a98cc97da4412c --- /dev/null +++ b/corpus/section-color/cold/read/page-000.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Untitled page - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/section-color/cold/read/page-000.png b/corpus/section-color/cold/read/page-000.png new file mode 100644 index 0000000000000000000000000000000000000000..d598c1229a73e117937784096e7b57cf6a0181e0 Binary files /dev/null and b/corpus/section-color/cold/read/page-000.png differ diff --git a/corpus/section-color/cold/read/page-000.xml b/corpus/section-color/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..01ed24d819243493efe518e4e034cbdefee660a9 --- /dev/null +++ b/corpus/section-color/cold/read/page-000.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/section-color/cold/read/page-001.navigation.json b/corpus/section-color/cold/read/page-001.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..a3ec3aa40d7375c559da1ef012a98cc97da4412c --- /dev/null +++ b/corpus/section-color/cold/read/page-001.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Untitled page - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/section-color/cold/read/page-001.png b/corpus/section-color/cold/read/page-001.png new file mode 100644 index 0000000000000000000000000000000000000000..d53ecf3d1bbe76c43d3781cf684f8cd4b8ef112c Binary files /dev/null and b/corpus/section-color/cold/read/page-001.png differ diff --git a/corpus/section-color/cold/read/page-001.xml b/corpus/section-color/cold/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..c2f09e78cd2ebbd3fdd6cd04b92c99fa76df015a --- /dev/null +++ b/corpus/section-color/cold/read/page-001.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/section-color/cold/read/page-002.navigation.json b/corpus/section-color/cold/read/page-002.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..a3ec3aa40d7375c559da1ef012a98cc97da4412c --- /dev/null +++ b/corpus/section-color/cold/read/page-002.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Untitled page - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/section-color/cold/read/page-002.png b/corpus/section-color/cold/read/page-002.png new file mode 100644 index 0000000000000000000000000000000000000000..4fae21273b9218fd87a393ec75a54c769658a2c2 Binary files /dev/null and b/corpus/section-color/cold/read/page-002.png differ diff --git a/corpus/section-color/cold/read/page-002.xml b/corpus/section-color/cold/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..7fe280535e3076187c6be7abf60f6553b57bbad7 --- /dev/null +++ b/corpus/section-color/cold/read/page-002.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/section-color/cold/read/page-003.navigation.json b/corpus/section-color/cold/read/page-003.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..a3ec3aa40d7375c559da1ef012a98cc97da4412c --- /dev/null +++ b/corpus/section-color/cold/read/page-003.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Untitled page - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/section-color/cold/read/page-003.png b/corpus/section-color/cold/read/page-003.png new file mode 100644 index 0000000000000000000000000000000000000000..7296223cf8e0e9a194ec7e1c22302973a422d36b Binary files /dev/null and b/corpus/section-color/cold/read/page-003.png differ diff --git a/corpus/section-color/cold/read/page-003.xml b/corpus/section-color/cold/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..9e63857829da737673ca92a9d2ca461ceb20aaad --- /dev/null +++ b/corpus/section-color/cold/read/page-003.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/section-color/cold/read/page-004.navigation.json b/corpus/section-color/cold/read/page-004.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..a3ec3aa40d7375c559da1ef012a98cc97da4412c --- /dev/null +++ b/corpus/section-color/cold/read/page-004.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Untitled page - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/section-color/cold/read/page-004.png b/corpus/section-color/cold/read/page-004.png new file mode 100644 index 0000000000000000000000000000000000000000..0dd5a0eb7eabde1c19a4a485c77c02bc33761ac1 Binary files /dev/null and b/corpus/section-color/cold/read/page-004.png differ diff --git a/corpus/section-color/cold/read/page-004.xml b/corpus/section-color/cold/read/page-004.xml new file mode 100644 index 0000000000000000000000000000000000000000..8470dd40a686c1825cb0bddeb7d0b5eb6b99179b --- /dev/null +++ b/corpus/section-color/cold/read/page-004.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/section-color/cold/read/page-005.navigation.json b/corpus/section-color/cold/read/page-005.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..a3ec3aa40d7375c559da1ef012a98cc97da4412c --- /dev/null +++ b/corpus/section-color/cold/read/page-005.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Untitled page - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/section-color/cold/read/page-005.png b/corpus/section-color/cold/read/page-005.png new file mode 100644 index 0000000000000000000000000000000000000000..2555dec4248e7d4e33c038b774c94ef90d918d36 Binary files /dev/null and b/corpus/section-color/cold/read/page-005.png differ diff --git a/corpus/section-color/cold/read/page-005.xml b/corpus/section-color/cold/read/page-005.xml new file mode 100644 index 0000000000000000000000000000000000000000..832c4888d5751a64400f6e81f5a71c4241cd62f5 --- /dev/null +++ b/corpus/section-color/cold/read/page-005.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/section-color/cold/read/page-006.navigation.json b/corpus/section-color/cold/read/page-006.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..a3ec3aa40d7375c559da1ef012a98cc97da4412c --- /dev/null +++ b/corpus/section-color/cold/read/page-006.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Untitled page - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/section-color/cold/read/page-006.png b/corpus/section-color/cold/read/page-006.png new file mode 100644 index 0000000000000000000000000000000000000000..5d5df88378f69bb329b562e440e6f37ec72dd4c1 Binary files /dev/null and b/corpus/section-color/cold/read/page-006.png differ diff --git a/corpus/section-color/cold/read/page-006.xml b/corpus/section-color/cold/read/page-006.xml new file mode 100644 index 0000000000000000000000000000000000000000..1b9e64e0bfb53e602fd89f5488c9999cb228b6c5 --- /dev/null +++ b/corpus/section-color/cold/read/page-006.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/section-color/cold/read/page-007.navigation.json b/corpus/section-color/cold/read/page-007.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..a3ec3aa40d7375c559da1ef012a98cc97da4412c --- /dev/null +++ b/corpus/section-color/cold/read/page-007.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Untitled page - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/section-color/cold/read/page-007.png b/corpus/section-color/cold/read/page-007.png new file mode 100644 index 0000000000000000000000000000000000000000..d8607670eb678812135d7a98162366b3d16d8fdf Binary files /dev/null and b/corpus/section-color/cold/read/page-007.png differ diff --git a/corpus/section-color/cold/read/page-007.xml b/corpus/section-color/cold/read/page-007.xml new file mode 100644 index 0000000000000000000000000000000000000000..3a177c868a0f72301963f1895b5977bcbf1994fc --- /dev/null +++ b/corpus/section-color/cold/read/page-007.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/section-color/cold/read/page-008.navigation.json b/corpus/section-color/cold/read/page-008.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..a3ec3aa40d7375c559da1ef012a98cc97da4412c --- /dev/null +++ b/corpus/section-color/cold/read/page-008.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Untitled page - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/section-color/cold/read/page-008.png b/corpus/section-color/cold/read/page-008.png new file mode 100644 index 0000000000000000000000000000000000000000..7288dc7dedc537fa0f82121a815b5cb52e909be3 Binary files /dev/null and b/corpus/section-color/cold/read/page-008.png differ diff --git a/corpus/section-color/cold/read/page-008.xml b/corpus/section-color/cold/read/page-008.xml new file mode 100644 index 0000000000000000000000000000000000000000..f9fb951e4b35cdaa3e614810c9b3d0ee29baeb9a --- /dev/null +++ b/corpus/section-color/cold/read/page-008.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/section-color/cold/read/page-009.navigation.json b/corpus/section-color/cold/read/page-009.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..a3ec3aa40d7375c559da1ef012a98cc97da4412c --- /dev/null +++ b/corpus/section-color/cold/read/page-009.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Untitled page - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/section-color/cold/read/page-009.png b/corpus/section-color/cold/read/page-009.png new file mode 100644 index 0000000000000000000000000000000000000000..83970fb350ed83698254f8b42297c5b38a69a927 Binary files /dev/null and b/corpus/section-color/cold/read/page-009.png differ diff --git a/corpus/section-color/cold/read/page-009.xml b/corpus/section-color/cold/read/page-009.xml new file mode 100644 index 0000000000000000000000000000000000000000..6403eaf72fcb86d54010f519e3bbfdd3ca390585 --- /dev/null +++ b/corpus/section-color/cold/read/page-009.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/section-color/cold/read/page-010.navigation.json b/corpus/section-color/cold/read/page-010.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..a3ec3aa40d7375c559da1ef012a98cc97da4412c --- /dev/null +++ b/corpus/section-color/cold/read/page-010.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Untitled page - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/section-color/cold/read/page-010.png b/corpus/section-color/cold/read/page-010.png new file mode 100644 index 0000000000000000000000000000000000000000..eeb4a5b79c5f6f7bc024483ae59c81b0c05d72b7 Binary files /dev/null and b/corpus/section-color/cold/read/page-010.png differ diff --git a/corpus/section-color/cold/read/page-010.xml b/corpus/section-color/cold/read/page-010.xml new file mode 100644 index 0000000000000000000000000000000000000000..a8f908dadc6435e2c5d64924869d2f6926075476 --- /dev/null +++ b/corpus/section-color/cold/read/page-010.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/section-color/cold/read/page-011.navigation.json b/corpus/section-color/cold/read/page-011.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..a3ec3aa40d7375c559da1ef012a98cc97da4412c --- /dev/null +++ b/corpus/section-color/cold/read/page-011.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Untitled page - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/section-color/cold/read/page-011.png b/corpus/section-color/cold/read/page-011.png new file mode 100644 index 0000000000000000000000000000000000000000..f3b39eef8e2cb6d8149cbd9838058aac16a2aba5 Binary files /dev/null and b/corpus/section-color/cold/read/page-011.png differ diff --git a/corpus/section-color/cold/read/page-011.xml b/corpus/section-color/cold/read/page-011.xml new file mode 100644 index 0000000000000000000000000000000000000000..5f95a1d4e8b24798c019494f8fcaf3e80f9e1d3d --- /dev/null +++ b/corpus/section-color/cold/read/page-011.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/section-color/cold/read/page-012.navigation.json b/corpus/section-color/cold/read/page-012.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..a3ec3aa40d7375c559da1ef012a98cc97da4412c --- /dev/null +++ b/corpus/section-color/cold/read/page-012.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Untitled page - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/section-color/cold/read/page-012.png b/corpus/section-color/cold/read/page-012.png new file mode 100644 index 0000000000000000000000000000000000000000..fc18ba287f557d4bb21b8daf79cf66ece3acf1c5 Binary files /dev/null and b/corpus/section-color/cold/read/page-012.png differ diff --git a/corpus/section-color/cold/read/page-012.xml b/corpus/section-color/cold/read/page-012.xml new file mode 100644 index 0000000000000000000000000000000000000000..8bac391fbc5ac57c8809c0bd985d9cbde40110a7 --- /dev/null +++ b/corpus/section-color/cold/read/page-012.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/section-color/cold/read/page-013.navigation.json b/corpus/section-color/cold/read/page-013.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..a3ec3aa40d7375c559da1ef012a98cc97da4412c --- /dev/null +++ b/corpus/section-color/cold/read/page-013.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Untitled page - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/section-color/cold/read/page-013.png b/corpus/section-color/cold/read/page-013.png new file mode 100644 index 0000000000000000000000000000000000000000..1155faa5cca9d0f0748f0d1defa09a8e1840a273 Binary files /dev/null and b/corpus/section-color/cold/read/page-013.png differ diff --git a/corpus/section-color/cold/read/page-013.xml b/corpus/section-color/cold/read/page-013.xml new file mode 100644 index 0000000000000000000000000000000000000000..7acef30255e2d0f76dce549a0d3b6f468203cc6c --- /dev/null +++ b/corpus/section-color/cold/read/page-013.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/section-color/cold/read/page-014.navigation.json b/corpus/section-color/cold/read/page-014.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..a3ec3aa40d7375c559da1ef012a98cc97da4412c --- /dev/null +++ b/corpus/section-color/cold/read/page-014.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Untitled page - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/section-color/cold/read/page-014.png b/corpus/section-color/cold/read/page-014.png new file mode 100644 index 0000000000000000000000000000000000000000..e066e7ad700cb207ff18ebef939f3b74260f0c1a Binary files /dev/null and b/corpus/section-color/cold/read/page-014.png differ diff --git a/corpus/section-color/cold/read/page-014.xml b/corpus/section-color/cold/read/page-014.xml new file mode 100644 index 0000000000000000000000000000000000000000..315d24c03b00837cb4e54139cbd2f88e459baa03 --- /dev/null +++ b/corpus/section-color/cold/read/page-014.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/section-color/cold/read/page-015.navigation.json b/corpus/section-color/cold/read/page-015.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..a3ec3aa40d7375c559da1ef012a98cc97da4412c --- /dev/null +++ b/corpus/section-color/cold/read/page-015.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Untitled page - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/section-color/cold/read/page-015.png b/corpus/section-color/cold/read/page-015.png new file mode 100644 index 0000000000000000000000000000000000000000..f59398bd4b046cd1ec9723d25f76ddcc7ac6bcab Binary files /dev/null and b/corpus/section-color/cold/read/page-015.png differ diff --git a/corpus/section-color/cold/read/page-015.xml b/corpus/section-color/cold/read/page-015.xml new file mode 100644 index 0000000000000000000000000000000000000000..d2086ce60fd9a78555437e798ce2a7dd90b6d048 --- /dev/null +++ b/corpus/section-color/cold/read/page-015.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/section-color/cold/read/page-016.navigation.json b/corpus/section-color/cold/read/page-016.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..a3ec3aa40d7375c559da1ef012a98cc97da4412c --- /dev/null +++ b/corpus/section-color/cold/read/page-016.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Untitled page - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/section-color/cold/read/page-016.png b/corpus/section-color/cold/read/page-016.png new file mode 100644 index 0000000000000000000000000000000000000000..82652cd55121fe83becd78fde0d36e084f3bf18a Binary files /dev/null and b/corpus/section-color/cold/read/page-016.png differ diff --git a/corpus/section-color/cold/read/page-016.xml b/corpus/section-color/cold/read/page-016.xml new file mode 100644 index 0000000000000000000000000000000000000000..b3526a71557bc830670e59554e9362dbb72c1ee9 --- /dev/null +++ b/corpus/section-color/cold/read/page-016.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/section-color/cold/read/page-017.navigation.json b/corpus/section-color/cold/read/page-017.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..a3ec3aa40d7375c559da1ef012a98cc97da4412c --- /dev/null +++ b/corpus/section-color/cold/read/page-017.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Untitled page - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/section-color/cold/read/page-017.png b/corpus/section-color/cold/read/page-017.png new file mode 100644 index 0000000000000000000000000000000000000000..99f9356aa8b512b61a48299c20c847e24ffb0212 Binary files /dev/null and b/corpus/section-color/cold/read/page-017.png differ diff --git a/corpus/section-color/cold/read/page-017.xml b/corpus/section-color/cold/read/page-017.xml new file mode 100644 index 0000000000000000000000000000000000000000..891eaa1c3985f00f00f366187040885f0b72d891 --- /dev/null +++ b/corpus/section-color/cold/read/page-017.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/section-color/cold/read/payloads.json b/corpus/section-color/cold/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/section-color/cold/read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/section-color/cold/run.json b/corpus/section-color/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..658d74ac8f153327cac9802ca8d89f3ac78bbe6c --- /dev/null +++ b/corpus/section-color/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/snowbound-properties/section-color/candidate", + "expected_pages": -1, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": false, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35" + } +} diff --git a/corpus/section-color/cold/scripts/cold.ps1 b/corpus/section-color/cold/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/section-color/cold/scripts/cold.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } +$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' +if ($CloneHost) { + if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { + throw 'The disposable clone hostname does not match this machine.' + } + New-Item "$key\Options\Paths" -Force | Out-Null + New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null +} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or + -not (Test-Path 'C:\one-tests\profile-original-cache')) { + throw 'Park the personal OneNote profile before resetting the test cache.' +} +$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' +$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) +if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } +New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null +if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } +if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } +New-Item "$key\OpenNotebooks" | Out-Null +New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null diff --git a/corpus/section-color/cold/scripts/read.ps1 b/corpus/section-color/cold/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a42366b32e8baaf13dae92e9472cae7a071220cc --- /dev/null +++ b/corpus/section-color/cold/scripts/read.ps1 @@ -0,0 +1,144 @@ +param( + [Parameter(Mandatory=$true)][string]$Root, + [int]$ExpectedPages = -1, + [switch]$UseCurrentCache, + [switch]$Pdf, + [switch]$KeepOpen, + [string]$CloneHost = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +$notebook = Join-Path $root 'notebook' +$output = Join-Path $root 'read' +if (Test-Path $output) { throw 'Choose a new read destination.' } +if ($UseCurrentCache) { + if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { + throw 'Park the personal OneNote profile before reading test notebooks.' + } +} else { + & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost +} +New-Item -ItemType Directory -Path $output | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +$failure = $null +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $process = Get-Process ONENOTE + @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; + powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | + ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 + $sections = @() + foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { + $id = '' + $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) + $sections += $id + } + $deadline = [DateTime]::UtcNow.AddSeconds(300) + $previous = '' + $lastChange = '' + $stableSince = [DateTime]::UtcNow + $settled = $false + do { + $pages = @{} + foreach ($section in $sections) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$xml = $hierarchy + foreach ($node in $xml.SelectNodes('//*[@path]')) { + if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { + throw 'OneNote opened a section outside the copied notebook.' + } + } + foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { + $id = $node.GetAttribute('ID') + $content = '' + $app.GetPageContent($id, [ref]$content, 1, 1) + $pages[$id] = $content + } + } + $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) + if ($signature -ne $previous) { + $lastChange = $previous + $previous = $signature + $stableSince = [DateTime]::UtcNow + } + if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or + ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and + ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { + [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) + $index = 0 + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + $index++ + } + $hierarchy = '' + $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) + throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." + } + $index = 0 + $payloads = @() + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + if ($Pdf) { + $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) + $app.NavigateTo($id, '', $false) + $app.Publish($id, $pdfPath, 3, '') + if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } + } + [xml]$page = $pages[$id] + foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { + $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) + # A paragraph's file is named by its paragraph, one on the page by itself. + $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode } + $payloads += @{ page = $id; object = $owner.GetAttribute('objectID'); + kind = $file.LocalName; name = $file.GetAttribute('preferredName'); + sha256 = $hash; bytes = $bytes.Length } + } + $index++ + } + [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) + $all = '' + $app.GetHierarchy($notebookId, 4, [ref]$all, 1) + [xml]$finalTree = $all + $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) + if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { + throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' + } + [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) + Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." +} catch { + $failure = $_ + [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + throw +} finally { + try { + try { + if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } + if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { + $app.CloseNotebook($notebookId, $false) + } + } catch { + if ($null -eq $failure) { throw } + [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + } + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + if (-not $UseCurrentCache -and -not $CloneHost) { + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + } +} diff --git a/corpus/section-color/cold/source.json b/corpus/section-color/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..6980c1b6282dd1456dbcb3dba4ab6d04bb27709c --- /dev/null +++ b/corpus/section-color/cold/source.json @@ -0,0 +1,116 @@ +[ + { + "path": "Apple.one", + "bytes": 6352, + "sha256": "0bf6021d0904aa6e281574ce50db4f24d5bb94980140b444e21b9720e9d4c0d4", + "mtime_ns": 1790825341918454713 + }, + { + "path": "Blue Mist.one", + "bytes": 6352, + "sha256": "d9f3430fc9d4671a22a62a5575513df6cb710376d709e6bff47fab2598a7cc15", + "mtime_ns": 1790825341692411886 + }, + { + "path": "Blue.one", + "bytes": 6352, + "sha256": "10811cc79043e79e26a908c4974a6f8b8df923223a509d2673adbe0b9b060f1d", + "mtime_ns": 1790825341319295896 + }, + { + "path": "Cyan.one", + "bytes": 6352, + "sha256": "d51a7b08f6cda049ea8209174498d50283c7ec9dc0a63583b35029aeb0c4e578", + "mtime_ns": 1790825341540263286 + }, + { + "path": "Green.one", + "bytes": 6352, + "sha256": "470c4d19a7ca9d03c1243604bb8d4142feb5ca33c1e9244c3732f0275b02315d", + "mtime_ns": 1790825341404235629 + }, + { + "path": "Lemon.one", + "bytes": 6352, + "sha256": "a30ee07c1d1eaabd7d8ac2dd5ba93a12d62db8ca5602f11ef9cdaf2127461d45", + "mtime_ns": 1790825341865471198 + }, + { + "path": "Magenta.one", + "bytes": 6352, + "sha256": "d2fb77176b7b891f822e9b4e0a86071c811bcef4655e066c66aaf101661eead1", + "mtime_ns": 1790825341641456588 + }, + { + "path": "New Section 1.one", + "bytes": 6000, + "sha256": "8f9614276e8f8a60f681660b2015c0f2f59428eedef503087c5aea8874d7fc7b", + "mtime_ns": 1790825341261553569 + }, + { + "path": "None.one", + "bytes": 6352, + "sha256": "d61d1899f38e371b042d40641bc7bd1ba6eadf78b205bb20bcd1a9470e5eff53", + "mtime_ns": 1790825342154418500 + }, + { + "path": "Open Notebook.onetoc2", + "bytes": 14552, + "sha256": "61b231025ae93c0242bd59468e14f493ccb24c231c59fcb2fae28ad4c6efc8a6", + "mtime_ns": 1790825342179576107 + }, + { + "path": "Orange.one", + "bytes": 6352, + "sha256": "1635f1fb9340b69e5abe82ef00bc99df332d83708187f44f573f5d887630c374", + "mtime_ns": 1790825341589404452 + }, + { + "path": "Purple Mist.one", + "bytes": 6352, + "sha256": "aa06592b69ced1ddd935d625eba1c4e02fd084e9c032ba5976226cf7a59cbc21", + "mtime_ns": 1790825341748511081 + }, + { + "path": "Purple.one", + "bytes": 6352, + "sha256": "4ade0b3953c99a4ab8e29a7855370c9a3bf1b9e06c359fd2f523abf167dc8797", + "mtime_ns": 1790825341493283421 + }, + { + "path": "Red Chalk.one", + "bytes": 6352, + "sha256": "1e9c778c50a963c66ab375e1e8f67cff05e2a11d95a5a89abf0dfa09dcc6d05a", + "mtime_ns": 1790825342043389615 + }, + { + "path": "Red.one", + "bytes": 6352, + "sha256": "f17f0e4de9947c3b40567f8ba60a9ec3f127c1bb7dfdc55b778e7b08e1f3b60d", + "mtime_ns": 1790825341447219727 + }, + { + "path": "Silver.one", + "bytes": 6352, + "sha256": "aa854f8b8c8333ae2d274d9a17333bb3a5c7c62bb35e69da1640385df3904101", + "mtime_ns": 1790825342101432402 + }, + { + "path": "Tan.one", + "bytes": 6352, + "sha256": "236d18dff5679e7bf10c108c44d18ab8df1d01cf860c2bd94b0d74fdb0c81015", + "mtime_ns": 1790825341806399157 + }, + { + "path": "Teal.one", + "bytes": 6352, + "sha256": "fbf08e85eee0b224bb76b82e3541fee9abe033bc04dd7a6abb09a8c0ae774e5f", + "mtime_ns": 1790825341979577971 + }, + { + "path": "Yellow.one", + "bytes": 6352, + "sha256": "a30a5231b8ec1a0d603c325694f538e432e6095d601dbcce002c2e663b42f3f7", + "mtime_ns": 1790825341361229906 + } +] diff --git a/corpus/section-color/cold/teardown.json b/corpus/section-color/cold/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/section-color/cold/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/section-color/native/after.onetoc2 b/corpus/section-color/native/after.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..3050eeed527df03016d22a8b3f3a810cb97d56d9 Binary files /dev/null and b/corpus/section-color/native/after.onetoc2 differ diff --git a/corpus/section-color/native/before.onetoc2 b/corpus/section-color/native/before.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..6597a3c91d618775244dae61595dea3fe5584ebd Binary files /dev/null and b/corpus/section-color/native/before.onetoc2 differ diff --git a/corpus/section-color/native/colors.txt b/corpus/section-color/native/colors.txt new file mode 100644 index 0000000000000000000000000000000000000000..6212bd9a9b3b387be0eda534f9218d5b3e50f9ef --- /dev/null +++ b/corpus/section-color/native/colors.txt @@ -0,0 +1,17 @@ +Blue #8AA8E4 +Yellow #FFD869 +Green #B7C997 +Red #EE9597 +Purple #B49EDE +Cyan #91BAAE +Orange #F6B078 +Magenta #D5A4BB +Blue Mist #9BBBD2 +Purple Mist #AB9CB7 +Tan #E8D199 +Lemon #F5F96F +Apple #ADE792 +Teal #4DBCCA +Red Chalk #BA7575 +Silver #9595AA +None none diff --git a/corpus/section-color/native/none.one b/corpus/section-color/native/none.one new file mode 100644 index 0000000000000000000000000000000000000000..3b7d710737f3f3c8dc3cebe3f5ab5bd5c8f0adb1 Binary files /dev/null and b/corpus/section-color/native/none.one differ diff --git a/corpus/section-color/native/notebook-colors.png b/corpus/section-color/native/notebook-colors.png new file mode 100644 index 0000000000000000000000000000000000000000..7f3c1bb7f1ed4ea47860e854a3382432b9da24cd Binary files /dev/null and b/corpus/section-color/native/notebook-colors.png differ diff --git a/corpus/section-color/native/notebook-menu.png b/corpus/section-color/native/notebook-menu.png new file mode 100644 index 0000000000000000000000000000000000000000..3badf10fef1f8dec4b9637f0865c434d10474c64 Binary files /dev/null and b/corpus/section-color/native/notebook-menu.png differ diff --git a/corpus/section-color/native/notebook-properties.png b/corpus/section-color/native/notebook-properties.png new file mode 100644 index 0000000000000000000000000000000000000000..33ad64b554afb3d03540eda2a85fb3127c98fcf0 Binary files /dev/null and b/corpus/section-color/native/notebook-properties.png differ diff --git a/corpus/section-color/native/section-color-menu.png b/corpus/section-color/native/section-color-menu.png new file mode 100644 index 0000000000000000000000000000000000000000..9b570b5a49ab3398dec7f273b1f2394b90743e47 Binary files /dev/null and b/corpus/section-color/native/section-color-menu.png differ diff --git a/corpus/section-color/native/section-menu.png b/corpus/section-color/native/section-menu.png new file mode 100644 index 0000000000000000000000000000000000000000..19858bbcc6b1785bc015a6a1c0226ad42d03ce1c Binary files /dev/null and b/corpus/section-color/native/section-menu.png differ diff --git a/corpus/section-color/native/teal.one b/corpus/section-color/native/teal.one new file mode 100644 index 0000000000000000000000000000000000000000..9f01c9f8eb2dc58d799ec4bc7d534d414358c905 Binary files /dev/null and b/corpus/section-color/native/teal.one differ diff --git a/corpus/styles/README.md b/corpus/styles/README.md new file mode 100644 index 0000000000000000000000000000000000000000..2dbcc220d5ca00285a888ebf7ccf1e1e0083442d --- /dev/null +++ b/corpus/styles/README.md @@ -0,0 +1,27 @@ +# Styles and themes + +`onenote/` is OneNote 2010 applying its own Styles gallery in the lab (2026-09-30): +`Styles.one` holds a page with each of the eleven styles applied (Ctrl+Alt+1 to 6 and the +gallery), Enter after each, a style over explicit formatting, Clear Formatting, and a second +page whose styles were made through COM in Georgia, onto which OneNote added its own `h1` +and into which a pasted Georgia heading brought its own; `applied.xml` is the first page's +COM read. Applying a style stores a paragraph style object per style per page and clears +the paragraph's explicit character formatting; headings carry NextStyle `p`. + +`candidate/` is the notebook `themed_pages_hold_their_theme_under_onenote_s_names` in +`crates/snowbound/src/themes.rs` writes: a section per built-in theme (OneNote, Manuscript, +Editorial, Modern), each a page with a paragraph in every gallery style applied through the +editor, then restyled as opening the page does, and `.snowbound/themes.json` giving each +section its theme. `cold/` is `tools/native_styles.py` in a fresh OneNote 2010 clone: every +page draws in its theme (`read/page-00N.png`) and lists the eleven styles under OneNote's +names with the theme's values, and no `.snowbound` section group appears. + +`onenote-edit/` is OneNote 2010 editing the Manuscript page of `candidate/` in the lab: +Ctrl+Alt+2 on the Normal paragraph and Enter after it, and Enter after the theme's Heading +5. OneNote applies its own `h2` and `p` beside the theme's (`Manuscript.png`); reopened in +Snowbound they read under those names and restyling heals them +(`styles_onenote_adds_to_a_themed_page_are_healed`). + +`tools/test_styles.py` checks this without a VM. Regenerate with `SNOWBOUND_STYLES_EXPORT` +set to a new absolute directory while running the test, then `tools/native_styles.py OUTPUT +COLD`. diff --git a/corpus/styles/candidate/.snowbound/themes.json b/corpus/styles/candidate/.snowbound/themes.json new file mode 100644 index 0000000000000000000000000000000000000000..549945dd36f23fa7a726c139f4d6865ceaedd612 --- /dev/null +++ b/corpus/styles/candidate/.snowbound/themes.json @@ -0,0 +1,33 @@ +{ + "themes": [], + "assignments": [ + { + "scope": { + "section": "125e8f01c6ee404b96cc675d00ceb659" + }, + "theme": "onenote", + "assigned": 134000000000000000 + }, + { + "scope": { + "section": "af4f8ea23c5f7e41838ac7cbea473590" + }, + "theme": "manuscript", + "assigned": 134000000000000000 + }, + { + "scope": { + "section": "18af2996b565454c971191d887028fc8" + }, + "theme": "editorial", + "assigned": 134000000000000000 + }, + { + "scope": { + "section": "f0ca724b3a9d3d4b82bc6f28bd77966c" + }, + "theme": "modern", + "assigned": 134000000000000000 + } + ] +} \ No newline at end of file diff --git a/corpus/styles/candidate/Editorial.one b/corpus/styles/candidate/Editorial.one new file mode 100644 index 0000000000000000000000000000000000000000..1f5b13635c9519f0b1d6470a2a80781333fa1743 Binary files /dev/null and b/corpus/styles/candidate/Editorial.one differ diff --git a/corpus/styles/candidate/Manuscript.one b/corpus/styles/candidate/Manuscript.one new file mode 100644 index 0000000000000000000000000000000000000000..d595551c7d0126f3a0f5e118d6bf55b45c038576 Binary files /dev/null and b/corpus/styles/candidate/Manuscript.one differ diff --git a/corpus/styles/candidate/Modern.one b/corpus/styles/candidate/Modern.one new file mode 100644 index 0000000000000000000000000000000000000000..7228d5e88fbbda251c692ea98fe89da570505f84 Binary files /dev/null and b/corpus/styles/candidate/Modern.one differ diff --git a/corpus/styles/candidate/OneNote.one b/corpus/styles/candidate/OneNote.one new file mode 100644 index 0000000000000000000000000000000000000000..6915847a3b5e8c59004dcd085c9d9705da76d783 Binary files /dev/null and b/corpus/styles/candidate/OneNote.one differ diff --git a/corpus/styles/candidate/Open Notebook.onetoc2 b/corpus/styles/candidate/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..d4ceb28b8e6899338a0ccf41cc757423a03dd3bd Binary files /dev/null and b/corpus/styles/candidate/Open Notebook.onetoc2 differ diff --git a/corpus/styles/cold/commands.jsonl b/corpus/styles/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..0843332e2b3ce3020bb737357ca84f70ba069ead --- /dev/null +++ b/corpus/styles/cold/commands.jsonl @@ -0,0 +1,4 @@ +{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\author.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-222E5D78", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-222E5D78 -ExpectedPages 4 -UseCurrentCache -KeepOpen", "exit": 0, "stdout": "Read 4 sections and 4 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} diff --git a/corpus/styles/cold/machine.json b/corpus/styles/cold/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..5d1ccba7f887bfaf23686dfb5c2dae77c791d0be --- /dev/null +++ b/corpus/styles/cold/machine.json @@ -0,0 +1 @@ +{"name": "m6-222e5d78", "hostname": "ONE-M6-222E5D78"} diff --git a/corpus/styles/cold/notebook/Editorial.one b/corpus/styles/cold/notebook/Editorial.one new file mode 100644 index 0000000000000000000000000000000000000000..42462a2e46e8b31d90bfd6e8fc0254a167a8add7 Binary files /dev/null and b/corpus/styles/cold/notebook/Editorial.one differ diff --git a/corpus/styles/cold/notebook/Manuscript.one b/corpus/styles/cold/notebook/Manuscript.one new file mode 100644 index 0000000000000000000000000000000000000000..8f3ce5fb57b2b2ff62ed6076782a98fac5baf849 Binary files /dev/null and b/corpus/styles/cold/notebook/Manuscript.one differ diff --git a/corpus/styles/cold/notebook/Modern.one b/corpus/styles/cold/notebook/Modern.one new file mode 100644 index 0000000000000000000000000000000000000000..403d594c04d6df0f50a8d65dc8c5b0178a5c40de Binary files /dev/null and b/corpus/styles/cold/notebook/Modern.one differ diff --git a/corpus/styles/cold/notebook/OneNote.one b/corpus/styles/cold/notebook/OneNote.one new file mode 100644 index 0000000000000000000000000000000000000000..eabc7f8002f2b284c95f9b55622de02338c54e86 Binary files /dev/null and b/corpus/styles/cold/notebook/OneNote.one differ diff --git a/corpus/styles/cold/notebook/Open Notebook.onetoc2 b/corpus/styles/cold/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..6a5357126ab396e70a19ac2ae665da6f24f35860 Binary files /dev/null and b/corpus/styles/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/styles/cold/read/environment.json b/corpus/styles/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..e93a5211ce6a1aba021b77c79ee65472d25d7ba3 --- /dev/null +++ b/corpus/styles/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-222E5D78", + "cold": false, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/styles/cold/read/hierarchy.xml b/corpus/styles/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..21cad963c0a70f32b9f24f30a03e43419f1311f5 --- /dev/null +++ b/corpus/styles/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/styles/cold/read/page-000.navigation.json b/corpus/styles/cold/read/page-000.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..556fccc0ba9aafbacaa8b574185841ec2226b9ac --- /dev/null +++ b/corpus/styles/cold/read/page-000.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Heading 1 in Manuscript - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/styles/cold/read/page-000.png b/corpus/styles/cold/read/page-000.png new file mode 100644 index 0000000000000000000000000000000000000000..cf74c1e244c403efb231e2bd111d7986ab78ec70 Binary files /dev/null and b/corpus/styles/cold/read/page-000.png differ diff --git a/corpus/styles/cold/read/page-000.xml b/corpus/styles/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..0a3a9b4653d5d2510d1851ed2749ae3eecf056d9 --- /dev/null +++ b/corpus/styles/cold/read/page-000.xml @@ -0,0 +1,7 @@ + +Heading 2 in Manuscript]]>Heading 3 in Manuscript]]>Heading 4 in Manuscript]]>Heading 6 in Manuscript]]>Quote in Manuscript]]> diff --git a/corpus/styles/cold/read/page-001.navigation.json b/corpus/styles/cold/read/page-001.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..eb3009ecdd53b4c3f537147cba67b7f35fb93b77 --- /dev/null +++ b/corpus/styles/cold/read/page-001.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Heading 1 in OneNote - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/styles/cold/read/page-001.png b/corpus/styles/cold/read/page-001.png new file mode 100644 index 0000000000000000000000000000000000000000..a5eb0b44677bb5f2dc2c2b6d278330c5775261da Binary files /dev/null and b/corpus/styles/cold/read/page-001.png differ diff --git a/corpus/styles/cold/read/page-001.xml b/corpus/styles/cold/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..ebc52a01470deea7c86e2d90066b47c2c3ebbfec --- /dev/null +++ b/corpus/styles/cold/read/page-001.xml @@ -0,0 +1,8 @@ + +Heading 1 in OneNote]]>Heading 2 in OneNote]]>Heading 3 in OneNote]]>Heading 4 in OneNote]]>Heading 6 in OneNote]]>Quote in OneNote]]> diff --git a/corpus/styles/cold/read/page-002.navigation.json b/corpus/styles/cold/read/page-002.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..5c4ad5710c39a47780832ac8e8af3262af484508 --- /dev/null +++ b/corpus/styles/cold/read/page-002.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Heading 1 in Editorial - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/styles/cold/read/page-002.png b/corpus/styles/cold/read/page-002.png new file mode 100644 index 0000000000000000000000000000000000000000..235a6a47216251b5bd1ef8b008a07c93d72345c0 Binary files /dev/null and b/corpus/styles/cold/read/page-002.png differ diff --git a/corpus/styles/cold/read/page-002.xml b/corpus/styles/cold/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..58207e109ab7de0708bea486bea44f6486295835 --- /dev/null +++ b/corpus/styles/cold/read/page-002.xml @@ -0,0 +1,8 @@ + +Heading 1 in Editorial]]>Heading 2 in Editorial]]>Heading 3 in Editorial]]>Heading 4 in Editorial]]>Heading 6 in Editorial]]>Quote in Editorial]]> diff --git a/corpus/styles/cold/read/page-003.navigation.json b/corpus/styles/cold/read/page-003.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..fbef07913da8ee5d5dac2283fcacb7e449ba65d5 --- /dev/null +++ b/corpus/styles/cold/read/page-003.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Heading 1 in Modern - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/styles/cold/read/page-003.png b/corpus/styles/cold/read/page-003.png new file mode 100644 index 0000000000000000000000000000000000000000..6cab06bc59975eb692f514c892f4cc47c1807d3c Binary files /dev/null and b/corpus/styles/cold/read/page-003.png differ diff --git a/corpus/styles/cold/read/page-003.xml b/corpus/styles/cold/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..60f71dd65b7be922721730dfa3f9c45bc3e4a52a --- /dev/null +++ b/corpus/styles/cold/read/page-003.xml @@ -0,0 +1,10 @@ + +Heading 1 in Modern]]>Heading 2 in Modern]]>Heading 3 in Modern]]>Heading 4 in Modern]]>Heading 5 in Modern]]>Heading 6 in Modern]]>Page Title in Modern]]>Quote in Modern]]> diff --git a/corpus/styles/cold/read/payloads.json b/corpus/styles/cold/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/styles/cold/read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/styles/cold/run.json b/corpus/styles/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..8c605f3ac1747131b7482d6df45fe748c6729c44 --- /dev/null +++ b/corpus/styles/cold/run.json @@ -0,0 +1,19 @@ +{ + "notebook": "/private/tmp/snowbound-styles/candidate", + "expected_pages": 4, + "author": "/Users/clo/dev/one/tools/native/styles.ps1", + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "author.ps1": "fee6de8f8db4c294d9b39f5cf6428fa6031b52b9158470a3f0aeaf82ef64bfa8", + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35" + } +} diff --git a/corpus/styles/cold/scripts/author.ps1 b/corpus/styles/cold/scripts/author.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..efba77f99a1621c369f3cc3637b8845fd4f225ea --- /dev/null +++ b/corpus/styles/cold/scripts/author.ps1 @@ -0,0 +1,7 @@ +param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost +# Snowbound makes its folder hidden, which the transfer's archive does not keep. +$sidecar = Get-Item -LiteralPath (Join-Path $Root 'notebook\.snowbound') -Force +$sidecar.Attributes = $sidecar.Attributes -bor [IO.FileAttributes]::Hidden diff --git a/corpus/styles/cold/scripts/cold.ps1 b/corpus/styles/cold/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/styles/cold/scripts/cold.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } +$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' +if ($CloneHost) { + if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { + throw 'The disposable clone hostname does not match this machine.' + } + New-Item "$key\Options\Paths" -Force | Out-Null + New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null +} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or + -not (Test-Path 'C:\one-tests\profile-original-cache')) { + throw 'Park the personal OneNote profile before resetting the test cache.' +} +$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' +$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) +if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } +New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null +if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } +if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } +New-Item "$key\OpenNotebooks" | Out-Null +New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null diff --git a/corpus/styles/cold/scripts/read.ps1 b/corpus/styles/cold/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a42366b32e8baaf13dae92e9472cae7a071220cc --- /dev/null +++ b/corpus/styles/cold/scripts/read.ps1 @@ -0,0 +1,144 @@ +param( + [Parameter(Mandatory=$true)][string]$Root, + [int]$ExpectedPages = -1, + [switch]$UseCurrentCache, + [switch]$Pdf, + [switch]$KeepOpen, + [string]$CloneHost = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +$notebook = Join-Path $root 'notebook' +$output = Join-Path $root 'read' +if (Test-Path $output) { throw 'Choose a new read destination.' } +if ($UseCurrentCache) { + if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { + throw 'Park the personal OneNote profile before reading test notebooks.' + } +} else { + & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost +} +New-Item -ItemType Directory -Path $output | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +$failure = $null +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $process = Get-Process ONENOTE + @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; + powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | + ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 + $sections = @() + foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { + $id = '' + $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) + $sections += $id + } + $deadline = [DateTime]::UtcNow.AddSeconds(300) + $previous = '' + $lastChange = '' + $stableSince = [DateTime]::UtcNow + $settled = $false + do { + $pages = @{} + foreach ($section in $sections) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$xml = $hierarchy + foreach ($node in $xml.SelectNodes('//*[@path]')) { + if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { + throw 'OneNote opened a section outside the copied notebook.' + } + } + foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { + $id = $node.GetAttribute('ID') + $content = '' + $app.GetPageContent($id, [ref]$content, 1, 1) + $pages[$id] = $content + } + } + $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) + if ($signature -ne $previous) { + $lastChange = $previous + $previous = $signature + $stableSince = [DateTime]::UtcNow + } + if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or + ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and + ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { + [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) + $index = 0 + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + $index++ + } + $hierarchy = '' + $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) + throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." + } + $index = 0 + $payloads = @() + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + if ($Pdf) { + $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) + $app.NavigateTo($id, '', $false) + $app.Publish($id, $pdfPath, 3, '') + if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } + } + [xml]$page = $pages[$id] + foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { + $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) + # A paragraph's file is named by its paragraph, one on the page by itself. + $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode } + $payloads += @{ page = $id; object = $owner.GetAttribute('objectID'); + kind = $file.LocalName; name = $file.GetAttribute('preferredName'); + sha256 = $hash; bytes = $bytes.Length } + } + $index++ + } + [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) + $all = '' + $app.GetHierarchy($notebookId, 4, [ref]$all, 1) + [xml]$finalTree = $all + $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) + if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { + throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' + } + [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) + Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." +} catch { + $failure = $_ + [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + throw +} finally { + try { + try { + if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } + if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { + $app.CloseNotebook($notebookId, $false) + } + } catch { + if ($null -eq $failure) { throw } + [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + } + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + if (-not $UseCurrentCache -and -not $CloneHost) { + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + } +} diff --git a/corpus/styles/cold/source.json b/corpus/styles/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..3788e618b7f47dc909b3ea59855c8f75ef059db2 --- /dev/null +++ b/corpus/styles/cold/source.json @@ -0,0 +1,38 @@ +[ + { + "path": ".snowbound/themes.json", + "bytes": 669, + "sha256": "0ede06c023cbdfa06fe307225baeb461f5a70e6ce368ae69dec9d405ef5822ac", + "mtime_ns": 1790821816933321351 + }, + { + "path": "Editorial.one", + "bytes": 12096, + "sha256": "0f65b622cdbeee22b146a6259074aeb4fd2b398d2bf982c7d712db31d190b6d1", + "mtime_ns": 1790821816932162894 + }, + { + "path": "Manuscript.one", + "bytes": 12120, + "sha256": "109d595430cf434fabcab11bfe233a34b5182a4b48f8cc5146885ee22936f636", + "mtime_ns": 1790821816931895020 + }, + { + "path": "Modern.one", + "bytes": 12000, + "sha256": "9ea5680d90a9b44bd036d4d50f5bcaae5b8f8ac505af4623e0ac3d0488b09ce2", + "mtime_ns": 1790821816932649894 + }, + { + "path": "OneNote.one", + "bytes": 12072, + "sha256": "5b019d8246db5a2728de5502d30b2a1bfe7a0a5d376c1a5d15ce98ac551ed210", + "mtime_ns": 1790821816932870935 + }, + { + "path": "Open Notebook.onetoc2", + "bytes": 2036, + "sha256": "9f613cf0b5db95b8809fe7d325cae9c37e8b9ae57fa3fff3b7d669649f1b416c", + "mtime_ns": 1790821816932392186 + } +] diff --git a/corpus/styles/cold/teardown.json b/corpus/styles/cold/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/styles/cold/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/styles/onenote-edit/Manuscript.one b/corpus/styles/onenote-edit/Manuscript.one new file mode 100644 index 0000000000000000000000000000000000000000..7a720c29371c07c449eaa4aa59b69703b69ecec4 Binary files /dev/null and b/corpus/styles/onenote-edit/Manuscript.one differ diff --git a/corpus/styles/onenote-edit/Manuscript.png b/corpus/styles/onenote-edit/Manuscript.png new file mode 100644 index 0000000000000000000000000000000000000000..2d5a2ea13d17474553bacf704fc354d0c2441294 Binary files /dev/null and b/corpus/styles/onenote-edit/Manuscript.png differ diff --git a/corpus/styles/onenote-edit/Manuscript.xml b/corpus/styles/onenote-edit/Manuscript.xml new file mode 100644 index 0000000000000000000000000000000000000000..ea02c0f6e873207e85db0593f28cdb34912b253f --- /dev/null +++ b/corpus/styles/onenote-edit/Manuscript.xml @@ -0,0 +1,8 @@ + +Heading 2 in Manuscript]]>Heading 3 in Manuscript]]>Heading 4 in Manuscript]]>Heading 6 in Manuscript]]>Quote in Manuscript]]>Normal in Manuscript]]> diff --git a/corpus/styles/onenote/Styles.one b/corpus/styles/onenote/Styles.one new file mode 100644 index 0000000000000000000000000000000000000000..0a1cb48a4bf475e484d3b904407b47ecc66e4d21 Binary files /dev/null and b/corpus/styles/onenote/Styles.one differ diff --git a/corpus/styles/onenote/applied.xml b/corpus/styles/onenote/applied.xml new file mode 100644 index 0000000000000000000000000000000000000000..ebaf6b989d953d8a68d8667966476e11032946cf --- /dev/null +++ b/corpus/styles/onenote/applied.xml @@ -0,0 +1,16 @@ + +Heading 1 text]]>Heading 2 text]]>Heading 3 text]]>Heading 4 text]]>Heading 6 text]]>Quote text]]>After Quote]]>H1 then italic word]]>Split heading ]]>Here]]>Start enter heading]]>Explicit big bold red then h2]]>Quote then h1]]> diff --git a/corpus/table-widths/README.md b/corpus/table-widths/README.md new file mode 100644 index 0000000000000000000000000000000000000000..3526ae50898baab6737af3498ed45a53112bda9c --- /dev/null +++ b/corpus/table-widths/README.md @@ -0,0 +1,17 @@ +# Table column widths through the editor + +`candidate/` is the output of `typed_and_dragged_columns_store_onenote_s_widths` in +`crates/canvas/tests/table_widths.rs`, editing `corpus/table-tags/native`. On "Check a +table" a three-by-three table is typed after the text a keystroke per revision, each +storing its column's fitted width with its text; on "Edit a tagged table" a native cell +is typed into, widening its column; on "Delete and undo" a new table's first column is +dragged to 96 pt, locking it, and a line long enough to reach the outline's width is +typed in the second. + +`cold/` is a fresh OneNote 2010 read: the typed table's columns are the widths OneNote +gives the same table typed in it (60.76, 37.11 and 139.09 pt), the widened native column +keeps its width, the dragged column stays 96 pt and locked, and the long line wraps in +its column at the outline's edge. `tools/test_table_widths.py` checks this without a VM. +Regenerate with `SNOWBOUND_TABLE_WIDTHS_EXPORT` set to a new absolute directory while +running the test, then cold-open it with +`tools/native_runner.py OUTPUT COLD --expected-pages 3 --collect-notebook --screenshots`. diff --git a/corpus/table-widths/candidate/Open Notebook.onetoc2 b/corpus/table-widths/candidate/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..072b33f33b6b3076cf5757788de6993db4aa6877 Binary files /dev/null and b/corpus/table-widths/candidate/Open Notebook.onetoc2 differ diff --git a/corpus/table-widths/candidate/Tables.one b/corpus/table-widths/candidate/Tables.one new file mode 100644 index 0000000000000000000000000000000000000000..260450fb65f61690ac53af3aa74f5adb1a115c79 Binary files /dev/null and b/corpus/table-widths/candidate/Tables.one differ diff --git a/corpus/table-widths/cold/commands.jsonl b/corpus/table-widths/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..226c9d58dd17ee4f7fc2039401d86b2b4c4187ec --- /dev/null +++ b/corpus/table-widths/cold/commands.jsonl @@ -0,0 +1,3 @@ +{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-F8389DC9 -ExpectedPages 3 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 3 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} diff --git a/corpus/table-widths/cold/machine.json b/corpus/table-widths/cold/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..64e8beebcac7e4860ea2cba58fada86f498fada1 --- /dev/null +++ b/corpus/table-widths/cold/machine.json @@ -0,0 +1 @@ +{"name": "m6-f8389dc9", "hostname": "ONE-M6-F8389DC9"} diff --git a/corpus/table-widths/cold/notebook/Open Notebook.onetoc2 b/corpus/table-widths/cold/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..e5444d85f95322e3dd910f33b09f5e50f6b1c0aa Binary files /dev/null and b/corpus/table-widths/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/table-widths/cold/notebook/Tables.one b/corpus/table-widths/cold/notebook/Tables.one new file mode 100644 index 0000000000000000000000000000000000000000..82e9d2103d7e1c1df09833739886a95a87b6e8a8 Binary files /dev/null and b/corpus/table-widths/cold/notebook/Tables.one differ diff --git a/corpus/table-widths/cold/read/environment.json b/corpus/table-widths/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..526ea192b5532b90546119f83796b66912371738 --- /dev/null +++ b/corpus/table-widths/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-F8389DC9", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/table-widths/cold/read/hierarchy.xml b/corpus/table-widths/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..cfa23966f5b734adefa410b62c7c1dbedcd1a4ee --- /dev/null +++ b/corpus/table-widths/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/table-widths/cold/read/page-000.navigation.json b/corpus/table-widths/cold/read/page-000.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..f96bf51a7b355a67f40c9bb4d1d7f1cf04129a47 --- /dev/null +++ b/corpus/table-widths/cold/read/page-000.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Delete and undo - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/table-widths/cold/read/page-000.png b/corpus/table-widths/cold/read/page-000.png new file mode 100644 index 0000000000000000000000000000000000000000..826b68ddc2625d9945a9997e9fe88b6647762cc6 Binary files /dev/null and b/corpus/table-widths/cold/read/page-000.png differ diff --git a/corpus/table-widths/cold/read/page-000.xml b/corpus/table-widths/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..92afc6f0b1daec063ee0737d44d19dafb45fbe35 --- /dev/null +++ b/corpus/table-widths/cold/read/page-000.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/table-widths/cold/read/page-001.navigation.json b/corpus/table-widths/cold/read/page-001.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..c40a308d9c3c9026ea7bafe144254a80248bacb1 --- /dev/null +++ b/corpus/table-widths/cold/read/page-001.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Edit a tagged table - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/table-widths/cold/read/page-001.png b/corpus/table-widths/cold/read/page-001.png new file mode 100644 index 0000000000000000000000000000000000000000..e2ad9494a5b061bc2428bbccecca13eabf28e9c0 Binary files /dev/null and b/corpus/table-widths/cold/read/page-001.png differ diff --git a/corpus/table-widths/cold/read/page-001.xml b/corpus/table-widths/cold/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..891acef918b921931d2224a065a32e89ce2d3c71 --- /dev/null +++ b/corpus/table-widths/cold/read/page-001.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/table-widths/cold/read/page-002.navigation.json b/corpus/table-widths/cold/read/page-002.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..0a7b9e0ec6b6277b956d5177ea958a524786fb8b --- /dev/null +++ b/corpus/table-widths/cold/read/page-002.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Check a table - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/table-widths/cold/read/page-002.png b/corpus/table-widths/cold/read/page-002.png new file mode 100644 index 0000000000000000000000000000000000000000..ddaa7aff31142b4501aa54f04d34a87dcef4abbe Binary files /dev/null and b/corpus/table-widths/cold/read/page-002.png differ diff --git a/corpus/table-widths/cold/read/page-002.xml b/corpus/table-widths/cold/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..791beb24d7b431e87abe887c2fa8bce7f2ec7c89 --- /dev/null +++ b/corpus/table-widths/cold/read/page-002.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/table-widths/cold/read/payloads.json b/corpus/table-widths/cold/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/table-widths/cold/read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/table-widths/cold/run.json b/corpus/table-widths/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..bedb24582e2bed7b6cb7c416abe04d0297ec5912 --- /dev/null +++ b/corpus/table-widths/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/snowbound-tables/candidate", + "expected_pages": 3, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35" + } +} diff --git a/corpus/table-widths/cold/scripts/cold.ps1 b/corpus/table-widths/cold/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/table-widths/cold/scripts/cold.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } +$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' +if ($CloneHost) { + if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { + throw 'The disposable clone hostname does not match this machine.' + } + New-Item "$key\Options\Paths" -Force | Out-Null + New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null +} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or + -not (Test-Path 'C:\one-tests\profile-original-cache')) { + throw 'Park the personal OneNote profile before resetting the test cache.' +} +$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' +$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) +if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } +New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null +if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } +if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } +New-Item "$key\OpenNotebooks" | Out-Null +New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null diff --git a/corpus/table-widths/cold/scripts/read.ps1 b/corpus/table-widths/cold/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a42366b32e8baaf13dae92e9472cae7a071220cc --- /dev/null +++ b/corpus/table-widths/cold/scripts/read.ps1 @@ -0,0 +1,144 @@ +param( + [Parameter(Mandatory=$true)][string]$Root, + [int]$ExpectedPages = -1, + [switch]$UseCurrentCache, + [switch]$Pdf, + [switch]$KeepOpen, + [string]$CloneHost = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +$notebook = Join-Path $root 'notebook' +$output = Join-Path $root 'read' +if (Test-Path $output) { throw 'Choose a new read destination.' } +if ($UseCurrentCache) { + if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { + throw 'Park the personal OneNote profile before reading test notebooks.' + } +} else { + & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost +} +New-Item -ItemType Directory -Path $output | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +$failure = $null +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $process = Get-Process ONENOTE + @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; + powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | + ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 + $sections = @() + foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { + $id = '' + $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) + $sections += $id + } + $deadline = [DateTime]::UtcNow.AddSeconds(300) + $previous = '' + $lastChange = '' + $stableSince = [DateTime]::UtcNow + $settled = $false + do { + $pages = @{} + foreach ($section in $sections) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$xml = $hierarchy + foreach ($node in $xml.SelectNodes('//*[@path]')) { + if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { + throw 'OneNote opened a section outside the copied notebook.' + } + } + foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { + $id = $node.GetAttribute('ID') + $content = '' + $app.GetPageContent($id, [ref]$content, 1, 1) + $pages[$id] = $content + } + } + $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) + if ($signature -ne $previous) { + $lastChange = $previous + $previous = $signature + $stableSince = [DateTime]::UtcNow + } + if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or + ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and + ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { + [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) + $index = 0 + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + $index++ + } + $hierarchy = '' + $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) + throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." + } + $index = 0 + $payloads = @() + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + if ($Pdf) { + $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) + $app.NavigateTo($id, '', $false) + $app.Publish($id, $pdfPath, 3, '') + if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } + } + [xml]$page = $pages[$id] + foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { + $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) + # A paragraph's file is named by its paragraph, one on the page by itself. + $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode } + $payloads += @{ page = $id; object = $owner.GetAttribute('objectID'); + kind = $file.LocalName; name = $file.GetAttribute('preferredName'); + sha256 = $hash; bytes = $bytes.Length } + } + $index++ + } + [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) + $all = '' + $app.GetHierarchy($notebookId, 4, [ref]$all, 1) + [xml]$finalTree = $all + $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) + if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { + throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' + } + [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) + Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." +} catch { + $failure = $_ + [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + throw +} finally { + try { + try { + if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } + if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { + $app.CloseNotebook($notebookId, $false) + } + } catch { + if ($null -eq $failure) { throw } + [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + } + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + if (-not $UseCurrentCache -and -not $CloneHost) { + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + } +} diff --git a/corpus/table-widths/cold/source.json b/corpus/table-widths/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..49ab44891d129a5d739922182daa417eb9f78fcc --- /dev/null +++ b/corpus/table-widths/cold/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 1604, + "sha256": "78250a5c9a50d89ba0b9a0cb725c8afe0c9cbc538a8f39e8a3cde75d2aebed65", + "mtime_ns": 1790820308278651723 + }, + { + "path": "Tables.one", + "bytes": 35192, + "sha256": "b45ea79ddcb9c9f30992e9beee4919993578c295cc81605ca3d97fbbfc4d2eac", + "mtime_ns": 1790820308259896484 + } +] diff --git a/corpus/table-widths/cold/teardown.json b/corpus/table-widths/cold/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/table-widths/cold/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/crates/canvas/examples/print_pdf.rs b/crates/canvas/examples/print_pdf.rs index a2615124b8b16e09fdfc558c48d7ed16c3208190..7f1f00d66da4f4a8c8dacfa3cb08759e5d10daa0 100644 --- a/crates/canvas/examples/print_pdf.rs +++ b/crates/canvas/examples/print_pdf.rs @@ -33,8 +33,16 @@ fn main() -> Result<(), Box> { .file_stem() .and_then(|stem| stem.to_str()) .unwrap_or_default(); - let paper = if a4 { print::A4 } else { print::LETTER }; - let pdf = print::pdf(pages, &mut TextEngine::default(), paper, name)?; + let setup = print::Setup { + paper: if a4 { print::A4 } else { print::LETTER }, + fit_width: true, + footer: print::Footer::SectionAndPage, + }; + let pdf = print::pdf( + vec![(name.to_owned(), pages)], + &mut TextEngine::default(), + &setup, + )?; fs::write(destination, pdf)?; Ok(()) } diff --git a/crates/canvas/examples/scratch_eqref.rs b/crates/canvas/examples/scratch_eqref.rs deleted file mode 100644 index 9f5aab70bf7be18cbc32f13a60e08fa50a84b333..0000000000000000000000000000000000000000 --- a/crates/canvas/examples/scratch_eqref.rs +++ /dev/null @@ -1,667 +0,0 @@ -//! Every place the canvas shows a page's content as a placeholder, draws it read-only, or -//! refuses an ordinary edit as content it cannot edit, across the active pages of every -//! section under the given directories, grouped by kind as Markdown. Identical sections -//! count once. -//! -//! `cargo run --release -p canvas --features gpu --example unsupported_inventory -- corpus` - -use canvas::{ - document::TextPosition, - editor::{CanvasEditor, EditorError, TextOutline}, - layout::{LayoutError, TextEngine}, - outline::Arrange, -}; -use onestore::{ - Arena, ExGuid, Section, - page::{ - Image, Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, - text::{Affinity, EditError}, - }, -}; -use std::{ - collections::{BTreeMap, BTreeSet, hash_map::DefaultHasher}, - hash::{Hash, Hasher}, - path::{Path, PathBuf}, -}; - -#[derive(Default)] -struct Inventory { - /// Kind to the places it occurs, as `section — page`. - kinds: BTreeMap<(Stage, String), Vec>, - sections: usize, - pages: usize, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] -enum Stage { - /// Drawn as an "Unsupported content" (or "Image unavailable") box. - Placeholder, - /// Drawn as stored, but the editor cannot hold it. - ReadOnly, - /// An ordinary edit the editor refuses as content it cannot edit. - Refused, - /// The page or section does not open at all. - Failure, -} - -impl Inventory { - fn add(&mut self, stage: Stage, kind: impl Into, place: &str) { - self.kinds - .entry((stage, kind.into())) - .or_default() - .push(place.to_owned()); - } -} - -/// MS-ONE's names for the object kinds it documents, and the observed undocumented ones. -fn jcid_name(jcid: u32) -> String { - let name = match jcid { - 0x60007 => "section", - 0x60008 => "page series", - 0x6000b => "page", - 0x6000c => "outline", - 0x6000d => "outline element", - 0x6000e => "rich text", - 0x60011 => "picture", - 0x60012 => "number list", - 0x60014 => "ink container", - 0x60019 => "outline group", - 0x60022 => "table", - 0x60023 => "table row", - 0x60024 => "table cell", - 0x6002c => "title", - 0x60035 => "embedded file", - 0x60037 => "page manifest", - 0x6003c => "version history", - 0x6003d => "version proxy", - _ => "undocumented", - }; - format!("jcid 0x{jcid:05x} ({name})") -} - -fn unsupported_error(error: &EditorError) -> bool { - matches!( - error, - EditorError::Layout(LayoutError::UnsupportedContent) - | EditorError::Edit(EditError::UnsupportedContent) - ) -} - -/// What a text paragraph holds that bears on editing it. -fn traits(text: &Paragraph) -> Vec<&'static str> { - let spans = text.spans(); - let mut traits = Vec::new(); - if text.text().contains('\u{fffc}') - || spans.iter().any(|s| s.format.embedded_object == Some(true)) - { - traits.push("embedded object in text"); - } - if spans.iter().any(|s| s.format.math == Some(true)) { - traits.push("equation"); - } - if spans.iter().any(|s| s.format.hyperlink == Some(true)) { - traits.push("link"); - } - if spans.iter().any(|s| s.format.hidden == Some(true)) { - traits.push("hidden text"); - } - if text.text().is_empty() { - traits.push("empty"); - } - traits -} - -/// Why `node`, alone in an outline, does not lay out as content the canvas understands. -fn culprit( - engine: &mut TextEngine, - outline: &Outline, - node: &PageParagraph, - page: &Page, -) -> Option { - let alone = Outline { - paragraphs: vec![PageParagraph { - parent: None, - level: 1, - ..node.clone() - }], - unsupported: Vec::new(), - ..outline.clone() - }; - match alone.layout(engine, &page.definitions) { - Err(LayoutError::UnsupportedContent) => {} - _ => return None, - } - Some(match &node.content { - ParagraphContent::Unsupported(u) => format!("holds {}", jcid_name(u.jcid)), - ParagraphContent::Table(table) if !table.tags.is_empty() => "table with note tags".into(), - ParagraphContent::Table(table) => { - let cells = || table.rows.iter().flat_map(|row| &row.cells); - if let Some(cell) = cells().find(|cell| !cell.unsupported.is_empty()) { - format!( - "table cell holding {}", - cell.unsupported - .iter() - .map(|u| jcid_name(u.jcid)) - .collect::>() - .join(", ") - ) - } else { - let inner = cells() - .flat_map(|cell| &cell.paragraphs) - .find_map(|inner| culprit(engine, outline, inner, page)); - format!( - "table cell: {}", - inner.unwrap_or_else(|| "nested content".into()) - ) - } - } - ParagraphContent::Image(_) => "picture without a size".into(), - ParagraphContent::Ink(_) => "handwriting without strokes".into(), - ParagraphContent::Attachment(_) => "file".into(), - ParagraphContent::Text(text) => { - let lists: Vec = node - .lists - .iter() - .filter_map(|id| page.definitions.get(id)) - .map(|definition| format!("{:?}", definition.kind)) - .collect(); - if !lists.is_empty() { - format!("list marker {}", lists.join(", ")) - } else if text - .text - .spans() - .iter() - .any(|s| s.format.math == Some(true)) - { - "equation the math layout does not parse".into() - } else { - format!("text ({})", traits(&text.text).join(", ")) - } - } - }) -} - -/// Why an outline the page shows is not editable. -fn diagnose(engine: &mut TextEngine, outline: &Outline, page: &Page) -> Vec { - let mut reasons: Vec = outline - .unsupported - .iter() - .map(|u| format!("outline child {}", jcid_name(u.jcid))) - .collect(); - reasons.extend( - outline - .paragraphs - .iter() - .filter_map(|node| culprit(engine, outline, node, page)), - ); - if reasons.is_empty() { - let text = outline.paragraphs.iter().any(|node| { - node.text().is_some() - || matches!(&node.content, ParagraphContent::Table(t) - if t.rows.iter().flat_map(|r| &r.cells).any(|c| !c.paragraphs.is_empty())) - }); - reasons.push(if text { - match TextOutline::from_outline(engine, outline, &page.definitions) { - Err(error) => format!("editor: {error}"), - Ok(_) => "outline on its own is editable".into(), - } - } else { - let kinds: BTreeSet<&str> = outline - .paragraphs - .iter() - .map(|node| match node.content { - ParagraphContent::Image(_) => "pictures", - ParagraphContent::Attachment(_) => "files", - ParagraphContent::Ink(_) => "handwriting", - _ => "other", - }) - .collect(); - format!( - "no paragraph of text (only {})", - kinds.into_iter().collect::>().join(", ") - ) - }); - } - reasons -} - -/// A picture that draws as "Image unavailable": no data, or (with `--features gpu`) data -/// the renderer does not decode, named by its leading bytes. -fn picture(image: &Image, at: &str, inventory: &mut Inventory, place: &str) { - let Some(bytes) = &image.bytes else { - return inventory.add( - Stage::Placeholder, - format!("{at} picture without data"), - place, - ); - }; - #[cfg(feature = "gpu")] - if [Some(bytes), image.display.as_ref()] - .into_iter() - .flatten() - .all(|bytes| draw::RasterImage::measure(bytes).is_err()) - { - let format = match &bytes[..bytes.len().min(4)] { - [0x50, 0x4b, 3, 4] => "a zip package (XPS printout)", - [1, 0, 0, 0] => "EMF", - [0xd7, 0xcd, 0xc6, 0x9a] | [1, 0, 9, 0] => "WMF", - [b'B', b'M', ..] => "BMP", - _ => "an unknown format", - }; - inventory.add( - Stage::Placeholder, - format!("{at} picture stored as {format}"), - place, - ); - } - #[cfg(not(feature = "gpu"))] - let _ = bytes; -} - -/// Pictures that draw as "Image unavailable" and objects the model keeps aside. -fn scan_paragraphs(nodes: &[PageParagraph], at: &str, inventory: &mut Inventory, place: &str) { - for node in nodes { - match &node.content { - ParagraphContent::Unsupported(u) => inventory.add( - Stage::Placeholder, - format!("{at} paragraph holding {}", jcid_name(u.jcid)), - place, - ), - ParagraphContent::Image(image) => picture(image, at, inventory, place), - ParagraphContent::Table(table) => { - for cell in table.rows.iter().flat_map(|row| &row.cells) { - for u in &cell.unsupported { - inventory.add( - Stage::Placeholder, - format!("table cell child {}", jcid_name(u.jcid)), - place, - ); - } - scan_paragraphs(&cell.paragraphs, "table cell", inventory, place); - } - } - _ => {} - } - } -} - -/// Ordinary edits at every text paragraph of every editable outline: typing at its start, -/// middle and end, Enter, Backspace at its start, Delete at its end, and deleting from its -/// middle into the next. -fn probe( - editor: &mut CanvasEditor, - engine: &mut TextEngine, - inventory: &mut Inventory, - place: &str, -) { - let ids: Vec = editor.outlines().iter().map(|o| o.id).collect(); - for id in ids { - if editor.focus_outline(id).is_err() { - continue; - } - let document = editor.active_outline().document(); - let containers = containers(document.nodes()); - let leaves: Vec<(Option, Paragraph)> = document - .text_nodes() - .map(|node| { - ( - containers.get(&node.id).copied().flatten(), - node.text().unwrap().text.clone(), - ) - }) - .collect(); - for (index, (container, text)) in leaves.iter().enumerate() { - let length = text.text().encode_utf16().count() as u32; - // The middle of what shows, where a click could put the caret. - let Ok(projection) = text.project() else { - continue; - }; - let shown = projection.text(); - let middle = shown - .text() - .char_indices() - .map(|(at, _)| at) - .nth(shown.text().chars().count() / 2) - .and_then(|at| shown.utf16_offset(at).ok()) - .and_then(|at| projection.source_offset(at, Affinity::Downstream).ok()) - .unwrap_or(length); - let at = |offset| TextPosition { - paragraph: index, - offset, - }; - let next = leaves.get(index + 1); - let mut probes: Vec<(&str, [TextPosition; 2], Probe)> = vec![ - ("typing at a paragraph's start", [at(0); 2], Probe::Type), - ("typing inside a paragraph", [at(middle); 2], Probe::Type), - ("typing at a paragraph's end", [at(length); 2], Probe::Type), - ("Enter inside a paragraph", [at(middle); 2], Probe::Enter), - ( - "Delete at a paragraph's end", - [at(length); 2], - Probe::Delete, - ), - ]; - if index > 0 { - probes.push(( - "Backspace at a paragraph's start", - [at(0); 2], - Probe::Backspace, - )); - } - if let Some((_, next_text)) = next { - let end = next_text.text().encode_utf16().count() as u32 / 2; - probes.push(( - "deleting a selection into the next paragraph", - [ - at(middle), - TextPosition { - paragraph: index + 1, - offset: end, - }, - ], - Probe::Backspace, - )); - } - for (name, positions, kind) in probes { - if editor.select(positions.into()).is_err() { - continue; - } - let result = match kind { - Probe::Type => editor.insert(engine, "x").map(|()| true), - Probe::Enter => editor.enter(engine, false).map(|()| true), - Probe::Delete => editor.delete(engine, false), - Probe::Backspace => editor.delete(engine, true), - }; - match result { - Ok(true) => { - editor.undo(engine).ok(); - } - Ok(false) => {} - Err(error) if unsupported_error(&error) => { - let show = |t: &Paragraph, o: u32| { - let b = t.byte_offset(o).unwrap_or(0); - let v = |s: &str| s.replace('\u{fdd0}', "⟨").replace('\u{fdef}', "⟩").replace('\u{fdee}', "|").replace('\u{fffc}', "¤"); - format!("{}‸{}", v(&t.text()[..b]), v(&t.text()[b..])) - }; - let a = show(text, positions[0].offset); - let b = if positions[1].paragraph != index { show(&leaves[positions[1].paragraph].1, positions[1].offset) } else { String::new() }; - eprintln!("REFUSED {name} @ {place}\n A: {a}\n B: {b}"); - let crossing = positions[0].paragraph != positions[1].paragraph - || name.starts_with("Backspace") - || name.starts_with("Delete"); - let neighbour = match name { - n if n.starts_with("Backspace") => leaves.get(index - 1), - _ if crossing => next, - _ => None, - }; - let mut why: Vec<&str> = traits(text); - if let Some((other, other_text)) = neighbour { - if other != container { - why.push("across a table cell boundary"); - } - why.extend(traits(other_text).into_iter().map(|t| match t { - "embedded object in text" => "next to an embedded object", - "equation" => "next to an equation", - "link" => "next to a link", - t => t, - })); - } - if container.is_some() { - why.push("in a table cell"); - } - why.sort(); - why.dedup(); - inventory.add( - Stage::Refused, - format!("{name} [{}]", why.join(", ")), - place, - ); - } - Err(_) => {} - } - } - } - } -} - -#[derive(Clone, Copy)] -enum Probe { - Type, - Enter, - Delete, - Backspace, -} - -/// Each paragraph's table cell, `None` at the outline's top. -fn containers(nodes: &[PageParagraph]) -> BTreeMap> { - let mut map = BTreeMap::new(); - let mut pending = vec![(None, nodes)]; - while let Some((container, nodes)) = pending.pop() { - for node in nodes { - map.insert(node.id, container); - if let ParagraphContent::Table(table) = &node.content { - for cell in table.rows.iter().flat_map(|row| &row.cells) { - pending.push((Some(cell.id), cell.paragraphs.as_slice())); - } - } - } - } - map -} - -fn page(engine: &mut TextEngine, page: Page, place: &str, inventory: &mut Inventory) { - for object in &page.objects { - match object { - PageObject::Unsupported(u) => inventory.add( - Stage::Placeholder, - format!("page object {}", jcid_name(u.jcid)), - place, - ), - PageObject::Image(image) => picture(image, "page", inventory, place), - PageObject::Outline(outline) => { - for u in &outline.unsupported { - inventory.add( - Stage::Placeholder, - format!("outline child {}", jcid_name(u.jcid)), - place, - ); - } - scan_paragraphs(&outline.paragraphs, "outline", inventory, place); - } - PageObject::Title(title) => { - for outline in &title.outlines { - scan_paragraphs(&outline.paragraphs, "title", inventory, place); - } - } - _ => {} - } - } - let mut editor = match CanvasEditor::from_page(page.clone(), engine) { - Ok(editor) => editor, - Err(error) => { - inventory.add( - Stage::Failure, - format!("page does not open: {error}"), - place, - ); - return; - } - }; - for object in &page.objects { - let (outlines, title) = match object { - PageObject::Outline(outline) => (std::slice::from_ref(outline), None), - PageObject::Title(title) => (title.outlines.as_slice(), Some(title)), - _ => continue, - }; - for outline in outlines { - if editor.has_page_outline(outline.id) - || editor.outlines().iter().any(|o| o.id == outline.id) - { - continue; - } - if let Some(title) = title { - if title.date == Some(outline.id) { - if editor.date().is_none() { - inventory.add(Stage::ReadOnly, "page date the date editor declines", place); - } - continue; - } - if !outline.title { - continue; - } - } - let placeholder = matches!( - outline.layout(engine, &page.definitions), - Err(LayoutError::UnsupportedContent) - ); - let at = if title.is_some() { "title" } else { "outline" }; - for reason in diagnose(engine, outline, &page) { - let stage = if placeholder { - Stage::Placeholder - } else { - Stage::ReadOnly - }; - let whole = if placeholder { - format!("whole {at} as a box: {reason}") - } else { - format!("{at} read-only: {reason}") - }; - inventory.add(stage, whole, place); - } - } - } - probe(&mut editor, engine, inventory, place); -} - -fn section(engine: &mut TextEngine, path: &Path, root: &Path, inventory: &mut Inventory) { - let Ok(image) = std::fs::read(path) else { - return; - }; - let name = path - .strip_prefix(root) - .unwrap_or(path) - .display() - .to_string(); - let arena = Arena::default(); - let mut section = match Section::open(&arena, image) { - Ok(section) => section, - Err(error) => { - inventory.add( - Stage::Failure, - format!("section does not open: {}", error.message), - &name, - ); - return; - } - }; - inventory.sections += 1; - let pages = match section.pages() { - Ok(pages) => pages, - Err(error) => { - inventory.add( - Stage::Failure, - format!("page list does not read: {}", error.message), - &name, - ); - return; - } - }; - for (space, title, _) in pages { - inventory.pages += 1; - let place = format!("{name} — {title}"); - match section.page(space) { - Ok(model) => page(engine, model, &place, inventory), - Err(error) => inventory.add( - Stage::Failure, - format!("page does not read: {}", error.message), - &place, - ), - } - } -} - -fn sections(directory: &Path, out: &mut Vec) { - let Ok(entries) = std::fs::read_dir(directory) else { - return; - }; - let mut entries: Vec = entries.flatten().map(|entry| entry.path()).collect(); - entries.sort(); - for path in entries { - if path.is_dir() { - sections(&path, out); - } else if path.extension().is_some_and(|e| e == "one") { - out.push(path); - } - } -} - -fn main() { - let roots: Vec = std::env::args().skip(1).map(PathBuf::from).collect(); - let mut engine = TextEngine::default(); - let mut inventory = Inventory::default(); - let mut seen = BTreeSet::new(); - let mut duplicates = 0; - for root in &roots { - let mut paths = Vec::new(); - sections(root, &mut paths); - for path in paths { - let Ok(bytes) = std::fs::read(&path) else { - continue; - }; - let mut hasher = DefaultHasher::new(); - bytes.hash(&mut hasher); - if !seen.insert(hasher.finish()) { - duplicates += 1; - continue; - } - section(&mut engine, &path, root, &mut inventory); - } - } - println!("# Unsupported content inventory\n"); - println!( - "{} distinct sections ({duplicates} identical copies skipped), {} pages, under {}.\n", - inventory.sections, - inventory.pages, - roots - .iter() - .map(|r| format!("`{}`", r.display())) - .collect::>() - .join(", ") - ); - let headings = [ - (Stage::Placeholder, "Drawn as a placeholder box"), - (Stage::ReadOnly, "Drawn as stored, read-only"), - ( - Stage::Refused, - "Edits refused as content the editor cannot edit", - ), - (Stage::Failure, "Does not open"), - ]; - for (stage, heading) in headings { - let mut rows: Vec<(&String, &Vec)> = inventory - .kinds - .iter() - .filter(|((s, _), _)| *s == stage) - .map(|((_, kind), places)| (kind, places)) - .collect(); - rows.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then(a.0.cmp(b.0))); - println!("## {heading}\n"); - if rows.is_empty() { - println!("None.\n"); - continue; - } - println!("| Kind | Count | Pages | Examples |\n| --- | ---: | ---: | --- |"); - for (kind, places) in rows { - let pages: BTreeSet<&String> = places.iter().collect(); - let examples: Vec = pages.iter().take(3).map(|p| format!("`{p}`")).collect(); - println!( - "| {} | {} | {} | {} |", - kind.replace('|', "\\|"), - places.len(), - pages.len(), - examples.join("
").replace('|', "\\|") - ); - } - println!(); - } -} diff --git a/crates/canvas/examples/scratch_files.rs b/crates/canvas/examples/scratch_files.rs deleted file mode 100644 index 6c458d6a182c00b8e003cf3e211a47fd58620915..0000000000000000000000000000000000000000 --- a/crates/canvas/examples/scratch_files.rs +++ /dev/null @@ -1,15 +0,0 @@ -use onestore::{RevisionIndex, Store, document::{Document, Kind}}; -fn main() { - let bytes = std::fs::read(std::env::args().nth(1).unwrap()).unwrap(); - let store = Store::parse(&bytes).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - let document = Document::parse(&index).unwrap(); - for (space, _) in document.pages().unwrap() { - let revision = document.active(space).unwrap(); - for (id, node) in &revision.nodes { - if let Kind::File { extension, payload, .. } = &node.kind { - println!("{id:?} {extension} {:?}", payload.map(|p| (p.len(), &p[..4.min(p.len())]))); - } - } - } -} diff --git a/crates/canvas/examples/scratch_runs.rs b/crates/canvas/examples/scratch_runs.rs deleted file mode 100644 index 33b31e9ebba3048c62ffe441927688290c8187e5..0000000000000000000000000000000000000000 --- a/crates/canvas/examples/scratch_runs.rs +++ /dev/null @@ -1,23 +0,0 @@ -use onestore::{Arena, Section, page::{PageObject}}; -fn main() { - let path = std::env::args().nth(1).unwrap(); - let want = std::env::args().nth(2).unwrap_or_default(); - let arena = Arena::default(); - let mut section = Section::open(&arena, std::fs::read(path).unwrap()).unwrap(); - for (space, _, _) in section.pages().unwrap() { - let page = section.page(space).unwrap(); - for object in &page.objects { - let PageObject::Outline(outline) = object else { continue }; - for p in &outline.paragraphs { - let Some(text) = p.text() else { continue }; - if !text.text.text().contains(want.as_str()) { continue } - let mut start = 0; - println!("-- {:?}", p.id); - for span in text.text.spans() { - println!(" {:?} math={:?} obj={:?} emb={:?}", &text.text.text()[start..span.end], span.format.math, span.format.math_object, span.format.embedded_object); - start = span.end; - } - } - } - } -} diff --git a/crates/canvas/examples/unsupported_inventory.rs b/crates/canvas/examples/unsupported_inventory.rs index 2443ce8f43c091f1c86fdeedb903eaddf342e558..1eddfb8e8978911e343a06355216be265ac1522e 100644 --- a/crates/canvas/examples/unsupported_inventory.rs +++ b/crates/canvas/examples/unsupported_inventory.rs @@ -468,6 +468,25 @@ fn page(engine: &mut TextEngine, page: Page, place: &str, inventory: &mut Invent _ => {} } } + // Pictures with pixels the scene still shows as placeholders, for want of a size or the + // like; undecodable ones are counted above. + #[cfg(feature = "gpu")] + if let Ok((scene, shown)) = canvas::gpu::page::PageScene::from_page(page.clone(), engine) { + for object in scene.read_only(Some(&shown)) { + if let PageObject::Image(image) = &object.source + && [image.bytes.as_ref(), image.display.as_ref()] + .into_iter() + .flatten() + .any(|bytes| draw::RasterImage::measure(bytes).is_ok()) + { + inventory.add( + Stage::Placeholder, + "page picture with pixels drawn as \"Image unavailable\"", + place, + ); + } + } + } let mut editor = match CanvasEditor::from_page(page.clone(), engine) { Ok(editor) => editor, Err(error) => { diff --git a/crates/canvas/src/editor.rs b/crates/canvas/src/editor.rs index e99e394f84450d108456ec3dac63641cc8b926b9..41d0f75a8a86709d7f77d612090af2febf4b8222 100644 --- a/crates/canvas/src/editor.rs +++ b/crates/canvas/src/editor.rs @@ -36,7 +36,8 @@ mod recording; mod space; mod table; pub use format::{ - Alignment, BULLET_LIBRARY, FormatState, Formatting, ListStyle, NUMBER_LIBRARY, NoteTag, Toggle, + Alignment, BULLET_LIBRARY, DefaultFont, FormatState, Formatting, ListStyle, NUMBER_LIBRARY, + NoteTag, Toggle, }; pub use ink::{FAVORITES, Pen}; pub use link::{Link, shown_urls}; @@ -81,6 +82,47 @@ pub struct Selection { pub affinities: [Affinity; 2], } +/// A piece of pasted content, in the order it comes. +pub enum Piece { + Text(String), + /// An encoded picture and its size in points. + Picture(Vec, [f32; 2]), + /// A picture still on its way, which ends the paragraph and goes in where + /// [`CanvasEditor::paste_pieces`] says once it arrives. + Awaited, +} + +/// Where an awaited picture goes: before paragraph `before` of outline `outline`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Awaited { + outline: ExGuid, + before: ExGuid, +} + +/// A new picture of encoded `bytes`, `size` points large. +pub(crate) fn picture( + bytes: Vec, + size: [f32; 2], +) -> Result { + Ok(onestore::page::Image { + id: onestore::page::text::new_id()?, + layout: onestore::document::Layout { + max_width: Some(size[0]), + max_height: Some(size[1]), + ..Default::default() + }, + size: Some(size), + bytes: Some(bytes.into()), + display: None, + alt: None, + background: false, + printout: None, + tags: Vec::new(), + link: None, + text: None, + }) +} + /// What OneNote 2010's Ctrl+A selects beyond text: the focused outline as an object, then every /// body outline on the page. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -102,7 +144,7 @@ pub struct CanvasEditor { pub(crate) objects: Vec, date: Option, outlines: Vec, - definitions: BTreeMap, + pub(crate) definitions: BTreeMap, header: PageHeader, active: Focus, undo: Vec, @@ -130,6 +172,12 @@ pub struct CanvasEditor { /// and whether it is stored: the first op that reaches it stores it, and undoing back to it /// empty and alone removes it again, as OneNote 2010's undo does. provisional: BTreeMap, + /// What new outlines' text is set in; `PageView::replace` hands it to the next page's + /// editor. + pub default_font: DefaultFont, + /// The paragraph styles the page's theme gives, by stored name: new text takes its `p`, + /// and Enter a NextStyle the page lacks from here. + pub styles: BTreeMap, } /// Imported page state the editable content does not carry. @@ -847,6 +895,11 @@ enum History { index: usize, ink: Option>, }, + /// Gives a picture or file on the page back the note tags it had. + Tags { + object: ExGuid, + tags: Vec, + }, /// Gives a drawing back the strokes it had. Strokes { ink: ExGuid, @@ -912,6 +965,8 @@ impl CanvasEditor { recording: None, ink_drag: None, provisional: BTreeMap::new(), + default_font: DefaultFont::default(), + styles: BTreeMap::new(), }) } @@ -1110,6 +1165,7 @@ impl CanvasEditor { History::Date(_) => date_changed, History::Paper { .. } => false, History::Image { .. } + | History::Tags { .. } | History::Picture { .. } | History::File { .. } | History::Ink { .. } @@ -1153,6 +1209,8 @@ impl CanvasEditor { fresh.preferred_x = self.preferred_x; fresh.ops = std::mem::replace(&mut self.ops, Ok(Vec::new())); fresh.stored = self.stored.take(); + fresh.default_font = std::mem::take(&mut self.default_font); + fresh.styles = std::mem::take(&mut self.styles); *self = fresh; Ok(true) } @@ -1230,6 +1288,12 @@ impl CanvasEditor { // OneNote stores it, holds only those its paragraphs reference. let mut referenced = BTreeSet::new(); for object in &objects { + let tags = match object { + PageObject::Image(image) => image.tags.as_slice(), + PageObject::Attachment(file) => file.tags.as_slice(), + _ => &[], + }; + referenced.extend(tags.iter().filter_map(|tag| tag.definition)); let outlines = match object { PageObject::Outline(outline) => std::slice::from_ref(outline), PageObject::Title(title) => title.outlines.as_slice(), @@ -1327,6 +1391,11 @@ impl CanvasEditor { self.header.color } + /// The page's notebook-management identity, which internal links name. + pub fn identity(&self) -> Option<[u8; 16]> { + self.header.identity + } + /// The page's rule lines (View, Rule Lines). pub fn rule_lines(&self) -> Option { self.header.rule_lines @@ -1408,16 +1477,8 @@ impl CanvasEditor { page::Content::Image(image) if !image.background => { let x = image.layout.x.unwrap_or(0.0); let y = image.layout.y.unwrap_or(0.0); - Some(( - image.id, - &image.layout, - [ - x, - y, - x + image.layout.max_width?, - y + image.layout.max_height?, - ], - )) + let [width, height] = crate::outline::image_size(image)?; + Some((image.id, &image.layout, [x, y, x + width, y + height])) } page::Content::ReadOnly(object) if !matches!(object.source, onestore::page::PageObject::Title(_)) => @@ -1508,6 +1569,8 @@ impl CanvasEditor { recording: None, ink_drag: None, provisional: BTreeMap::new(), + default_font: DefaultFont::default(), + styles: BTreeMap::new(), }) } @@ -1709,7 +1772,7 @@ impl CanvasEditor { position: [f32; 2], width: f32, ) -> Result<(), EditorError> { - let document = TextDocument::new(vec![Paragraph::new(String::new(), Default::default())])?; + let document = TextDocument::from_nodes(vec![self.body_paragraph()?])?; let outline = TextOutline::new(engine, document, width, position)?; self.finish_composition(); self.active = Focus::Caret { @@ -1736,6 +1799,34 @@ impl CanvasEditor { }) } + /// A new outline's first paragraph as OneNote 2010 starts one: in the page's `p` quick + /// style of the Default font, which a page whose `p` is in another font gains beside it. + fn body_paragraph(&mut self) -> Result { + let definition = match self.styles.get("p") { + Some(themed) => themed.clone(), + None => self.default_font.body_style(), + }; + let known = self.definitions.iter().find(|(_, known)| { + matches!(&known.kind, Kind::Style { name: Some(name), .. } if name == "p") + && known.format.font == definition.format.font + && known.format.font_size == definition.format.font_size + }); + let style = match known { + Some((id, _)) => *id, + None => { + let id = onestore::page::text::new_id()?; + self.definitions.insert(id, definition.clone()); + id + } + }; + let mut node = crate::document::node( + Paragraph::new(String::new(), definition.format), + onestore::document::Format::default(), + )?; + node.style = Some(style); + Ok(node) + } + /// An empty paragraph in `base`'s style, whose runs keep `base`'s language as OneNote's do. fn blank_paragraph(&self, base: &PageParagraph) -> Result { let mut format = self.style_format(base.style)?; @@ -1844,10 +1935,7 @@ impl CanvasEditor { position: [f32; 2], width: f32, ) -> Result { - let document = TextDocument::new(vec![Paragraph::new( - String::new(), - onestore::document::Format::default(), - )])?; + let document = TextDocument::from_nodes(vec![self.body_paragraph()?])?; let outline = TextOutline::new(engine, document, width, position)?; let id = outline.id; self.finish_composition(); @@ -1935,7 +2023,7 @@ impl CanvasEditor { let layout = &image.layout; return Some(( [layout.x.unwrap_or(0.0), layout.y.unwrap_or(0.0)], - [layout.max_width?, layout.max_height?], + crate::outline::image_size(image)?, )); } let (outline, ..) = self.outline_picture(id)?; @@ -1963,6 +2051,18 @@ impl CanvasEditor { }) } + /// The address picture `id` links to, in an outline or on the page. + pub fn picture_link(&self, id: ExGuid) -> Option<&str> { + let image = match self.image(id) { + Some(image) => image, + None => match &self.outline_picture(id)?.3.content { + ParagraphContent::Image(image) => image, + _ => return None, + }, + }; + image.link.as_deref() + } + /// The file a paragraph holds or the page shows, which the host opens and saves. pub fn attachment(&self, id: ExGuid) -> Option<&onestore::page::Attachment> { if let Some((file, _)) = self.page_file(id) { @@ -2024,6 +2124,36 @@ impl CanvasEditor { }) } + fn object_tags_mut(&mut self, id: ExGuid) -> Option<&mut Vec> { + self.objects.iter_mut().find_map(|object| match object { + page::Content::Image(image) if image.id == id => Some(&mut image.tags), + page::Content::File { source, .. } if source.id == id => Some(&mut source.tags), + _ => None, + }) + } + + /// Gives picture or file `id` on the page `tags`, returning those it had. + fn swap_object_tags( + &mut self, + id: ExGuid, + tags: Vec, + ) -> Vec { + let previous = std::mem::replace(self.object_tags_mut(id).unwrap(), tags); + let tags = self.object_tags_mut(id).unwrap().clone(); + self.record(Ok(ops::tags(id, tags, &self.definitions))); + previous + } + + /// Gives picture or file `id` on the page `tags`, as one undo step. + pub(super) fn set_object_tags(&mut self, id: ExGuid, tags: Vec) { + let previous = self.swap_object_tags(id, tags); + self.undo.push(History::Tags { + object: id, + tags: previous, + }); + self.redo.clear(); + } + fn image_mut(&mut self, id: ExGuid) -> Option<&mut onestore::page::Image> { self.objects.iter_mut().find_map(|object| match object { page::Content::Image(image) if image.id == id && !image.background => Some(image), @@ -2079,7 +2209,7 @@ impl CanvasEditor { let image = self.image(id).ok_or(EditError::InvalidRange)?; let mut layout = image.layout.clone(); [layout.x, layout.y] = origin.map(Some); - if [layout.max_width, layout.max_height] != size.map(Some) { + if crate::outline::image_size(image) != Some(size) { [layout.max_width, layout.max_height] = size.map(Some); layout.width_set_by_user = Some(true); } @@ -2180,12 +2310,39 @@ impl CanvasEditor { /// Puts `image` at the caret as OneNote 2010 pastes and inserts a picture: in an outline /// as [`Self::insert_attachment`] puts a file, and on blank page at the caret with the - /// caret moving to the grid row below it, where typing starts a new outline. + /// caret moving to the grid row below it, where typing starts a new outline. From the + /// title it goes at the end of an outline where the body starts, in a new one there on a + /// page without body content, and otherwise on the page two grid rows below the content + /// (lab, 2026-09-30). pub fn insert_picture( &mut self, engine: &mut TextEngine, mut image: onestore::page::Image, ) -> Result<(), EditorError> { + let margin = self.margin_origin(); + if self.active_outline().title { + let start = self.body_start_of(self.active_outline()); + let first = self + .outlines + .iter() + .find(|outline| !outline.title && outline.origin() == start) + .map(|outline| outline.id); + if let Some(id) = first { + self.focus_outline(id)?; + self.move_selection(engine, Movement::DocumentEnd, false)?; + return self.insert_in_flow(engine, ParagraphContent::Image(image)); + } + let Some(bottom) = self.body_bottom() else { + self.place_caret(engine, start, DEFAULT_OUTLINE_WIDTH)?; + return self.insert_in_flow(engine, ParagraphContent::Image(image)); + }; + let row = ((bottom - margin[1]) / 18.0).floor() + 2.0; + self.place_caret( + engine, + [start[0], margin[1] + row * 18.0], + DEFAULT_OUTLINE_WIDTH, + )?; + } let Focus::Caret { outline, .. } = &self.active else { return self.insert_in_flow(engine, ParagraphContent::Image(image)); }; @@ -2198,9 +2355,123 @@ impl CanvasEditor { self.add_picture(index, image); self.undo.push(History::Picture { index, image: None }); self.redo.clear(); - let margin = self.margin_origin()[1]; - let row = ((y + height - margin) / 18.0).floor() + 1.0; - self.place_caret(engine, [x, margin + row * 18.0], DEFAULT_OUTLINE_WIDTH) + let row = ((y + height - margin[1]) / 18.0).floor() + 1.0; + self.place_caret(engine, [x, margin[1] + row * 18.0], DEFAULT_OUTLINE_WIDTH) + } + + /// Pastes text in `language` and pictures in order, as one undo step, as OneNote 2010 + /// pastes a web page's text with its pictures; the places of the awaited ones, in order. + pub fn paste_pieces( + &mut self, + engine: &mut TextEngine, + pieces: Vec, + language: u32, + ) -> Result, EditorError> { + let depth = self.undo.len(); + let mut result = Ok(()); + let mut awaited = Vec::new(); + for piece in pieces { + result = match piece { + Piece::Text(text) => self.paste(engine, &text, language), + Piece::Picture(bytes, size) => picture(bytes, size) + .map_err(EditorError::from) + .and_then(|image| self.insert_picture(engine, image)), + Piece::Awaited => self.enter(engine, false).and_then(|()| { + let caret = self.selection().positions[1]; + let outline = self.active_outline(); + let (.., node) = outline + .document + .leaf(caret.paragraph) + .ok_or(EditError::InvalidRange)?; + awaited.push(Awaited { + outline: outline.id, + before: node.id, + }); + Ok(()) + }), + }; + if result.is_err() { + break; + } + } + let entries = self.undo.split_off(depth); + if entries.len() > 1 { + self.undo.push(History::Group { + entries, + page: false, + }); + } else { + self.undo.extend(entries); + } + result.map(|()| awaited) + } + + /// Puts an awaited picture in its place as an undo step of its own, keeping the focused + /// outline's selection; false where the place has gone. + pub fn insert_awaited( + &mut self, + engine: &mut TextEngine, + at: Awaited, + image: onestore::page::Image, + ) -> Result { + let Some(outline) = self.outlines.iter().find(|outline| outline.id == at.outline) else { + return Ok(false); + }; + let Some((container, index, node)) = descendants(outline.document.nodes(), None) + .find(|(.., node)| node.id == at.before) + .map(|(container, index, node)| (container, index, node.clone())) + else { + return Ok(false); + }; + let picture = PageParagraph { + id: onestore::page::text::new_id()?, + content: ParagraphContent::Image(image), + style: None, + lists: Vec::new(), + tags: Vec::new(), + collapsed: false, + ..node + }; + // A caret on blank page is let go; picture paragraphs take no text positions. + let focused = match self.active { + Focus::Outline(index) => Some((self.outlines[index].id, self.selection())), + _ => None, + }; + self.focus_outline(at.outline)?; + let selection = self.selection(); + self.commit( + engine, + DocumentEdit { + columns: BTreeMap::new(), + container, + range: index..index, + replacement: vec![picture], + }, + selection, + )?; + if let Some((id, selection)) = focused { + self.focus_outline(id)?; + self.select(selection)?; + } + Ok(true) + } + + /// The lowest edge of the page's body content, in page points; none on an empty page. + fn body_bottom(&self) -> Option { + let outlines = self + .outlines + .iter() + .filter(|outline| !outline.title) + .map(|outline| outline.bounds().y1 as f32); + let objects = self + .objects + .iter() + .filter(|object| !object.picture().is_some_and(|image| image.background)) + .filter_map(|object| { + let (_, layout) = object.layout()?; + Some(layout.y? + layout.max_height?) + }); + outlines.chain(objects).reduce(f32::max) } /// Attaches `file` at the caret as OneNote 2010 does (`corpus/attachment-insert`): the @@ -2316,19 +2587,7 @@ impl CanvasEditor { }) .ok_or(EditError::InvalidRange)?; self.finish_composition(); - let mut image = self.remove_picture(index); - // A printout page's XPS package cannot be written back, so undoing its deletion - // restores the picture OneNote showed of it. - if image.printout.is_some() - && image - .bytes - .as_deref() - .is_some_and(|bytes| bytes.starts_with(b"PK\x03\x04")) - && let Some(shown) = image.display.take() - { - image.bytes = Some(shown); - image.printout = None; - } + let image = self.remove_picture(index); self.undo.push(History::Picture { index, image: Some(Box::new(image)), @@ -3401,8 +3660,13 @@ impl CanvasEditor { tail.tags = std::mem::take(&mut head.tags); tail.text_mut().unwrap().tags = std::mem::take(&mut head.text_mut().unwrap().tags); } - let style = |id: Option| match &self.definitions.get(&id?)?.kind { - Kind::Style { name } => name.as_deref(), + // Enter at a paragraph's end gives the new one its style's NextStyle, as OneNote 2010 + // follows a heading with Normal. + let following = match head.style.and_then(|id| self.definitions.get(&id)) { + Some(Definition { + kind: Kind::Style { name, next }, + .. + }) => next.clone().filter(|next| Some(next) != name.as_ref()), _ => None, }; if empty(head) && empty(tail) { @@ -3411,16 +3675,9 @@ impl CanvasEditor { tail.lists.clear(); } } else if empty(tail) - && matches!( - style(head.style), - Some("h1" | "h2" | "h3" | "h4" | "h5" | "h6") - ) + && let Some(following) = following { - tail.style = self - .definitions - .keys() - .copied() - .find(|id| style(Some(*id)) == Some("p")); + tail.style = self.style_named(&following)?; // The heading's run formatting carries over under the body style (`c9-h1-*`). let run = format.over(&self.style_format(head.style)?); tail.text_mut().unwrap().text = @@ -3730,6 +3987,26 @@ impl CanvasEditor { !self.redo.is_empty() } + /// How many steps Undo and Redo hold. + pub fn history_depth(&self) -> [usize; 2] { + [self.undo.len(), self.redo.len()] + } + + /// Takes up `parked`, the editor this page had when it was last left, history and all, + /// in place of this one: what storage changed since reaches it as `refresh` would. + pub fn resume( + &mut self, + mut parked: CanvasEditor, + engine: &mut TextEngine, + ) -> Result<(), EditorError> { + if let Some((page, _)) = &self.stored { + parked.refresh(page.clone(), engine)?; + parked.default_font = std::mem::take(&mut self.default_font); + *self = parked; + } + Ok(()) + } + pub fn undo(&mut self, engine: &mut TextEngine) -> Result { if self.composition.is_some() { return self.cancel_composition(engine); @@ -3842,6 +4119,7 @@ impl CanvasEditor { .iter() .all(|id| self.outlines.iter().any(|item| item.id == **id)), History::Image { image, .. } => self.image(*image).is_some(), + History::Tags { object, .. } => self.object_tags_mut(*object).is_some(), History::Picture { index, image: Some(_), @@ -4073,6 +4351,10 @@ impl CanvasEditor { layout: previous, } } + History::Tags { object, tags } => History::Tags { + object, + tags: self.swap_object_tags(object, tags), + }, History::Image { image, layout } => { let previous = std::mem::replace(&mut self.image_mut(image).unwrap().layout, layout); @@ -4262,6 +4544,7 @@ impl CanvasEditor { .replace(range.clone(), replacement)?; self.own_lists(&mut edit)?; self.link_notes(&mut edit)?; + self.fit_columns(engine, &mut edit)?; let inverse = self.apply( engine, edit, @@ -4361,6 +4644,7 @@ impl CanvasEditor { ) -> Result<(), EditorError> { self.own_lists(&mut edit)?; self.link_notes(&mut edit)?; + self.fit_columns(engine, &mut edit)?; let inverse = self.apply(engine, edit, selection, true, None)?; self.record_change(inverse); Ok(()) @@ -6152,6 +6436,9 @@ mod tests { alt: None, background, printout: None, + tags: Vec::new(), + link: None, + text: None, }); let (id, background_id) = (picture.id, background.id); let mut editor = CanvasEditor::from_page( @@ -6260,6 +6547,9 @@ mod tests { alt: None, background, printout: None, + tags: Vec::new(), + link: None, + text: None, }; let [old, picture] = [image(true, b"old art"), image(false, b"picture")]; let mut editor = CanvasEditor::from_page( @@ -6349,6 +6639,9 @@ mod tests { alt: None, background: false, printout: None, + tags: Vec::new(), + link: None, + text: None, }; let id = picture.id; let objects = vec![ @@ -6414,6 +6707,9 @@ mod tests { alt: None, background: false, printout: None, + tags: Vec::new(), + link: None, + text: None, }; picture.content = ParagraphContent::Image(image.clone()); source.paragraphs.insert(1, picture); @@ -6562,6 +6858,7 @@ mod tests { bytes: Some(std::sync::Arc::from(b"payload".as_slice())), preview: None, recording: None, + tags: Vec::new(), }; let at = |paragraph, offset| Selection { positions: [TextPosition { paragraph, offset }; 2], @@ -6651,6 +6948,9 @@ mod tests { alt: None, background: false, printout: None, + tags: Vec::new(), + link: None, + text: None, }); let (mixed_id, picture_id) = (mixed.id, picture_only.id); let editor = CanvasEditor::from_page( @@ -6736,6 +7036,9 @@ mod tests { alt: None, background, printout: None, + tags: Vec::new(), + link: None, + text: None, }); let image_ids = images.each_ref().map(|image| image.id); let image_bytes = images @@ -6990,6 +7293,7 @@ mod tests { Definition { kind: onestore::document::Kind::Style { name: Some("p".into()), + next: None, }, format: base.clone(), }, @@ -7057,6 +7361,7 @@ mod tests { Definition { kind: onestore::document::Kind::Style { name: Some("p".into()), + next: None, }, format: base.clone(), }, @@ -7258,6 +7563,47 @@ mod tests { assert_eq!(editor.outlines[0].document, source); } + /// New text takes the page's `p` quick style in the Default font, and a page whose `p` + /// is in another font gains a second `p` beside it, as OneNote 2010 does + /// (`corpus/default-font`). + #[test] + fn new_outlines_take_a_body_style_in_the_default_font() { + let mut engine = TextEngine::default(); + let source = + TextDocument::new(vec![Paragraph::new("body".into(), Format::default())]).unwrap(); + let mut editor = CanvasEditor::new(&mut engine, source, 240.0).unwrap(); + let style = |editor: &CanvasEditor, outline: usize| { + let node = &editor.outlines[outline].document.nodes()[0]; + let definition = &editor.definitions[&node.style.unwrap()]; + assert_eq!( + node.text().unwrap().text.spans()[0].format, + definition.format + ); + (node.style.unwrap(), definition.clone()) + }; + editor.default_font = DefaultFont { + face: "Georgia".into(), + size: 14.0, + color: None, + }; + for (typed, at) in [("one", 300.0), ("two", 400.0)] { + editor.place_caret(&mut engine, [300.0, at], 240.0).unwrap(); + editor.insert(&mut engine, typed).unwrap(); + } + let (first, georgia) = style(&editor, 1); + assert_eq!(style(&editor, 2).0, first, "one p per font"); + assert_eq!(georgia, editor.default_font.body_style()); + editor.default_font = DefaultFont::default(); + editor + .place_caret(&mut engine, [300.0, 500.0], 240.0) + .unwrap(); + editor.insert(&mut engine, "three").unwrap(); + let (third, calibri) = style(&editor, 3); + assert_ne!(third, first); + assert_eq!(calibri.format.font.as_deref(), Some("Calibri")); + assert_eq!(calibri.format.font_size, Some(11.0)); + } + #[test] fn deleting_through_provisional_paragraphs_removes_the_outline_and_restores_draft_on_undo() { let mut engine = TextEngine::default(); @@ -9314,6 +9660,9 @@ mod tests { alt: None, background: false, printout: None, + tags: Vec::new(), + link: None, + text: None, }); if objects { let selection = [TextPosition { diff --git a/crates/canvas/src/editor/evidence.rs b/crates/canvas/src/editor/evidence.rs index 21e92c05ac22102c3a301dcbbde4da240392e514..20a3129a03e7d652750efd6799a57d85fffcf889 100644 --- a/crates/canvas/src/editor/evidence.rs +++ b/crates/canvas/src/editor/evidence.rs @@ -47,6 +47,7 @@ fn open(engine: &mut TextEngine, summary: &str) -> CanvasEditor { Definition { kind: Kind::Style { name: Some(name.into()), + next: (name == "h1").then(|| "p".into()), }, format: calibri(if name == "p" { 11.0 } else { 16.0 }, name != "p"), }, @@ -182,7 +183,7 @@ fn render(editor: &CanvasEditor) -> String { let style = node .style .map(|style| match &editor.definitions[&style].kind { - Kind::Style { name } => { + Kind::Style { name, .. } => { format!("qs{}", usize::from(name.as_deref() == Some("h1"))) } kind => panic!("{kind:?}"), diff --git a/crates/canvas/src/editor/format.rs b/crates/canvas/src/editor/format.rs index 2daf7dfa2e1dabd7518192800d28b7560f76a9b9..af9e92437a5a14f3ae0878c601b2dbba243150cc 100644 --- a/crates/canvas/src/editor/format.rs +++ b/crates/canvas/src/editor/format.rs @@ -43,6 +43,10 @@ pub enum Formatting { /// Format Painter: gives the selection the character formatting and alignment of text /// picked up with [`CanvasEditor::painted_format`], as OneNote's does. Paint(Format), + /// Gives the selected paragraphs a paragraph style, as OneNote 2010's Styles gallery + /// does: the page's style of that definition, made where it has none, and their text + /// loses its character formatting but links, fields and language. + Style(Definition), } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -164,6 +168,57 @@ impl ListStyle { } } +/// OneNote's Default font (Options > General): what new outlines' text is set in, as the +/// page's `p` quick style, and the face and colour new titles take. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct DefaultFont { + pub face: String, + pub size: f32, + /// COLORREF; none is Automatic. + pub color: Option, +} + +impl Default for DefaultFont { + /// OneNote 2010's: Calibri 11, Automatic. + fn default() -> Self { + Self { + face: "Calibri".into(), + size: 11.0, + color: None, + } + } +} + +impl DefaultFont { + /// The `p` quick style OneNote 2010 stores for new text in this font + /// (`corpus/default-font`): plain, in its colour, no highlight or paragraph spacing. + pub(crate) fn body_style(&self) -> Definition { + Definition { + kind: Kind::Style { + name: Some("p".into()), + next: None, + }, + format: Format { + bold: Some(false), + italic: Some(false), + underline: Some(false), + strike: Some(false), + superscript: Some(false), + subscript: Some(false), + font: Some(self.face.clone()), + font_size: Some(self.size), + color: Some(self.color.unwrap_or(0xff00_0000)), + highlight: Some(0xff00_0000), + space_before: Some(0.0), + space_after: Some(0.0), + line_spacing: Some(0.0), + ..Format::default() + }, + } + } +} + /// A tag of the user's list as the Customize Tags dialog edits it: what its definition /// stores besides its place in the list, which the page stores as its action type. #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -293,6 +348,8 @@ pub struct FormatState { pub list: Option, /// Tags every selected paragraph has, with their action types. pub tags: Vec<(NoteTag, u16)>, + /// The stored name of the paragraph style every selected paragraph shares. + pub style: Option, } impl Toggle { @@ -477,6 +534,35 @@ fn common(mut values: impl Iterator>) -> Option Format { + let mut format = format.clone(); + macro_rules! follow { + ($($field:ident),*) => {$( + if format.$field.is_none() || old.$field.is_some() && format.$field == old.$field { + format.$field = new.$field.clone(); + } + )*}; + } + follow!( + bold, + italic, + underline, + strike, + superscript, + subscript, + font, + font_size, + color, + highlight, + space_before, + space_after, + line_spacing + ); + format +} + /// Seconds since 1980, as note tags date themselves. fn time32() -> Option { let now = std::time::SystemTime::now() @@ -629,6 +715,12 @@ impl CanvasEditor { shared.sort_by_key(|(_, action_type)| *action_type); shared }), + style: common(paragraphs.iter().map(|node| { + match &self.definitions.get(&node.style?)?.kind { + Kind::Style { name, .. } => name.clone(), + _ => None, + } + })), }) } @@ -765,36 +857,8 @@ impl CanvasEditor { let ParagraphContent::Text(text) = &mut node.content else { unreachable!() }; - // An element holds one tag of each action type. - for tags in [&mut node.tags, &mut text.tags] { - tags.retain(|tag| match self.tag_kind(tag) { - Some(kind) if remove => *kind != definition.kind, - Some(Kind::TagDefinition { - action_type: other, .. - }) => *other != Some(*action_type), - _ => true, - }); - } - if !remove { - let checkable = crate::outline::checkable(tag.shape); - // Stored newest first. - text.tags.insert( - 0, - Tag { - definition: Some(id), - action_type: None, - shape: None, - property_status: None, - status: u16::from(!checkable), - created, - completed: if checkable { Some(0) } else { created }, - start: None, - due: None, - task_id: None, - extra_set: 0, - }, - ); - } + let added = (!remove).then_some((id, tag.shape, created)); + self.retag([&mut node.tags, &mut text.tags], &definition.kind, added); }); } Formatting::RemoveTags => leaves_mut(&mut replacement, &mut |node| { @@ -804,6 +868,39 @@ impl CanvasEditor { } }), Formatting::Check => self.check(&mut replacement, ranges, ends), + Formatting::Style(_) if title => return Ok(()), + Formatting::Style(definition) => { + let olds = bases(&replacement)?; + let style = self.define_style(definition)?; + leaves_mut(&mut replacement, &mut |node| { + if ranges.remove(&node.id).is_some() { + node.style = Some(style); + let old = &olds[&node.id]; + let text = &mut node.text_mut().unwrap().text; + *text = restyle(text, 0..text.text().len(), |format| { + // An equation keeps its own formatting; what it took from the + // old style it takes from the new. + if [format.math, format.embedded_object].contains(&Some(true)) { + *format = followed(format, old, &definition.format); + return; + } + *format = Format { + hidden: format.hidden, + hyperlink: format.hyperlink, + hyperlink_label: format.hyperlink_label, + math: format.math, + embedded_object: format.embedded_object, + language: format.language, + alignment: format.alignment, + rtl: format.rtl, + list_spacing: format.list_spacing, + math_object: format.math_object.clone(), + ..definition.format.clone() + }; + }); + } + }); + } Formatting::Toggle(_) | Formatting::Font(_) | Formatting::FontSize(_) @@ -906,6 +1003,43 @@ impl CanvasEditor { } } +impl CanvasEditor { + /// The page's paragraph style holding `definition`, made where it has none. + pub(super) fn define_style(&mut self, definition: &Definition) -> Result { + if let Some((id, _)) = self + .definitions + .iter() + .find(|(_, kept)| *kept == definition) + { + return Ok(*id); + } + let id = new_id()?; + self.definitions.insert(id, definition.clone()); + Ok(id) + } + + /// A paragraph style named `name`: the page's in its theme's formatting, else the page's + /// first of that name, else the theme's made on the page; Normal falls back to the + /// Default font's. None where nothing names it. + pub(super) fn style_named(&mut self, name: &str) -> Result, EditError> { + let themed = self.styles.get(name).cloned(); + let named = |definition: &Definition| matches!(&definition.kind, Kind::Style { name: Some(own), .. } if own == name); + let known = self + .definitions + .iter() + .filter(|(_, kept)| named(kept)) + .min_by_key(|(_, kept)| Some(*kept) != themed.as_ref()) + .map(|(id, _)| *id); + if known.is_some() { + return Ok(known); + } + let definition = themed.or_else(|| (name == "p").then(|| self.default_font.body_style())); + definition + .map(|definition| self.define_style(&definition)) + .transpose() + } +} + impl CanvasEditor { /// A toolbar command on OneNote 2010's page selection: each outline takes it as if selected /// alone, but a toggle turns on everywhere unless every outline already has it. @@ -991,18 +1125,174 @@ impl CanvasEditor { .find(|(_, _, node)| node.id == id) .ok_or(EditError::InvalidRange)?; let mut node = node.clone(); - let checkable = |tag: &Tag| { - matches!( - self.tag_kind(tag), - Some(Kind::TagDefinition { shape: Some(shape), .. }) - if crate::outline::checkable(*shape) - ) - }; let content = match &mut node.content { ParagraphContent::Table(table) => table.tags.as_mut_slice(), + ParagraphContent::Image(image) => image.tags.as_mut_slice(), + ParagraphContent::Attachment(file) => file.tags.as_mut_slice(), _ => &mut [], }; - let mut tags: Vec<&mut Tag> = node.tags.iter_mut().chain(content).collect(); + self.toggle_checks(node.tags.iter_mut().chain(content).collect()); + let selection = outline.selection; + self.commit( + engine, + DocumentEdit { + columns: BTreeMap::new(), + container, + range: index..index + 1, + replacement: vec![node], + }, + selection, + ) + } + + /// A tag command on selected picture or file `id`, which OneNote 2010 tags itself rather + /// than its paragraph (`corpus/object-tags`); false for other commands. + pub fn format_object( + &mut self, + engine: &mut TextEngine, + id: ExGuid, + command: &Formatting, + ) -> Result { + if !matches!( + command, + Formatting::Tag(..) | Formatting::RemoveTags | Formatting::Check + ) { + return Ok(false); + } + let node = self + .outline_picture(id) + .map(|(outline, _, _, node)| (outline, node.clone())); + let mut tags = match &node { + Some((_, node)) => match &node.content { + ParagraphContent::Image(image) => image.tags.clone(), + ParagraphContent::Attachment(file) => file.tags.clone(), + _ => return Ok(false), + }, + None => self + .object_tags_mut(id) + .ok_or(EditError::InvalidRange)? + .clone(), + }; + match command { + Formatting::Tag(tag, action_type) => { + let definition = tag.definition(*action_type); + let existing = self + .definitions + .iter() + .find(|(_, other)| other.kind == definition.kind) + .map(|(id, _)| *id); + let defined = match existing { + Some(id) => id, + None => { + let id = new_id()?; + self.definitions.insert(id, definition.clone()); + id + } + }; + let has = tags + .iter() + .any(|tag| self.tag_kind(tag) == Some(&definition.kind)); + let added = (!has).then_some((defined, tag.shape, time32())); + self.retag([&mut tags], &definition.kind, added); + } + Formatting::RemoveTags => tags.clear(), + _ => self.toggle_checks(tags.iter_mut().collect()), + } + self.finish_composition(); + let Some((outline, mut node)) = node else { + self.set_object_tags(id, tags); + return Ok(true); + }; + match &mut node.content { + ParagraphContent::Image(image) => image.tags = tags, + ParagraphContent::Attachment(file) => file.tags = tags, + _ => unreachable!(), + } + self.focus_outline(outline)?; + let (container, index, _) = descendants(self.active_outline().document.nodes(), None) + .find(|(_, _, other)| other.id == node.id) + .ok_or(EditError::InvalidRange)?; + let selection = self.active_outline().selection; + self.commit( + engine, + DocumentEdit { + columns: BTreeMap::new(), + container, + range: index..index + 1, + replacement: vec![node], + }, + selection, + )?; + Ok(true) + } + + /// A click on the check box of picture or file `id` on the page. + pub fn click_object_check(&mut self, id: ExGuid) -> Result<(), EditorError> { + let mut tags = self + .objects + .iter() + .find_map(|object| object.tagged().filter(|tagged| tagged.0 == id)) + .ok_or(EditError::InvalidRange)? + .1 + .to_vec(); + self.toggle_checks(tags.iter_mut().collect()); + self.finish_composition(); + self.set_object_tags(id, tags); + Ok(()) + } + + /// Takes tags of `kind` from `lists`, or with `added`, the definition's identity, its + /// shape and when, gives the last list one in place of any of its action type; an element + /// holds one tag of each action type, stored newest first. + fn retag( + &self, + mut lists: [&mut Vec; N], + kind: &Kind<'static>, + added: Option<(ExGuid, u16, Option)>, + ) { + let Kind::TagDefinition { action_type, .. } = kind else { + return; + }; + for tags in lists.iter_mut() { + tags.retain(|tag| match self.tag_kind(tag) { + Some(other) if added.is_none() => other != kind, + Some(Kind::TagDefinition { + action_type: other, .. + }) => other != action_type, + _ => true, + }); + } + let (Some((id, shape, created)), Some(tags)) = (added, lists.last_mut()) else { + return; + }; + let checkable = crate::outline::checkable(shape); + tags.insert( + 0, + Tag { + definition: Some(id), + action_type: None, + shape: None, + property_status: None, + status: u16::from(!checkable), + created, + completed: if checkable { Some(0) } else { created }, + start: None, + due: None, + task_id: None, + extra_set: 0, + }, + ); + } + + /// Checks the check boxes among `tags`, or clears them once all are checked. + fn toggle_checks(&self, mut tags: Vec<&mut Tag>) { + let checkable = |tag: &Tag| { + matches!( + self.tag_kind(tag), + Some(Kind::TagDefinition { shape: Some(shape), .. }) + if crate::outline::checkable(*shape) + ) + }; let checked = tags .iter() .filter(|tag| checkable(tag)) @@ -1014,17 +1304,6 @@ impl CanvasEditor { tag.completed = completed; } } - let selection = outline.selection; - self.commit( - engine, - DocumentEdit { - columns: BTreeMap::new(), - container, - range: index..index + 1, - replacement: vec![node], - }, - selection, - ) } /// Checks the check boxes of the `covered` paragraphs, or clears them once all are @@ -1344,6 +1623,80 @@ mod tests { ); } + /// A style from the gallery replaces the paragraph's character formatting but keeps its + /// language, as OneNote 2010's does; Enter at its end takes its NextStyle, from the + /// theme where the page has none (lab, 2026-09-30). + #[test] + fn a_gallery_style_clears_formatting_and_enter_takes_its_next_style() { + let mut engine = TextEngine::default(); + let body = Format { + font: Some("Calibri".into()), + font_size: Some(11.0), + language: Some(0x40c), + ..Format::default() + }; + let source = TextDocument::new(vec![Paragraph::from_runs([ + ("Plain ".to_owned(), body.clone()), + ( + "red".to_owned(), + Format { + color: Some(0xff), + italic: Some(true), + ..body.clone() + }, + ), + ])]) + .unwrap(); + let mut editor = CanvasEditor::new(&mut engine, source, 240.0).unwrap(); + let style = |name: &str, size, next: Option<&str>| Definition { + kind: Kind::Style { + name: Some(name.into()), + next: next.map(Into::into), + }, + format: Format { + bold: Some(name != "p"), + italic: Some(false), + font: Some("Georgia".into()), + font_size: Some(size), + color: Some(0x0033_2211), + space_before: Some(12.0), + ..Format::default() + }, + }; + let heading = style("h2", 16.0, Some("p")); + editor.styles = BTreeMap::from([("p".to_owned(), style("p", 12.0, None))]); + editor.select([at(0, 3); 2].into()).unwrap(); + editor + .format(&mut engine, Formatting::Style(heading.clone())) + .unwrap(); + let node = editor.active_outline().document.nodes()[0].clone(); + assert_eq!(editor.definitions[&node.style.unwrap()], heading); + let spans = node.text().unwrap().text.spans().to_vec(); + assert_eq!(spans.len(), 1); + assert_eq!( + spans[0].format, + Format { + language: Some(0x40c), + ..heading.format.clone() + } + ); + assert_eq!(editor.format_state().unwrap().style.as_deref(), Some("h2")); + let length = node.text().unwrap().text.text().len() as u32; + editor.select([at(0, length); 2].into()).unwrap(); + editor.enter(&mut engine, false).unwrap(); + let next = editor.active_outline().document.nodes()[1].style; + assert_eq!(editor.definitions[&next.unwrap()], editor.styles["p"]); + // Enter after Normal keeps Normal. + editor.insert(&mut engine, "body").unwrap(); + editor.enter(&mut engine, false).unwrap(); + assert_eq!(editor.active_outline().document.nodes()[2].style, next); + editor.undo(&mut engine).unwrap(); + editor.undo(&mut engine).unwrap(); + editor.undo(&mut engine).unwrap(); + editor.undo(&mut engine).unwrap(); + assert_eq!(editor.active_outline().document.nodes()[0].style, None); + } + #[test] fn clearing_and_removing_attributes_return_to_the_paragraph_style() { let mut engine = TextEngine::default(); @@ -1377,6 +1730,7 @@ mod tests { Definition { kind: Kind::Style { name: Some("h1".into()), + next: None, }, format: heading.clone(), }, @@ -2199,6 +2553,7 @@ mod tests { (NoteTag::defaults()[0].clone(), 0), (NoteTag::defaults()[2].clone(), 2) ], + style: state.style.clone(), } ); // Format Painter, a gallery tag and an inserted table write as well. diff --git a/crates/canvas/src/editor/ops.rs b/crates/canvas/src/editor/ops.rs index d3e0362ddcd1201d2f8f8c55bb1534fa4c356f80..0860c4102ec7da943753d0d5ef1a71cce28515a6 100644 --- a/crates/canvas/src/editor/ops.rs +++ b/crates/canvas/src/editor/ops.rs @@ -46,6 +46,7 @@ fn reaches(op: &PageOp, outline: ExGuid, blank: &PageParagraph) -> bool { | PageOp::Outline { object: id, .. } | PageOp::Paragraph { paragraph: id, .. } | PageOp::Style { paragraph: id, .. } + | PageOp::Unstyle { paragraph: id } | PageOp::Media { paragraph: id, .. } | PageOp::List { paragraph: id, .. } | PageOp::Tags { target: id, .. } => own(id), @@ -58,6 +59,8 @@ pub(super) fn references(node: &PageParagraph) -> impl Iterator + let content_tags = match &node.content { ParagraphContent::Text(text) => text.tags.as_slice(), ParagraphContent::Table(table) => table.tags.as_slice(), + ParagraphContent::Image(image) => image.tags.as_slice(), + ParagraphContent::Attachment(file) => file.tags.as_slice(), _ => &[], }; node.lists.iter().copied().chain(node.style).chain( @@ -283,6 +286,26 @@ pub(super) fn layout_ops(id: ExGuid, old: &Layout, new: &Layout) -> Lowered { } /// A page picture's stored position, size and description. +/// The op giving `target` `tags`, with the definitions they name. +pub(super) fn tags( + target: ExGuid, + tags: Vec, + definitions: &BTreeMap, +) -> Vec { + let definitions = tags + .iter() + .filter_map(|tag| tag.definition) + .collect::>() + .into_iter() + .filter_map(|id| Some((id, definitions.get(&id)?.clone()))) + .collect(); + vec![PageOp::Tags { + target, + tags, + definitions, + }] +} + pub(super) fn picture_layout(image: &onestore::page::Image) -> Vec { vec![PageOp::Picture { picture: image.id, diff --git a/crates/canvas/src/editor/page.rs b/crates/canvas/src/editor/page.rs index 0d4815d1a531975586caffaade9ca16919764159..dd0b87d96f241f0c04141c58ac7da1a204974647 100644 --- a/crates/canvas/src/editor/page.rs +++ b/crates/canvas/src/editor/page.rs @@ -100,6 +100,22 @@ impl Content { Some((source, [x + x0, y + y0, x + x1, y + y1])) } + /// A tagged picture or file: its identity, note tags and bounds in page points. + pub(crate) fn tagged( + &self, + ) -> Option<(onestore::ExGuid, &[onestore::document::Tag], [f32; 4])> { + let (id, tags, bounds) = match self { + Self::Image(image) => { + let [x, y] = [image.layout.x, image.layout.y].map(|v| v.unwrap_or(0.0)); + let [width, height] = crate::outline::image_size(image)?; + (image.id, &image.tags, [x, y, x + width, y + height]) + } + Self::File { source, .. } => (source.id, &source.tags, self.file()?.1), + _ => return None, + }; + (!tags.is_empty()).then_some((id, tags.as_slice(), bounds)) + } + pub(super) fn layout_mut( &mut self, ) -> Option<(onestore::ExGuid, &mut onestore::document::Layout)> { @@ -354,27 +370,17 @@ pub(crate) fn build( } } PageObject::Image(source) => { - if source.bytes.is_none() - || source.layout.max_width.is_none() - || source.layout.max_height.is_none() - { + // A picture without a layout size shows at its own, as COM inserts them. + let Some(size) = + crate::outline::image_size(source).filter(|_| source.bytes.is_some()) + else { objects.push(Content::unavailable(object, engine)?); continue; - } + }; let origin = [ source.layout.x.unwrap_or(0.0), source.layout.y.unwrap_or(0.0), ]; - let size = [ - source - .layout - .max_width - .ok_or(EditorError::InvalidGeometry)?, - source - .layout - .max_height - .ok_or(EditorError::InvalidGeometry)?, - ]; if origin.iter().any(|v| !v.is_finite()) || size.iter().any(|v| !v.is_finite() || *v <= 0.0) || !(origin[0] + size[0]).is_finite() diff --git a/crates/canvas/src/editor/recording.rs b/crates/canvas/src/editor/recording.rs index fd075ef8fa0c976dcce9652c751cc1bb7b6cd6be..7093635c1c82c3f5c86b8d44fcc662dd1e307f1a 100644 --- a/crates/canvas/src/editor/recording.rs +++ b/crates/canvas/src/editor/recording.rs @@ -22,6 +22,7 @@ fn cite() -> Definition { Definition { kind: Kind::Style { name: Some("cite".into()), + next: None, }, format: Format { bold: Some(false), @@ -107,7 +108,7 @@ impl CanvasEditor { vec![Paragraph::new(String::new(), format); parts], )?; let style = match self.definitions.iter().find(|(_, definition)| { - matches!(&definition.kind, Kind::Style { name: Some(name) } if name == "cite") + matches!(&definition.kind, Kind::Style { name: Some(name), .. } if name == "cite") }) { Some((style, _)) => *style, None => { diff --git a/crates/canvas/src/editor/table.rs b/crates/canvas/src/editor/table.rs index 54bdaed6ce298322c513d0ed0510601196f002e2..243170bf8c5dd8b62dace0d22d9f22abbf25e9a7 100644 --- a/crates/canvas/src/editor/table.rs +++ b/crates/canvas/src/editor/table.rs @@ -1,6 +1,6 @@ use super::*; use onestore::page::text::new_id; -use onestore::page::{ParagraphContent, TableCell, TableColumn, TableRow}; +use onestore::page::{ParagraphContent, Table, TableCell, TableColumn, TableRow}; struct CellLocation<'a> { container: Option, @@ -35,6 +35,18 @@ fn locate(document: &TextDocument, id: ExGuid) -> Option> { None } +/// Table `id`'s paragraph, its container and its index there. +fn table_node( + document: &TextDocument, + id: ExGuid, +) -> Result<(Option, usize, &PageParagraph), EditError> { + descendants(document.nodes(), None) + .find(|(_, _, node)| { + matches!(&node.content, ParagraphContent::Table(table) if table.id == id) + }) + .ok_or(EditError::InvalidRange) +} + /// A new cell like `source`, one empty paragraph in its first text's format; a cell holding /// no text, only pictures or files, gives a plain one. fn empty_cell(source: &TableCell) -> Result { @@ -85,7 +97,270 @@ fn cell_range(document: &TextDocument, cell: &TableCell) -> Result f32 { + width / 36.0 * 36.0 +} + +/// `table`'s widths with its unlocked columns in `only`, or all, fit to their widest cell +/// as `natural` measures it, the table no wider than `room` unless its narrowest columns are. +fn fitted( + table: &Table, + only: Option, + room: f32, + mut natural: impl FnMut(&TableCell) -> Result, +) -> Result, LayoutError> { + let mut widths = table + .columns + .iter() + .map(|column| column.width) + .collect::>(); + for (index, column) in table.columns.iter().enumerate() { + if column.locked || only.is_some_and(|only| only != index) { + continue; + } + let mut widest = 0.0_f32; + for row in &table.rows { + widest = widest.max(natural( + row.cells.get(index).ok_or(LayoutError::InvalidWidth)?, + )?); + } + // Columns sit 4.98 pt apart, and the table ends 3.15 pt past the last one. + let others = widths.iter().map(|width| width + 4.98).sum::() - widths[index] - 1.83; + widths[index] = stored((widest + COLUMN_ROOM).min(room - others).max(COLUMN_WIDTH)); + } + Ok(widths) +} + +/// Fits the tables in `nodes`, innermost first, placed by `indents` in an outline `wrap` wide. +fn fit_tables( + nodes: &mut [PageParagraph], + indents: &[f32], + wrap: f32, + natural: &mut impl FnMut(&TableCell) -> Result, +) -> Result<(), LayoutError> { + for node in nodes { + let level = node.level; + if let ParagraphContent::Table(table) = &mut node.content { + for TableCell { + paragraphs, + indents, + .. + } in table.rows.iter_mut().flat_map(|row| &mut row.cells) + { + fit_tables(paragraphs, indents, wrap, natural)?; + } + let room = wrap - crate::outline::indentation(level, indents, wrap)?; + let widths = fitted(table, None, room, &mut *natural)?; + for (column, width) in table.columns.iter_mut().zip(widths) { + column.width = width; + } + } + } + Ok(()) +} + impl CanvasEditor { + /// Fits the unlocked columns `edit` writes in to their content, as OneNote 2010 widens + /// and narrows them while typing: the tables it adds or rewrites, and the column of each + /// table around the cell it edits. A table stops at the outline's width and wraps there. + pub(super) fn fit_columns( + &self, + engine: &mut TextEngine, + edit: &mut DocumentEdit, + ) -> Result<(), EditorError> { + let outline = self.active_outline(); + if outline.title { + return Ok(()); + } + let wrap = outline.wrap_width(); + let mut natural = |cell: &TableCell, edit: Option<&DocumentEdit>| { + let flow = OutlineLayout::flow( + crate::document::edited_nodes(&cell.paragraphs, Some(cell.id), edit), + &cell.indents, + f32::from(u16::MAX), + false, + 1, + edit, + &mut |node, previous, width, indents| { + ParagraphLayout::shape( + engine, + node, + previous, + width, + indents, + &self.definitions, + ) + }, + )?; + Ok::<_, LayoutError>(flow.content_width()) + }; + let indents = |container: Option| match container { + Some(cell) => { + let location = + locate(&outline.document, cell).ok_or(EditError::InvalidStructure)?; + let ParagraphContent::Table(table) = &location.node.content else { + unreachable!() + }; + Ok::<_, EditError>(&table.rows[location.row].cells[location.column].indents) + } + None => Ok(&outline.indents), + }; + fit_tables( + &mut edit.replacement, + indents(edit.container)?, + wrap, + &mut |cell| natural(cell, None), + )?; + let mut container = edit.container; + while let Some(cell) = container { + let location = locate(&outline.document, cell).ok_or(EditError::InvalidStructure)?; + let ParagraphContent::Table(table) = &location.node.content else { + unreachable!() + }; + if !edit.columns.contains_key(&table.id) { + let room = wrap + - crate::outline::indentation( + location.node.level, + indents(location.container)?, + wrap, + )?; + let widths = fitted(table, Some(location.column), room, |cell| { + natural(cell, Some(edit)) + })?; + if widths + .iter() + .zip(&table.columns) + .any(|(width, column)| *width != column.width) + { + edit.columns.insert(table.id, widths); + } + } + container = location.container; + } + Ok(()) + } +} + +impl TextOutline { + /// The table column whose right border lies within `reach` of outline-local `point`, as + /// `(table, column, width)`. + pub fn column_border(&self, point: [f32; 2], reach: f32) -> Option<(ExGuid, usize, f32)> { + let columns = descendants(self.document.nodes(), None) + .filter_map(|(_, _, node)| match &node.content { + ParagraphContent::Table(table) => Some((table.id, table.columns.len())), + _ => None, + }) + .collect::>(); + // Nested tables follow the tables holding them. + self.shaped.tables.iter().rev().find_map(|table| { + let (first, last) = (table.cells.first()?, table.cells.last()?); + if !(first.rect[1]..=last.rect[3]).contains(&point[1]) { + return None; + } + let row = table.cells.get(..*columns.get(&table.id)?)?; + row.iter().enumerate().find_map(|(column, cell)| { + // A cell's box reaches 3.6 pt before its column and 1.38 pt past it. + ((point[0] - cell.rect[2]).abs() <= reach) + .then(|| (table.id, column, cell.rect[2] - cell.rect[0] - 4.98)) + }) + }) + } +} + +impl CanvasEditor { + /// The active outline with column `column` of `table` `width` wide, as a border drag + /// shows it before release. + pub fn preview_column( + &self, + engine: &mut TextEngine, + table: ExGuid, + column: usize, + width: f32, + ) -> Result { + let outline = self.active_outline(); + let (_, _, node) = table_node(&outline.document, table)?; + let ParagraphContent::Table(source) = &node.content else { + unreachable!() + }; + let mut widths = source + .columns + .iter() + .map(|column| column.width) + .collect::>(); + *widths.get_mut(column).ok_or(EditError::InvalidRange)? = width.max(COLUMN_WIDTH); + let edit = DocumentEdit { + container: None, + range: 0..0, + replacement: Vec::new(), + columns: [(table, widths)].into(), + }; + let shaped = OutlineLayout::flow( + outline.document.nodes().iter(), + &outline.indents, + outline.wrap_width(), + outline.layout.width_set_by_user == Some(true), + 0, + Some(&edit), + &mut |node, previous, width, indents| { + ParagraphLayout::shape(engine, node, previous, width, indents, &self.definitions) + }, + )?; + let mut preview = outline.clone(); + preview.document.apply(edit)?; + preview.shaped = shaped; + Ok(preview) + } + + /// Drags a border to make column `column` of `table` `width` wide, as OneNote 2010 does: + /// no narrower than a new column, locked against fitting, the columns after it moving + /// with it. One edit and one undo step. + pub fn resize_column( + &mut self, + engine: &mut TextEngine, + table: ExGuid, + column: usize, + width: f32, + ) -> Result<(), EditorError> { + if !width.is_finite() { + return Err(LayoutError::InvalidWidth.into()); + } + let outline = self.active_outline(); + let (container, index, node) = table_node(&outline.document, table)?; + let mut node = node.clone(); + let ParagraphContent::Table(source) = &mut node.content else { + unreachable!() + }; + let resized = TableColumn { + width: stored(width.max(COLUMN_WIDTH)), + locked: true, + }; + let slot = source + .columns + .get_mut(column) + .ok_or(EditError::InvalidRange)?; + if *slot == resized { + return Ok(()); + } + *slot = resized; + let selection = outline.selection; + self.finish_composition(); + self.commit( + engine, + DocumentEdit { + columns: BTreeMap::new(), + container, + range: index..index + 1, + replacement: vec![node], + }, + selection, + ) + } /// Insert, Table: an empty table of `rows` by `columns` at the caret with the caret in /// its first cell, as OneNote 2010 inserts one: in place of an empty paragraph, before /// or after the caret's paragraph at its start or end, and between its halves otherwise. @@ -137,7 +412,7 @@ impl CanvasEditor { id: new_id()?, columns: vec![ TableColumn { - width: 37.11, + width: COLUMN_WIDTH, locked: false }; columns @@ -260,7 +535,7 @@ impl CanvasEditor { id: new_id()?, columns: vec![ TableColumn { - width: 37.11, + width: COLUMN_WIDTH, locked: false }; 2 @@ -341,7 +616,7 @@ impl CanvasEditor { }; table.rows[0].cells.push(target); table.columns.push(TableColumn { - width: 37.11, + width: COLUMN_WIDTH, locked: false, }); TextPosition { @@ -713,7 +988,9 @@ mod tests { }; assert_eq!(after.rows.len(), 3); assert_eq!(&after.rows[..2], &before.rows); - assert_eq!(after.columns, before.columns); + // The first column widens to the table placed in it unfitted. + assert!(after.columns[0].width > before.columns[0].width + 40.0); + assert_eq!(after.columns[1], before.columns[1]); for cell in &after.rows[2].cells { assert_eq!(cell.paragraphs.len(), 1); assert!(cell.paragraphs[0].text().unwrap().text.text().is_empty()); @@ -912,4 +1189,244 @@ mod tests { ); assert_eq!(editor.active_outline().shaped.paragraphs[0].origin[0], 27.0); } + + fn calibri(engine: &mut TextEngine) -> CanvasEditor { + let format = Format { + font: Some("Calibri".into()), + font_size: Some(11.0), + ..Format::default() + }; + CanvasEditor::new( + engine, + TextDocument::new(vec![Paragraph::new(String::new(), format)]).unwrap(), + 468.0, + ) + .unwrap() + } + + fn table(editor: &CanvasEditor) -> &onestore::page::Table { + let ParagraphContent::Table(table) = &editor.active_outline().document.nodes()[0].content + else { + panic!() + }; + table + } + + fn widths(editor: &CanvasEditor) -> Vec { + table(editor) + .columns + .iter() + .map(|column| column.width) + .collect() + } + + fn type_rows(editor: &mut CanvasEditor, engine: &mut TextEngine, rows: &[&[&str]]) { + for (index, row) in rows.iter().enumerate() { + if index > 0 { + editor.enter(engine, false).unwrap(); + } + for (column, text) in row.iter().enumerate() { + if column > 0 { + editor.tab(engine, false).unwrap(); + } + editor.insert(engine, text).unwrap(); + } + } + } + + /// OneNote 2010's widths for the same table typed the same way (lab, 2026-09-30). + #[test] + fn typing_fits_unlocked_columns_to_their_widest_line() { + let mut engine = TextEngine::default(); + let mut editor = calibri(&mut engine); + type_rows( + &mut editor, + &mut engine, + &[ + &["Fruit", "Colour", "Notes"], + &["Apple", "Red", "Crisp and sweet, good for pies"], + &["Watermelon", "Green", "Summer"], + ], + ); + for (width, onenote) in widths(&editor) + .into_iter() + .zip([60.75945, 37.11, 139.08636]) + { + assert!((width - onenote).abs() < 0.01, "{width} against {onenote}"); + } + // Deleting the widest line narrows the column to the next widest, here the minimum. + let end = TextPosition { + paragraph: 6, + offset: 10, + }; + editor + .select([TextPosition { offset: 0, ..end }, end].into()) + .unwrap(); + editor.delete(&mut engine, true).unwrap(); + assert_eq!(widths(&editor)[0], COLUMN_WIDTH); + assert!(editor.undo(&mut engine).unwrap()); + assert!((widths(&editor)[0] - 60.75945).abs() < 0.01); + } + + #[test] + fn a_widening_keystroke_stores_its_width_with_its_text() { + let mut engine = TextEngine::default(); + let mut editor = calibri(&mut engine); + type_rows(&mut editor, &mut engine, &[&["A", ""]]); + editor.take_ops().unwrap(); + let before = widths(&editor); + editor.insert(&mut engine, "Watermelon").unwrap(); + let ops = editor.take_ops().unwrap(); + let columns = ops + .iter() + .filter_map(|op| match op { + PageOp::Table { + edit: onestore::op::TableEdit::Columns(columns), + .. + } => Some( + columns + .iter() + .map(|column| column.width) + .collect::>(), + ), + _ => None, + }) + .collect::>(); + assert_eq!(columns, [widths(&editor)]); + assert!(ops.len() > 1); + assert!(widths(&editor)[1] > before[1]); + // A keystroke the column already holds stores no width. + editor.delete(&mut engine, true).unwrap(); + editor.insert(&mut engine, "n").unwrap(); + editor.take_ops().unwrap(); + editor + .select( + [TextPosition { + paragraph: 0, + offset: 1, + }; 2] + .into(), + ) + .unwrap(); + editor.insert(&mut engine, "!").unwrap(); + assert!(!editor.take_ops().unwrap().iter().any(|op| matches!( + op, + PageOp::Table { + edit: onestore::op::TableEdit::Columns(_), + .. + } + ))); + } + + /// OneNote 2010 stops a table at the outline's width and wraps the cell from there. + #[test] + fn a_table_stops_widening_at_the_outline_width() { + let mut engine = TextEngine::default(); + let mut editor = calibri(&mut engine); + type_rows(&mut editor, &mut engine, &[&["A", ""]]); + for _ in 0..40 { + editor.insert(&mut engine, "word ").unwrap(); + } + let widths = widths(&editor); + let table = widths.iter().map(|width| width + 4.98).sum::() - 1.83; + assert!((table - 468.0).abs() < 0.01, "{table}"); + assert!( + editor.active_outline().shaped.paragraphs[1] + .text + .lines() + .count() + > 1 + ); + } + + #[test] + fn a_dragged_column_locks_moves_the_columns_after_it_and_undoes_in_one_step() { + let mut engine = TextEngine::default(); + let mut editor = calibri(&mut engine); + type_rows(&mut editor, &mut engine, &[&["B", "Word"]]); + let id = table(&editor).id; + let fitted = widths(&editor); + let cells = |editor: &CanvasEditor| { + editor.active_outline().shaped.tables[0] + .cells + .iter() + .map(|cell| cell.rect) + .collect::>() + }; + let before = cells(&editor); + // The border of the first column, from its cell's right edge. + let border = [before[0][2], (before[0][1] + before[0][3]) / 2.0]; + assert_eq!( + editor.active_outline().column_border(border, 2.0), + Some((id, 0, fitted[0])) + ); + assert_eq!( + editor + .active_outline() + .column_border([border[0] - 10.0, border[1]], 2.0), + None + ); + let preview = editor.preview_column(&mut engine, id, 0, 88.86).unwrap(); + assert_eq!(widths(&editor), fitted); + editor.take_ops().unwrap(); + let history = editor.undo.len(); + editor.resize_column(&mut engine, id, 0, 88.86).unwrap(); + assert_eq!(editor.undo.len(), history + 1); + assert_eq!( + table(&editor).columns[0], + TableColumn { + width: 88.86, + locked: true + } + ); + assert_eq!( + table(&editor).columns[1], + TableColumn { + width: fitted[1], + locked: false + } + ); + assert_eq!(cells(&editor), { + let ParagraphContent::Table(_) = &preview.document.nodes()[0].content else { + panic!() + }; + preview.shaped.tables[0] + .cells + .iter() + .map(|cell| cell.rect) + .collect::>() + }); + let moved = cells(&editor); + assert!((moved[1][0] - before[1][0] - (88.86 - fitted[0])).abs() < 0.001); + let ops = editor.take_ops().unwrap(); + assert!( + matches!( + ops.as_slice(), + [PageOp::Table { + edit: onestore::op::TableEdit::Columns(_), + .. + }] + ), + "{ops:?}" + ); + // A locked column keeps its width while typing; dragging stops at a new column's width. + editor + .insert(&mut engine, " and a long line of text") + .unwrap(); + assert_eq!(table(&editor).columns[0].width, 88.86); + editor.resize_column(&mut engine, id, 1, 5.0).unwrap(); + assert_eq!( + table(&editor).columns[1], + TableColumn { + width: COLUMN_WIDTH, + locked: true + } + ); + for _ in 0..3 { + editor.undo(&mut engine).unwrap(); + } + assert_eq!(widths(&editor), fitted); + assert!(table(&editor).columns.iter().all(|column| !column.locked)); + assert_eq!(cells(&editor), before); + } } diff --git a/crates/canvas/src/gpu/page.rs b/crates/canvas/src/gpu/page.rs index dcbef897c20d30ccf14cb64e54bf1526b6bab70a..e08881737341bb0d2206f1a8e3c37563688c9bb3 100644 --- a/crates/canvas/src/gpu/page.rs +++ b/crates/canvas/src/gpu/page.rs @@ -24,6 +24,8 @@ use std::{ /// Retained drawing data in the source page's coordinate system. pub struct PageScene { reference: Option>, + /// The definitions a page read without an editor names, as its objects' tags do. + definitions: std::collections::BTreeMap, /// Pictures and file icons, by the identity of the object that shows them. pictures: std::collections::BTreeMap, /// Background pictures from OneNote's page templates, painted from their recreations. @@ -80,6 +82,8 @@ pub enum SceneHit { ReadOnly(usize), Image(onestore::ExGuid), File(onestore::ExGuid), + /// A check box tag on a picture or file on the page. + Check(onestore::ExGuid), } fn outline_origin( @@ -116,11 +120,12 @@ pub(crate) fn append_ink( let size = stroke.width.max(stroke.height); let segment = |from: [f32; 2], to: [f32; 2], [t0, t1]: [f32; 2]| { if !highlighter { - if round && t0 != t1 { + if t0 != t1 { return Primitive::Taper { from, to, widths: [size * t0, size * t1], + round, color, }; } @@ -253,6 +258,7 @@ impl PageScene { pictures.mark_unavailable(&mut objects, engine)?; Ok(Self { reference: Some(objects), + definitions: std::mem::take(&mut page.definitions), ..pictures }) } @@ -398,6 +404,7 @@ impl PageScene { } Ok(Self { reference: None, + definitions: Default::default(), pictures, backgrounds, }) @@ -438,6 +445,17 @@ impl PageScene { } } + /// Bytes of the rasters the scene keeps decoded, for bounding what a cache of scenes holds. + pub fn raster_bytes(&self) -> u64 { + let size = |image: &draw::RasterImage| { + let [width, height] = image.size(); + u64::from(width) * u64::from(height) * 4 + }; + let pictures = self.pictures.values().filter_map(Picture::image); + let art = self.backgrounds.values().flat_map(Background::images); + pictures.chain(art).map(size).sum() + } + /// The raster a picture or file icon shows, once decoded. pub fn image(&self, id: onestore::ExGuid) -> Option<&draw::RasterImage> { self.pictures.get(&id)?.image() @@ -463,9 +481,7 @@ impl PageScene { source.layout.x.unwrap_or(0.0), source.layout.y.unwrap_or(0.0), ]; - if let (Some(width), Some(height)) = - (source.layout.max_width, source.layout.max_height) - { + if let Some([width, height]) = crate::outline::image_size(source) { rects.push((source.id, [x, y, x + width, y + height])); } } @@ -581,6 +597,38 @@ impl PageScene { .ok_or(SceneError::MissingOutline) } + /// The note tags of the pictures and files on the page, each with the object it marks, + /// in page points; `moving` shows one at a previewed rectangle. + fn object_tags( + &self, + editor: Option<&CanvasEditor>, + moving: Option<(onestore::ExGuid, [f32; 4])>, + ) -> Vec<(onestore::ExGuid, crate::outline::ParagraphTag)> { + let definitions = match (&self.reference, editor) { + (None, Some(editor)) => &editor.definitions, + _ => &self.definitions, + }; + let Ok(objects) = self.objects(editor) else { + return Vec::new(); + }; + objects + .iter() + .filter_map(|object| { + let (id, tags, bounds) = object.tagged()?; + let bounds = match moving { + Some((moved, rect)) if moved == id => rect, + _ => bounds, + }; + Some( + crate::outline::object_tags(tags, definitions, bounds) + .into_iter() + .map(move |tag| (id, tag)), + ) + }) + .flatten() + .collect() + } + pub fn read_only<'a>( &'a self, editor: Option<&'a CanvasEditor>, @@ -650,15 +698,11 @@ impl PageScene { true, )) } - Content::Image(source) => Some(( - [ - source.layout.x.unwrap_or(0.0), - source.layout.y.unwrap_or(0.0), - source.layout.x.unwrap_or(0.0) + source.layout.max_width?, - source.layout.y.unwrap_or(0.0) + source.layout.max_height?, - ], - false, - )), + Content::Image(source) => { + let [x, y] = [source.layout.x, source.layout.y].map(|v| v.unwrap_or(0.0)); + let [width, height] = crate::outline::image_size(source)?; + Some(([x, y, x + width, y + height], false)) + } Content::File { .. } => Some((object.file()?.1, true)), Content::Ink(ink) => Some((crate::editor::page::ink_bounds(ink)?, true)), Content::ReadOnly(object) => Some((object.rect(), true)), @@ -716,6 +760,14 @@ impl PageScene { editor: Option<&CanvasEditor>, mut outline: impl FnMut(onestore::ExGuid) -> Option, ) -> Option> { + let check = self.object_tags(editor, None).into_iter().find(|(_, tag)| { + tag.icon.checkable() + && (tag.origin[0]..=tag.origin[0] + tag.size).contains(&point[0]) + && (tag.origin[1]..=tag.origin[1] + tag.size).contains(&point[1]) + }); + if let Some((id, _)) = check { + return Some(SceneHit::Check(id)); + } let mut readonly = self.read_only(editor).count(); for object in self.objects(editor).ok()?.iter().rev() { match object { @@ -746,8 +798,7 @@ impl PageScene { source.layout.x.unwrap_or(0.0), source.layout.y.unwrap_or(0.0), ]; - if let (Some(width), Some(height)) = - (source.layout.max_width, source.layout.max_height) + if let Some([width, height]) = crate::outline::image_size(source) && (x..=x + width).contains(&point[0]) && (y..=y + height).contains(&point[1]) { @@ -824,11 +875,7 @@ impl PageScene { ]; let size = match self.backgrounds.get(&source.id) { Some(art) => Some(art.size), - None => source - .layout - .max_width - .zip(source.layout.max_height) - .map(|(width, height)| [width, height]), + None => crate::outline::image_size(source), }; if let Some([width, height]) = size { cover([x, y, x + width, y + height], !source.background); @@ -975,10 +1022,8 @@ impl PageScene { .pictures .get(&source.id) .ok_or(SceneError::MissingImage)?; - let size = [ - source.layout.max_width.ok_or(SceneError::MissingImage)?, - source.layout.max_height.ok_or(SceneError::MissingImage)?, - ]; + let size = crate::outline::image_size(source) + .ok_or(SceneError::MissingImage)?; if picture.failed() { append_placeholder( [ @@ -1059,6 +1104,16 @@ impl PageScene { | Content::File { .. } => unreachable!(), } } + let art = super::TagArt::default(); + for (_, tag) in self.object_tags(editor, moving) { + primitives.push(Primitive::Icon { + sources: art.sources(&tag), + origin: [tag.origin[0] + offset[0], tag.origin[1] + offset[1]], + size: tag.size, + tint: [1.0, 1.0, 1.0, if tag.disabled { 0.45 } else { 1.0 }], + palette: draw::Palette::default(), + }); + } Ok(()) } } @@ -1547,6 +1602,7 @@ mod tests { onestore::page::Definition { kind: onestore::document::Kind::Style { name: Some("p".into()), + next: None, }, format: onestore::document::Format { font_size: Some(base_size), @@ -1720,6 +1776,9 @@ mod tests { alt: None, background, printout: None, + tags: Vec::new(), + link: None, + text: None, })); } let (mut scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); @@ -2082,6 +2141,9 @@ mod tests { alt: Some("missing diagram".into()), background: false, printout: None, + tags: Vec::new(), + link: None, + text: None, }), PageObject::Unsupported(unknown.clone()), ], @@ -2252,6 +2314,9 @@ mod tests { alt: None, background: false, printout: None, + tags: Vec::new(), + link: None, + text: None, }), outline(50.0, "last"), ], @@ -2545,6 +2610,9 @@ mod tests { alt: None, background: false, printout: None, + tags: Vec::new(), + link: None, + text: None, })], }; let mut engine = TextEngine::default(); @@ -2610,6 +2678,9 @@ mod tests { alt: None, background, printout: None, + tags: Vec::new(), + link: None, + text: None, }; let [background, picture] = [image(0.0, true), image(50.0, false)]; let id = picture.id; @@ -2681,6 +2752,9 @@ mod tests { alt: None, background: false, printout: None, + tags: Vec::new(), + link: None, + text: None, }) .collect(); assert!(20 * u64::from(side * side) * 4 > draw::MAX_IMAGE_BYTES); diff --git a/crates/canvas/src/gpu/page/background.rs b/crates/canvas/src/gpu/page/background.rs index eae3db1f59eb491602a9e6cb47dafebd1d64d3fc..cc3c0147e062c69d82c3636e4955d5f6295a11ec 100644 --- a/crates/canvas/src/gpu/page/background.rs +++ b/crates/canvas/src/gpu/page/background.rs @@ -429,6 +429,13 @@ struct Variant { } impl Background { + /// The rasters made so far, for light and for dark paper. + pub fn images(&self) -> impl Iterator { + self.variants + .iter() + .filter_map(|variant| variant.shown.as_ref().map(|(image, _)| image)) + } + /// Whether both recreate the same template picture at the same size. pub fn same(&self, other: &Self) -> bool { self.svg == other.svg && self.size == other.size @@ -757,6 +764,9 @@ mod tests { alt: None, background: true, printout: None, + tags: Vec::new(), + link: None, + text: None, } } diff --git a/crates/canvas/src/gpu/profile.rs b/crates/canvas/src/gpu/profile.rs index e6cdbcf7d183afc6729842d9dea7da4d45dfa49a..65583922b6d9524b12c805f413be25e49ce3dcd0 100644 --- a/crates/canvas/src/gpu/profile.rs +++ b/crates/canvas/src/gpu/profile.rs @@ -247,6 +247,9 @@ fn frame_cost() { alt: None, background: false, printout: None, + tags: Vec::new(), + link: None, + text: None, }); let cell = || TableCell { id: new_id().unwrap(), diff --git a/crates/canvas/src/interaction/mod.rs b/crates/canvas/src/interaction/mod.rs index 7593d0281772abb4c6d65be2ed42fe7764e41383..b3434707f3bd76166c7717b0c4511d6cb95af81f 100644 --- a/crates/canvas/src/interaction/mod.rs +++ b/crates/canvas/src/interaction/mod.rs @@ -16,7 +16,10 @@ mod tests; use crate::gpu::{Paper, Viewport, page::PageScene}; use crate::{ date::DateField, - editor::{CanvasEditor, DEFAULT_OUTLINE_WIDTH, Formatting, Selection, TextOutline, Whole}, + editor::{ + Awaited, CanvasEditor, DEFAULT_OUTLINE_WIDTH, Formatting, Piece, Selection, TextOutline, + Whole, + }, layout::TextEngine, }; use draw::{ @@ -177,6 +180,15 @@ enum Drag { outline: Option>, grab: f32, }, + /// A table column's right border from document x `press`, where the column was `width` + /// wide; the preview holds the width it has reached. + Column { + table: onestore::ExGuid, + column: usize, + width: f32, + press: f32, + preview: Option<(f32, Box)>, + }, Outline { id: onestore::ExGuid, grab: [f32; 2], @@ -346,9 +358,14 @@ impl PageView { view } - /// Shows a page reloaded from storage in place of the edited one. - pub fn replace(&mut self, editor: CanvasEditor, scene: Option<(PageScene, [f32; 2])>) { - self.editor = editor; + /// Shows a page reloaded from storage in place of the edited one, whose editor it returns. + pub fn replace( + &mut self, + mut editor: CanvasEditor, + scene: Option<(PageScene, [f32; 2])>, + ) -> CanvasEditor { + editor.default_font = std::mem::take(&mut self.editor.default_font); + let left = std::mem::replace(&mut self.editor, editor); self.scene = scene; self.drag = None; self.space = false; @@ -356,6 +373,7 @@ impl PageView { self.found.clear(); self.played = None; self.leave_ink(); + left } /// Shows the stored page after a change made elsewhere in place of the one shown, @@ -376,23 +394,36 @@ impl PageView { /// Shows another page as OneNote opens one, keeping the zoom: at `place` if the page was /// left there earlier. OneNote keeps the scroll in device pixels across zoom changes and - /// does not reveal the restored selection. + /// does not reveal the restored selection. Returns the editor of the page left. pub fn open( &mut self, editor: CanvasEditor, scene: Option<(PageScene, [f32; 2])>, place: Option, - ) { - self.replace(editor, scene); + ) -> CanvasEditor { + let left = self.replace(editor, scene); self.reach = None; - let Some(place) = place else { - return self.place_opened(); - }; - self.viewport.origin = place.origin; - self.scroll().clamp(&mut self.viewport); - if self.editor.focus_outline(place.outline).is_ok() { - let _ = self.editor.select(place.selection); + match place { + Some(place) => { + self.viewport.origin = place.origin; + self.scroll().clamp(&mut self.viewport); + if self.editor.focus_outline(place.outline).is_ok() { + let _ = self.editor.select(place.selection); + } + } + None => self.place_opened(), } + left + } + + /// Takes up `parked`, the editor the page `open` just showed was left with, history and + /// all, in place of the one it opened with. + pub fn resume(&mut self, parked: CanvasEditor) -> Result<()> { + self.editor.resume(parked, &mut self.engine)?; + if let Some((scene, _)) = &mut self.scene { + scene.refresh(&mut self.editor, &mut self.engine)?; + } + Ok(()) } /// Where the page is left, for `open` to return to. @@ -907,10 +938,16 @@ impl PageView { /// Everything to draw this frame. pub fn primitives(&self, colors: TextColors) -> Result>> { let preview = match &self.drag { - Some(Drag::Resize { - outline: Some(outline), - .. - }) => Some(PointerFeedback::Resize(outline)), + Some( + Drag::Resize { + outline: Some(outline), + .. + } + | Drag::Column { + preview: Some((_, outline)), + .. + }, + ) => Some(PointerFeedback::Resize(outline)), _ => self .drag_delta() .map(|(id, delta)| PointerFeedback::Move(id, delta)) @@ -958,6 +995,7 @@ impl PageView { Some( Drag::Outline { .. } | Drag::Resize { .. } + | Drag::Column { .. } | Drag::Image { .. } | Drag::Space { .. } ) @@ -1038,8 +1076,14 @@ impl PageView { let hit = self.hit_test(point); match (&self.drag, hit) { (Some(Drag::Image { handle, .. }), _) => handle_cursor(*handle), + (None, Some(Hit::Image { id, handle: [0, 0] })) + if self.modifiers.command && self.editor.picture_link(id).is_some() => + { + Cursor::Pointer + } (None, Some(Hit::Image { handle, .. })) => handle_cursor(handle), (Some(Drag::Resize { .. }), _) | (None, Some(Hit::Resize { .. })) => Cursor::EwResize, + (Some(Drag::Column { .. }), _) | (None, Some(Hit::Column { .. })) => Cursor::ColResize, (Some(Drag::Outline { .. }), _) | (None, Some(Hit::Handle { .. })) => Cursor::Move, (None, Some(Hit::Date(_))) => Cursor::Pointer, (None, Some(Hit::Text { id, point })) @@ -1047,7 +1091,10 @@ impl PageView { { Cursor::Pointer } - (None, Some(Hit::ReadOnly(_) | Hit::Check { .. } | Hit::File(_))) => Cursor::Default, + ( + None, + Some(Hit::ReadOnly(_) | Hit::Check { .. } | Hit::ObjectCheck(_) | Hit::File(_)), + ) => Cursor::Default, _ => Cursor::Text, } } @@ -1155,6 +1202,23 @@ impl PageView { } Ok(Response::default()) } + Some(Drag::Column { + table, + column, + width, + press, + preview, + }) => { + let width = *width + self.viewport.document_point(self.pointer)[0] - *press; + if preview.as_ref().is_none_or(|(shown, _)| *shown != width) { + let outline = + self.editor + .preview_column(&mut self.engine, *table, *column, width)?; + *preview = Some((width, Box::new(outline))); + return self.changed(); + } + Ok(Response::default()) + } None => Ok(Response::redraw()), } } @@ -1195,6 +1259,12 @@ impl PageView { .click_check(&mut self.engine, outline, paragraph)?; return self.changed(); } + Some(Hit::ObjectCheck(id)) => { + self.set_object_focus(None); + self.drag = None; + self.editor.click_object_check(id)?; + return self.changed(); + } Some(Hit::File(id)) => { self.set_object_focus(Some(ObjectFocus::File(id))); self.drag = None; @@ -1213,6 +1283,17 @@ impl PageView { }); } } + // A click selects a linked picture; Ctrl+click follows its link, as in OneNote. + Some(Hit::Image { id, handle: [0, 0] }) + if self.modifiers.command + && let Some(address) = self.editor.picture_link(id) => + { + self.drag = None; + return Ok(Response { + request: Some(Request::OpenLink(address.to_owned())), + ..Response::default() + }); + } Some(Hit::Image { id, handle }) => { self.set_object_focus(Some(ObjectFocus::Image(id))); self.drag = Some(Drag::Image { @@ -1242,6 +1323,22 @@ impl PageView { grab, }); } + Some(Hit::Column { + id, + table, + column, + width, + }) => { + self.set_object_focus(None); + self.editor.focus_outline(id)?; + self.drag = Some(Drag::Column { + table, + column, + width, + press: point[0], + preview: None, + }); + } Some(Hit::Text { id, point }) if unit == SelectionUnit::Grapheme && !self.modifiers.shift @@ -1326,13 +1423,24 @@ impl PageView { self.editor .place_image(&mut self.engine, id, origin, size)?; } - if let Some(Drag::Resize { - outline: Some(outline), - .. - }) = self.drag.take() - { - self.editor - .resize(&mut self.engine, outline.bounds().width() as f32)?; + match self.drag.take() { + Some(Drag::Resize { + outline: Some(outline), + .. + }) => { + self.editor + .resize(&mut self.engine, outline.bounds().width() as f32)?; + } + Some(Drag::Column { + table, + column, + preview: Some((width, _)), + .. + }) => { + self.editor + .resize_column(&mut self.engine, table, column, width)?; + } + _ => {} } if let Some(id) = clicked { // A click on an outline's handle selects it as OneNote does. @@ -1421,6 +1529,11 @@ impl PageView { /// A toolbar command, ignored like other edits while an input method composes or an object /// holds focus. pub fn format(&mut self, command: Formatting) -> Result { + if let Some(ObjectFocus::Image(id) | ObjectFocus::File(id)) = self.object_focus + && self.editor.format_object(&mut self.engine, id, &command)? + { + return self.edited(); + } if !self.accepts_text() || self.editor.marked_range().is_some() { return Ok(Response::default()); } @@ -1692,25 +1805,44 @@ impl PageView { if !self.accepts_text() || self.editor.marked_range().is_some() { return Ok(Response::default()); } - let image = onestore::page::Image { - id: onestore::page::text::new_id()?, - layout: onestore::document::Layout { - max_width: Some(size[0]), - max_height: Some(size[1]), - ..Default::default() - }, - size: Some(size), - bytes: Some(bytes.into()), - display: None, - alt: None, - background: false, - printout: None, - }; + let image = crate::editor::picture(bytes, size)?; self.editor.insert_picture(&mut self.engine, image)?; self.follow_pictures()?; self.edited() } + /// Pasted text in `language`, an LCID, and pictures, in order and as one undo step; see + /// [`CanvasEditor::paste_pieces`]. + pub fn paste_pieces( + &mut self, + pieces: Vec, + language: u32, + ) -> Result<(Vec, Response)> { + if !self.accepts_text() || self.editor.marked_range().is_some() { + return Ok((Vec::new(), Response::default())); + } + let awaited = self + .editor + .paste_pieces(&mut self.engine, pieces, language)?; + self.follow_pictures()?; + Ok((awaited, self.edited()?)) + } + + /// An awaited picture arrived: see [`CanvasEditor::insert_awaited`]. The view stays. + pub fn insert_awaited( + &mut self, + at: Awaited, + bytes: Vec, + size: [f32; 2], + ) -> Result { + let image = crate::editor::picture(bytes, size)?; + if !self.editor.insert_awaited(&mut self.engine, at, image)? { + return Ok(Response::default()); + } + self.follow_pictures()?; + self.changed() + } + /// Record Audio: see [`CanvasEditor::start_recording`]; none where text cannot go. pub fn start_recording(&mut self, label: &str) -> Result<(Option<[u8; 16]>, Response)> { if !self.accepts_text() || self.editor.marked_range().is_some() { @@ -1765,6 +1897,24 @@ impl PageView { position: [f32; 2], file: onestore::page::Attachment, ) -> Result { + self.drop_caret(position)?; + self.insert_attachment(file) + } + + /// A picture file dropped at view point `position`, in device pixels, placed as + /// [`Self::drop_attachment`] places a file and sized as [`Self::insert_picture`]. + pub fn drop_picture( + &mut self, + position: [f32; 2], + bytes: Vec, + size: [f32; 2], + ) -> Result { + self.drop_caret(position)?; + self.insert_picture(bytes, size) + } + + /// Puts the caret where a drop at `position` lands, as a click there would. + fn drop_caret(&mut self, position: [f32; 2]) -> Result<()> { let point = self.viewport.document_point(position); self.set_object_focus(None); match self.hit_test(point) { @@ -1779,7 +1929,7 @@ impl PageView { None => self.place_caret(point)?, _ => {} } - self.insert_attachment(file) + Ok(()) } /// Professional, or Linear with `linear`, on the equation at the caret. @@ -1906,6 +2056,7 @@ impl PageView { Some( Drag::Outline { .. } | Drag::Resize { .. } + | Drag::Column { .. } | Drag::Image { .. } | Drag::Space { .. } ) @@ -2086,6 +2237,13 @@ pub enum Hit { id: onestore::ExGuid, grab: f32, }, + /// The right border of `column` of `table`, which is `width` wide. + Column { + id: onestore::ExGuid, + table: onestore::ExGuid, + column: usize, + width: f32, + }, Text { id: onestore::ExGuid, point: [f32; 2], @@ -2107,6 +2265,8 @@ pub enum Hit { }, /// A file in an outline's flow or on the page, its icon or name. File(onestore::ExGuid), + /// A check box tag on a picture or file on the page. + ObjectCheck(onestore::ExGuid), } /// What a document point lands on; `pixel` is document points per device pixel. @@ -2231,6 +2391,14 @@ fn page_hit( } } let inner = [x - outline.origin()[0], y - outline.origin()[1]]; + if let Some((table, column, width)) = outline.column_border(inner, 3.0 * pixel) { + return Some(Hit::Column { + id: outline.id, + table, + column, + width, + }); + } if let Some(picture) = outline.shaped().objects.iter().find(|object| { matches!(object.kind, crate::outline::ObjectKind::Picture) && (object.rect[0]..=object.rect[2]).contains(&inner[0]) @@ -2285,6 +2453,7 @@ fn page_hit( crate::gpu::page::SceneHit::ReadOnly(index) => Some(Hit::ReadOnly(index)), crate::gpu::page::SceneHit::Image(id) => Some(Hit::Image { id, handle: [0, 0] }), crate::gpu::page::SceneHit::File(id) => Some(Hit::File(id)), + crate::gpu::page::SceneHit::Check(id) => Some(Hit::ObjectCheck(id)), } }; [Layer::Grips, Layer::Body, Layer::Below] diff --git a/crates/canvas/src/interaction/tests.rs b/crates/canvas/src/interaction/tests.rs index 7d0e57ba3ed8231938cc7104d0150757ce26f2b6..ca5a9f9543564cc27665bf8e57728b8e143ec9e2 100644 --- a/crates/canvas/src/interaction/tests.rs +++ b/crates/canvas/src/interaction/tests.rs @@ -708,6 +708,9 @@ fn a_picture_in_an_outline_takes_the_click_over_its_text() { alt: None, background: false, printout: None, + tags: Vec::new(), + link: None, + text: None, }); source.paragraphs.insert(1, picture); let editor = CanvasEditor::from_page( @@ -882,6 +885,13 @@ fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() { .unwrap(); editor.tab(&mut engine, false).unwrap(); editor.insert(&mut engine, "R").unwrap(); + // Columns dragged narrower than the glyphs they hold. + let table = editor.active_outline().shaped().tables[0].id; + for column in 0..2 { + editor + .resize_column(&mut engine, table, column, 37.11) + .unwrap(); + } editor.tab(&mut engine, true).unwrap(); let mut primitives = Vec::new(); append_outline( @@ -1545,6 +1555,9 @@ fn picture_view() -> (PageView, onestore::ExGuid) { alt: None, background: false, printout: None, + tags: Vec::new(), + link: None, + text: None, }); source.paragraphs.insert(1, picture); let editor = CanvasEditor::from_page( @@ -2768,6 +2781,7 @@ fn an_attached_file_selects_opens_on_a_double_click_and_deletes() { bytes: Some(Arc::from(b"notes".as_slice())), preview: None, recording: None, + tags: Vec::new(), }; view.editor .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() { bytes: Some(Arc::from(b"float".as_slice())), preview: None, recording: None, + tags: Vec::new(), }; let _ = view.drop_attachment([400.0, 400.0], file.clone()).unwrap(); assert_eq!(view.editor.outlines().len(), 1); @@ -3154,3 +3169,177 @@ fn the_spelling_pane_walks_marked_words_from_the_caret() { spelling.learn("qwrt"); assert_eq!(next(&mut view), None); } + +/// Page `title` of `section`, with a view of it whose scene holds its pictures. +fn scene_view(section: &[u8], title: &str) -> PageView { + let arena = onestore::Arena::default(); + let mut section = onestore::Section::open(&arena, section.to_vec()).unwrap(); + let (space, ..) = section + .pages() + .unwrap() + .into_iter() + .find(|page| page.1 == title) + .unwrap(); + let mut engine = TextEngine::default(); + let (scene, editor) = PageScene::from_page(section.page(space).unwrap(), &mut engine).unwrap(); + PageView::new( + editor, + engine, + Some((scene, [0.0; 2])), + [800, 600], + 1.0, + Duration::from_millis(500), + ) +} + +fn device(view: &PageView, point: [f32; 2]) -> [f32; 2] { + [0, 1].map(|axis| point[axis] * view.viewport.scale + view.viewport.origin[axis]) +} + +/// A click selects a picture with a link and Ctrl+click (Command on macOS) follows it, as in +/// OneNote 2010, whose tooltip says so (`corpus/picture-link`). +#[test] +fn ctrl_click_follows_a_pictures_link() { + let mut view = scene_view( + include_bytes!("../../../../corpus/m6/native-features-01/notebook/Features.one"), + "Image png", + ); + let (id, [x, y], [width, height]) = view + .editor + .objects + .iter() + .find_map(|object| match object { + crate::editor::page::Content::Image(image) => Some(image.id), + _ => None, + }) + .and_then(|id| { + let (origin, size) = view.editor.image_placement(id)?; + Some((id, origin, size)) + }) + .unwrap(); + assert_eq!( + view.editor.picture_link(id), + Some("https://example.invalid/image/png") + ); + let _ = view + .pointer_moved(device(&view, [x + width / 2.0, y + height / 2.0])) + .unwrap(); + let response = view.pointer_pressed(Instant::now()).unwrap(); + let _ = view.pointer_released().unwrap(); + assert_eq!(response.request, None); + assert_eq!(view.object_focus, Some(ObjectFocus::Image(id))); + let _ = view + .modifiers_changed(Modifiers { + control: true, + command: true, + ..Modifiers::default() + }) + .unwrap(); + assert_eq!(view.cursor(), Cursor::Pointer); + let response = view.pointer_pressed(Instant::now()).unwrap(); + let _ = view.pointer_released().unwrap(); + assert_eq!( + response.request, + Some(Request::OpenLink( + "https://example.invalid/image/png".into() + )) + ); +} + +/// A click on the check box OneNote 2010 draws beside a tagged picture on the page checks +/// it, and undo clears it (`corpus/object-tags`). +#[test] +fn a_check_box_beside_a_page_picture_checks_it() { + let mut view = scene_view( + include_bytes!("../../../../corpus/object-tags/native/notebook/files.one"), + "Pictures and files", + ); + let (id, [x, y], [_, height]) = view + .editor + .objects + .iter() + .find_map(|object| match object { + crate::editor::page::Content::Image(image) if !image.tags.is_empty() => Some(image.id), + _ => None, + }) + .and_then(|id| { + let (origin, size) = view.editor.image_placement(id)?; + Some((id, origin, size)) + }) + .unwrap(); + let status = |view: &PageView| { + view.editor + .objects + .iter() + .find_map(|object| match object { + crate::editor::page::Content::Image(image) if image.id == id => { + Some(image.tags[0].status) + } + _ => None, + }) + .unwrap() + }; + let _ = view + .pointer_moved(device(&view, [x - 18.0, y + height / 2.0])) + .unwrap(); + assert_eq!(view.cursor(), Cursor::Default); + assert!(view.pointer_pressed(Instant::now()).unwrap().changed); + let _ = view.pointer_released().unwrap(); + assert_eq!(status(&view) & 1, 1); + assert!(view.editor.undo(&mut view.engine).unwrap()); + assert_eq!(status(&view) & 1, 0); +} + +/// OneNote 2010 resizes a column from its right border: a column-resize pointer over it, the +/// table redrawn as it moves, and one edit on release. +#[test] +fn a_column_border_drags_with_a_resize_pointer_and_stores_on_release() { + let mut engine = TextEngine::default(); + let mut editor = CanvasEditor::new( + &mut engine, + TextDocument::new(vec![Paragraph::new(String::new(), Format::default())]).unwrap(), + 468.0, + ) + .unwrap(); + editor.insert_table(&mut engine, 1, 2).unwrap(); + let mut view = PageView::new( + editor, + engine, + None, + [800, 600], + 1.0, + Duration::from_millis(500), + ); + let _ = view.editor.take_ops().unwrap(); + let outline = view.editor.active_outline(); + let cell = outline.shaped().tables[0].cells[0].clone(); + let origin = outline.origin(); + let (scale, offset) = (view.viewport.scale, view.viewport.origin); + let device = |point: [f32; 2]| [0, 1].map(|axis| point[axis] * scale + offset[axis]); + let border = [ + origin[0] + cell.rect[2], + origin[1] + (cell.rect[1] + cell.rect[3]) / 2.0, + ]; + let _ = view.pointer_moved(device(border)).unwrap(); + assert_eq!(view.cursor(), Cursor::ColResize); + let _ = view.pointer_pressed(Instant::now()).unwrap(); + let to = device([border[0] + 50.0, border[1]]); + assert!(view.pointer_moved(to).unwrap().changed); + assert_eq!(view.cursor(), Cursor::ColResize); + view.primitives(COLORS).unwrap(); + let onestore::page::ParagraphContent::Table(table) = + &view.editor.active_outline().document().nodes()[0].content + else { + panic!() + }; + assert!(!table.columns[0].locked); + let _ = view.pointer_released().unwrap(); + let onestore::page::ParagraphContent::Table(table) = + &view.editor.active_outline().document().nodes()[0].content + else { + panic!() + }; + assert!(table.columns[0].locked); + assert!((table.columns[0].width - (37.11 + 50.0)).abs() < 0.01); + assert_eq!(view.editor.take_ops().unwrap().len(), 1); +} diff --git a/crates/canvas/src/layout.rs b/crates/canvas/src/layout.rs index 2bccd6e2fa920c5da1291838b19baa9635f3dbfb..8fb8ea431dd89f7472a4d5f3687d486117e5ddbd 100644 --- a/crates/canvas/src/layout.rs +++ b/crates/canvas/src/layout.rs @@ -62,6 +62,7 @@ pub struct TextLayout { id: u64, pub(crate) shaped: Arc>, /// The paragraph text laid out, which a PDF's text layer maps glyphs back to. + #[cfg(feature = "gpu")] pub(crate) text: Arc, lines: Vec, /// Where each inline space lies: its index in the spaces laid out, its x, and its line. @@ -520,6 +521,7 @@ impl TextEngine { Ok(TextLayout { id, shaped: Arc::new(shaped), + #[cfg(feature = "gpu")] text: text.into(), lines, spaces: placed, diff --git a/crates/canvas/src/outline.rs b/crates/canvas/src/outline.rs index e1409e1d1b7d3e0cd3aab514a1d68ead33cd6086..91b1be1b502b01ae2e88e9c1f1bb20128d89084c 100644 --- a/crates/canvas/src/outline.rs +++ b/crates/canvas/src/outline.rs @@ -1,5 +1,5 @@ use crate::{ - document::{DocumentEdit, edited_nodes, leaves}, + document::{DocumentEdit, descendants, edited_nodes, leaves}, layout::{InlineSpace, LayoutError, TextEngine, TextLayout}, }; use onestore::page::text::{Paragraph, TextProjection}; @@ -529,6 +529,51 @@ impl ParagraphTag { } } +/// How far a 12 pt icon starts left of a picture or file on the page, as OneNote 2010 draws +/// one (`corpus/object-tags`). +const PAGE_INSET: f32 = 24.75; + +/// The note tags of a picture or file on the page, whose bounds are `bounds`: oldest first in +/// a column ending left of it, centred on it; a tag whose definition is missing draws nothing. +pub(crate) fn object_tags( + tags: &[onestore::document::Tag], + definitions: &BTreeMap, + [x, top, _, bottom]: [f32; 4], +) -> Vec { + let side = ParagraphTag::side(crate::layout::DEFAULT_FONT_SIZE); + let mut drawn: Vec = tags + .iter() + .rev() + .filter_map(|tag| { + let (icon, label) = if tag.status & 4 != 0 { + let shape = tag.shape.unwrap_or(0); + (TagIcon::Task { shape }, None) + } else { + let Kind::TagDefinition { shape, label, .. } = + &definitions.get(tag.definition.as_ref()?)?.kind + else { + return None; + }; + let checked = tag.status & 1 != 0; + (TagIcon::of(shape.unwrap_or(0), checked)?, label.as_deref()) + }; + Some(ParagraphTag { + icon, + origin: [0.0, (top + bottom - side) / 2.0], + size: side, + label: label.unwrap_or_default().to_owned(), + disabled: tag.status & 2 != 0, + }) + }) + .collect(); + let right = x - PAGE_INSET + ParagraphTag::SIZE; + let count = drawn.len() as f32; + for (index, tag) in drawn.iter_mut().enumerate() { + tag.origin[0] = right - side * (count - index as f32); + } + drawn +} + /// How much further than its list spacing OneNote 2010 sets a marker's advance from its text. const MARKER_OFFSET: f32 = 3.9; /// Points an equation's linear text lays out at, holding the caret without showing. @@ -1519,7 +1564,12 @@ impl OutlineLayout { unreachable!("text and tables are not objects") } }; - object.tags = BlockTags::new(node, &[], x, shape)?; + let tags = match content { + ParagraphContent::Image(image) => image.tags.as_slice(), + ParagraphContent::Attachment(file) => file.tags.as_slice(), + _ => &[], + }; + object.tags = BlockTags::new(node, tags, x, shape)?; let flow = object.place(y, height); result.objects.push(object); (space, height, flow, extent) @@ -1580,8 +1630,9 @@ impl OutlineLayout { } /// Lays out root nodes `range` of `nodes` once `edit` applies, the edit's own or the table - /// holding its cell, and works out where the nodes after them move. Resizing columns or - /// showing or hiding other nodes lays out every node; renumbering lays out the renumbered. + /// holding its cell, and works out where the nodes after them move. Resizing columns of + /// other nodes or showing or hiding them lays out every node; renumbering lays out the + /// renumbered. #[allow(clippy::too_many_arguments)] pub(crate) fn relayout( &self, @@ -1598,7 +1649,15 @@ impl OutlineLayout { &[f32], ) -> Result, ) -> Result { - let mut range = if edit.columns.is_empty() { + let resized = |node: &PageParagraph| match &node.content { + ParagraphContent::Table(table) => edit.columns.contains_key(&table.id), + _ => false, + }; + let mut range = if edit.columns.len() + == descendants(&nodes[range.clone()], None) + .filter(|(_, _, node)| resized(node)) + .count() + { range } else { 0..nodes.len() @@ -1869,6 +1928,13 @@ impl OutlineLayout { table_width(&self.tables) } + /// How far the content reaches, without the 36-point floor of `size`. + pub(crate) fn content_width(&self) -> f32 { + let text = self.paragraphs.iter().map(|paragraph| paragraph.size()[0]); + let objects = self.objects.iter().map(|object| object.rect[2]); + text.chain(objects).fold(self.table_width(), f32::max) + } + fn table( table: &Table, depth: usize, @@ -2747,6 +2813,7 @@ mod tests { bytes: None, preview: None, recording: None, + tags: Vec::new(), }); let mut ink = paragraph(3, "", 1, None); ink.content = ParagraphContent::Ink(Ink { diff --git a/crates/canvas/src/print.rs b/crates/canvas/src/print.rs index 02692d56d346f706170cd6fc2d51001b73872295..6357d970cab019daaf5eaa8b08774262dd1c51f5 100644 --- a/crates/canvas/src/print.rs +++ b/crates/canvas/src/print.rs @@ -5,8 +5,8 @@ //! when its content reaches past the paper's right edge ("Scale content to paper width"). //! A page longer than a sheet continues on the next, which starts at the first line of text //! or picture the sheet's foot would have cut. Rule lines and template art print across the -//! paper's width; the page colour does not. Each sheet's footer names the section and -//! numbers the sheet, counting from the first page printed, in Times New Roman. +//! paper's width; the page colour does not. Each sheet's footer names the section, numbers +//! the sheet counting from the first page printed, or both, in Times New Roman. use crate::gpu::{Paper, page::PageScene}; use crate::layout::{LayoutError, TextEngine}; @@ -40,15 +40,17 @@ struct Pagination { impl Pagination { /// Lays content covering `bounds`, whose `rows` (lines of text, pictures) no sheet - /// should cut, on `paper` from the page's `margin_origin`. + /// should cut, on `paper` from the page's `margin_origin`, shrunk to the paper's width + /// with `fit_width`. fn new( bounds: [f32; 4], rows: &[[f32; 2]], margin_origin: [f32; 2], paper: [f32; 2], + fit_width: bool, ) -> Self { let left = margin_origin[0] - LEFT; - let scale = if bounds[2].is_finite() { + let scale = if fit_width && bounds[2].is_finite() { (paper[0] / (bounds[2] - left + RIGHT)).min(1.0) } else { 1.0 @@ -113,29 +115,54 @@ impl fmt::Display for PrintError { impl std::error::Error for PrintError {} -/// `pages` in order as a PDF on `paper` (points), each on as many sheets as it takes, the -/// footers naming `section`. +/// What each sheet's footer says, as OneNote's Print Preview offers it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Footer { + /// "Section Page 3", OneNote's default. + #[default] + SectionAndPage, + Page, + Section, + None, +} + +/// How pages go on paper. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Setup { + /// Width and height in points, turned as the sheet prints. + pub paper: [f32; 2], + /// Shrinks a page whose content reaches past the paper's right edge to fit it. + pub fit_width: bool, + pub footer: Footer, +} + +/// `sections`' pages in order as a PDF, each on as many sheets as it takes, the footers +/// naming the section a page is in and numbering the sheets from the first. pub fn pdf( - pages: Vec, + sections: Vec<(String, Vec)>, engine: &mut TextEngine, - paper: [f32; 2], - section: &str, + setup: &Setup, ) -> Result, PrintError> { - let title = pages - .first() + let paper = setup.paper; + let title = sections + .iter() + .flat_map(|(_, pages)| pages.first()) + .next() .map(|page| page.title.clone()) .unwrap_or_default(); - let mut printed = Vec::with_capacity(pages.len()); - for page in pages { - let (margin_origin, rules) = (page.margin_origin, page.rule_lines); - let mut scene = PageScene::new(page, engine).map_err(PrintError::Scene)?; - scene.settle(None, DENSITY, Paper::WHITE); - let (bounds, rows) = scene.printed_extent().map_err(PrintError::Scene)?; - let pagination = Pagination::new(bounds, &rows, margin_origin, paper); - printed.push((scene, pagination, margin_origin, rules)); + let mut printed = Vec::new(); + for (section, pages) in sections { + for page in pages { + let (margin_origin, rules) = (page.margin_origin, page.rule_lines); + let mut scene = PageScene::new(page, engine).map_err(PrintError::Scene)?; + scene.settle(None, DENSITY, Paper::WHITE); + let (bounds, rows) = scene.printed_extent().map_err(PrintError::Scene)?; + let pagination = Pagination::new(bounds, &rows, margin_origin, paper, setup.fit_width); + printed.push((scene, pagination, margin_origin, rules, section.clone())); + } } let mut contents = Vec::with_capacity(printed.len()); - for (scene, pagination, margin_origin, rules) in &printed { + for (scene, pagination, margin_origin, rules, _) in &printed { let mut primitives = Vec::new(); scene .append_primitives(&mut primitives, [0.0; 2], Paper::WHITE) @@ -161,14 +188,17 @@ pub fn pdf( ..Default::default() }; let mut footers = Vec::new(); - for (sheet, _) in printed + for (sheet, section) in printed .iter() - .flat_map(|(_, pagination, ..)| pagination.sheets()) + .flat_map(|(_, pagination, .., section)| pagination.sheets().map(move |_| section)) .enumerate() { - let text = match section { - "" => format!("Page {}", sheet + 1), - section => format!("{section} Page {}", sheet + 1), + let number = sheet + 1; + let text = match (setup.footer, section.as_str()) { + (Footer::None, _) => continue, + (Footer::Section, section) => section.to_owned(), + (Footer::Page, _) | (Footer::SectionAndPage, "") => format!("Page {number}"), + (Footer::SectionAndPage, section) => format!("{section} Page {number}"), }; let layout = engine .layout(&Paragraph::new(text, format.clone()), f32::MAX) @@ -193,37 +223,33 @@ pub fn pdf( let scale = pagination.scale; for ([top, bottom], rules) in pagination.sheets().zip(rules) { let origin = [-pagination.left * scale, MARGIN - top * scale]; - // Rule lines run to the bottom margin, the page's content to where the next - // sheet takes it up. - let page = |primitives, bottom: f32| Layer { + let layer = |origin, clip, primitives| Layer { scale, origin, - clip: Some([0.0, MARGIN, paper[0], MARGIN + (bottom - top) * scale]), + clip, backdrop: None, round: None, motion: None, primitives, }; + // Rule lines run to the bottom margin, the page's content to where the next + // sheet takes it up. + let page = |primitives, bottom: f32| { + let clip = [0.0, MARGIN, paper[0], MARGIN + (bottom - top) * scale]; + layer(origin, Some(clip), primitives) + }; + let mut layers = vec![page(rules, top + pagination.band), page(primitives, bottom)]; // OneNote shrinks the footer with the page, towards the paper's bottom left. - let footer = Layer { - scale, - origin: [ + if let Some(footer) = footer.next() { + let at = [ paper[0] / 2.0 * scale, paper[1] - 2.0 - (1.0 - scale) * 16.0, - ], - clip: None, - backdrop: None, - round: None, - motion: None, - primitives: footer.next().expect("Each sheet has a footer"), - }; + ]; + layers.push(layer(at, None, footer)); + } sheets.push(Sheet { size: paper, - layers: vec![ - page(rules, top + pagination.band), - page(primitives, bottom), - footer, - ], + layers, }); } } @@ -236,6 +262,13 @@ mod tests { use onestore::{RevisionIndex, Store, document::Document}; use std::collections::HashMap; + /// OneNote 2010's defaults on Letter paper. + const ONENOTE: Setup = Setup { + paper: LETTER, + fit_width: true, + footer: Footer::SectionAndPage, + }; + fn section(path: &str) -> Vec { let bytes = std::fs::read(format!("{}/../../{path}", env!("CARGO_MANIFEST_DIR"))).unwrap(); let store = Store::parse(&bytes).unwrap(); @@ -456,7 +489,13 @@ mod tests { let rows: Vec<[f32; 2]> = (0..60) .map(|line| [90.0 + line as f32 * 20.0, 110.0 + line as f32 * 20.0]) .collect(); - let pagination = Pagination::new([36.0, 14.4, 400.0, 1290.0], &rows, [36.0, 14.4], LETTER); + let pagination = Pagination::new( + [36.0, 14.4, 400.0, 1290.0], + &rows, + [36.0, 14.4], + LETTER, + true, + ); assert_eq!(pagination.scale, 1.0); // 14.4 + 720 cuts the line from 730 to 750, which starts the next sheet. assert_eq!(pagination.tops, [14.4, 730.0]); @@ -471,6 +510,7 @@ mod tests { &[[20.0, 1000.0]], [36.0, 14.4], LETTER, + true, ); assert_eq!(pagination.tops, [14.4, 734.4]); } @@ -490,6 +530,7 @@ mod tests { &[], [36.0, 14.4], [612.36, 790.92], + true, ) .scale; assert!( @@ -504,10 +545,9 @@ mod tests { #[test] fn a_section_prints_on_onenotes_sheets() { let pdf = pdf( - section("corpus/print/native/Print.one"), + vec![("Print".into(), section("corpus/print/native/Print.one"))], &mut TextEngine::default(), - LETTER, - "Print", + &ONENOTE, ) .unwrap(); let sheets = sheets(&pdf); @@ -538,10 +578,12 @@ mod tests { #[test] fn ruled_pages_and_art_print_on_onenotes_sheets() { let pdf = pdf( - section("corpus/page-background/candidate/Rules.one"), + vec![( + "Rules".into(), + section("corpus/page-background/candidate/Rules.one"), + )], &mut TextEngine::default(), - LETTER, - "Rules", + &ONENOTE, ) .unwrap(); let sheets = sheets(&pdf); @@ -550,4 +592,44 @@ mod tests { assert!(sheets[9].1.contains("Rules Page 10")); assert!(!sheets[9].1.contains("VeryLargeGrid")); } + + #[test] + fn unscaled_pages_keep_their_size() { + let pagination = Pagination::new( + [36.0, 14.4, 1200.0, 100.0], + &[], + [36.0, 14.4], + LETTER, + false, + ); + assert_eq!(pagination.scale, 1.0); + } + + /// Sheets number on across sections, each footer naming its page's section, or say + /// what the footer choice asks for. + #[test] + fn footers_say_what_was_chosen() { + let print = section("corpus/print/native/Print.one"); + let rules = section("corpus/page-background/candidate/Rules.one"); + let two = vec![ + ("Print".into(), print[2..3].to_vec()), + ("Rules".into(), rules[..1].to_vec()), + ]; + let text = |footer| { + let setup = Setup { footer, ..ONENOTE }; + sheets(&pdf(two.clone(), &mut TextEngine::default(), &setup).unwrap()) + .into_iter() + .map(|(_, text)| text) + .collect::>() + }; + let both = text(Footer::SectionAndPage); + assert!(both[0].contains("Print Page 1") && both[1].contains("Rules Page 2")); + let pages = text(Footer::Page); + assert!(pages[1].contains("Page 2") && !pages[1].contains("Rules")); + assert!( + text(Footer::Section)[1].contains("Rules") + && !text(Footer::Section)[1].contains("Page 2") + ); + assert!(!text(Footer::None)[1].contains("Rules")); + } } diff --git a/crates/canvas/src/search.rs b/crates/canvas/src/search.rs index 90ae50799046f6e7701a126967271dce0442918a..e63476cca4c383097b8b94ab79402f85d90b5de9 100644 --- a/crates/canvas/src/search.rs +++ b/crates/canvas/src/search.rs @@ -203,10 +203,48 @@ fn text_paragraphs<'a>(page: &'a Page, mut visit: impl FnMut(&'a PageParagraph, } } -/// A page's text outside its title, a paragraph to a line. +/// The text OneNote recognised in each of the page's pictures, or printed on a printout's +/// pages, in page order. +fn picture_text(page: &Page) -> Vec<&str> { + fn walk<'a>(paragraphs: &'a [PageParagraph], out: &mut Vec<&'a str>) { + for paragraph in paragraphs { + match ¶graph.content { + ParagraphContent::Image(image) => { + out.extend(image.text.as_ref().map(|text| text.text.as_str())) + } + ParagraphContent::Table(table) => { + for cell in table.rows.iter().flat_map(|row| &row.cells) { + walk(&cell.paragraphs, out); + } + } + _ => {} + } + } + } + let mut out = Vec::new(); + for object in &page.objects { + match object { + PageObject::Outline(outline) => walk(&outline.paragraphs, &mut out), + PageObject::Image(image) => { + out.extend(image.text.as_ref().map(|text| text.text.as_str())) + } + _ => {} + } + } + out +} + +/// A page's text outside its title, a paragraph to a line, then the text in its pictures, +/// which OneNote searches too. pub fn page_text(page: &Page) -> String { let mut out = Vec::new(); text_paragraphs(page, |_, text| out.push(shown(text))); + out.extend( + picture_text(page) + .into_iter() + .flat_map(str::lines) + .map(str::to_owned), + ); out.retain(|line| !line.trim().is_empty()); out.join("\n") } diff --git a/crates/canvas/src/template.rs b/crates/canvas/src/template.rs index e2bfca3ff3e08f5a31f99ee798af5b610d425b8b..194f3489cc5b497b65ace4190934f51dc29003b0 100644 --- a/crates/canvas/src/template.rs +++ b/crates/canvas/src/template.rs @@ -47,6 +47,9 @@ impl Template { alt: None, background: true, printout: None, + tags: Vec::new(), + link: None, + text: None, }) }) .collect() diff --git a/crates/canvas/tests/attachment_insert.rs b/crates/canvas/tests/attachment_insert.rs index 8151120e78c001d61e3e6d91ca078e58b0a03faf..9a2c767028c8233f9cf892d7110b538c3b78eb94 100644 --- a/crates/canvas/tests/attachment_insert.rs +++ b/crates/canvas/tests/attachment_insert.rs @@ -58,6 +58,7 @@ fn file(name: &str, bytes: Vec, preview: Option>) -> Attachment { bytes: Some(bytes.into()), preview, recording: None, + tags: Vec::new(), } } diff --git a/crates/canvas/tests/default_font.rs b/crates/canvas/tests/default_font.rs new file mode 100644 index 0000000000000000000000000000000000000000..e6e284a960e59e5838a2c0affc9d459682aa844e --- /dev/null +++ b/crates/canvas/tests/default_font.rs @@ -0,0 +1,122 @@ +//! OneNote's Default font as the editor stores it: a new page's title in its face and colour, +//! and new text in the page's `p` quick style in its face, size and colour. `CANVAS_DEFAULT_FONT_EXPORT` +//! names a directory receiving the section as a notebook, for a cold reopen in OneNote 2010 +//! (`corpus/default-font`). + +use canvas::{ + editor::{CanvasEditor, DefaultFont}, + layout::TextEngine, +}; +use onestore::{ + Arena, PageCreation, Section, Store, + document::Kind, + op::{Edit, Op, SectionOp}, + page::{PageObject, ParagraphContent}, +}; + +/// Office's standard Blue, COLORREF, which OneNote 2010 stores as `DefaultFontColor`. +const BLUE: u32 = 0xc07000; + +#[test] +fn new_pages_and_text_take_the_default_font() { + let source = onestore::create_section("fonts.one", "Calibri page", "Author").unwrap(); + let arena = Arena::default(); + let mut section = Section::open(&arena, source.clone()).unwrap(); + let creation = PageCreation::new(None, Some("Georgia title"), "Author") + .unwrap() + .titled_in("Georgia", Some(BLUE)) + .unwrap(); + let mut at = 133_000_000_000_000_000; + section + .apply( + "Author", + &Edit { + at, + ops: vec![Op::Section(SectionOp::Create(creation))], + }, + ) + .unwrap(); + let (space, ..) = section.pages().unwrap()[1].clone(); + let mut engine = TextEngine::default(); + let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap(); + editor.default_font = DefaultFont { + face: "Georgia".into(), + size: 14.0, + color: Some(BLUE), + }; + for (text, y) in [("Georgia body", 120.0), ("Georgia again", 200.0)] { + editor.place_caret(&mut engine, [72.0, y], 240.0).unwrap(); + editor.insert(&mut engine, text).unwrap(); + let ops = editor.take_ops().unwrap(); + at += 10_000_000; + let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect(); + section.apply("Author", &Edit { at, ops }).unwrap(); + } + let mut image = source; + section.seal().unwrap().unwrap().apply(&mut image).unwrap(); + + let arena = Arena::default(); + let reopened = Section::open(&arena, image.clone()).unwrap(); + let page = reopened.page(space).unwrap(); + let style = |id| { + let definition = &page.definitions[&id]; + let Kind::Style { name, .. } = &definition.kind else { + panic!("{definition:?}") + }; + ( + name.as_deref().unwrap_or_default().to_owned(), + definition.format.font.clone().unwrap_or_default(), + definition.format.font_size, + definition.format.color, + ) + }; + let title = page + .objects + .iter() + .find_map(|object| match object { + PageObject::Title(title) => Some(title), + _ => None, + }) + .unwrap(); + let titled = title + .outlines + .iter() + .flat_map(|outline| &outline.paragraphs) + .filter_map(|paragraph| paragraph.style) + .map(style) + .find(|(name, ..)| name == "PageTitle"); + assert_eq!( + titled, + Some(("PageTitle".into(), "Georgia".into(), Some(17.0), Some(BLUE))) + ); + let bodies: Vec<_> = page + .objects + .iter() + .filter_map(|object| match object { + PageObject::Outline(outline) if !outline.title => Some(&outline.paragraphs[0]), + _ => None, + }) + .collect(); + assert_eq!(bodies.len(), 2); + for body in &bodies { + assert!(matches!(body.content, ParagraphContent::Text(_))); + assert_eq!( + style(body.style.unwrap()), + ("p".into(), "Georgia".into(), Some(14.0), Some(BLUE)) + ); + } + assert_eq!(bodies[0].style, bodies[1].style, "one p per font"); + + if let Some(directory) = std::env::var_os("CANVAS_DEFAULT_FONT_EXPORT") { + let directory = std::path::PathBuf::from(directory); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write(directory.join("fonts.one"), &image).unwrap(); + let file_id = Store::parse(&image).unwrap().header.file_id; + std::fs::write( + directory.join("Open Notebook.onetoc2"), + onestore::create_table_of_contents("Open Notebook.onetoc2", &[("fonts.one", file_id)]) + .unwrap(), + ) + .unwrap(); + } +} diff --git a/crates/canvas/tests/links_equations.rs b/crates/canvas/tests/links_equations.rs index 0129c39e54a88eaf80dbc1dd7b760f9185d71152..bdf88e9df56df40f192674a53a2136c9ce2b912d 100644 --- a/crates/canvas/tests/links_equations.rs +++ b/crates/canvas/tests/links_equations.rs @@ -874,11 +874,18 @@ fn selections_into_equation_objects_take_them_whole() { // Enter in "Type equation here." leaves the placeholder's paragraph empty above a new one. let placeholder = find(&editor, "equation here"); - select(&mut editor, placeholder.paragraph, placeholder.offset..placeholder.offset); + select( + &mut editor, + placeholder.paragraph, + placeholder.offset..placeholder.offset, + ); editor.enter(engine, false).unwrap(); save(&mut editor); let after = texts(&editor); - assert_eq!(after[placeholder.paragraph..=placeholder.paragraph + 1], ["", ""]); + assert_eq!( + after[placeholder.paragraph..=placeholder.paragraph + 1], + ["", ""] + ); assert_eq!(caret(&editor).paragraph, placeholder.paragraph + 1); // 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() { .utf16_offset(texts(&editor)[end].len()) .unwrap(); select(&mut editor, end, offset..offset); - for (before, linear, after) in [("One ", "a/b+c ", " two"), ("Three ", "\\sqrt x+y ", " four")] { + for (before, linear, after) in [ + ("One ", "a/b+c ", " two"), + ("Three ", "\\sqrt x+y ", " four"), + ] { typed(&mut editor, engine, before); editor.insert_equation(engine).unwrap(); typed(&mut editor, engine, linear); diff --git a/crates/canvas/tests/ops_differential.rs b/crates/canvas/tests/ops_differential.rs index 173fc4271a02d75d6376a89b162d4cb3231854a8..9d8483251f52d290d4859a9ca382d36b5641513d 100644 --- a/crates/canvas/tests/ops_differential.rs +++ b/crates/canvas/tests/ops_differential.rs @@ -31,7 +31,7 @@ const ART: [u8; 67] = [ #[path = "../../onestore/tests/support/sweep.rs"] mod sweep; -const SECTIONS: [&str; 19] = [ +const SECTIONS: [&str; 20] = [ "corpus/outline-edit/before/notebook/synthetic.one", "corpus/paragraph-edit/before/notebook/synthetic.one", "corpus/outline-edit/tree/before/notebook/synthetic.one", @@ -51,6 +51,7 @@ const SECTIONS: [&str; 19] = [ "corpus/math-edit/native-enter/notebook/links.one", "corpus/paragraph-format/cold/notebook/synthetic.one", "corpus/canvas/baseline-anchors.one", + "corpus/styles/onenote/Styles.one", ]; #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] @@ -130,6 +131,15 @@ enum Kind { /// Lassoed drawings moved or deleted. LassoMove, LassoDelete, + /// A style from the Styles gallery on the caret's paragraph or a selection. + Style, + /// The stored page's styles restyled to a theme, as a theme reaches a page, and the + /// editor refreshed from what is stored. + Theme, + /// A two-by-two table at the caret with a line typed into its first cell, widening it. + Table, + /// The first table's first column dragged 30 points wider, locking it. + ColumnResize, Undo, Redo, } @@ -267,6 +277,74 @@ const INK_PLANS: [&[Kind]; 6] = [ &[Kind::LassoMove, Kind::LassoDelete, Kind::Undo, Kind::Undo], ]; +/// Gallery styles and themes, in plans of their own so the random edits' sequences stay as +/// they were. +/// Tables whose columns fit as they are typed in and dragged, in plans of their own so the +/// random edits' sequences stay as they were. +const TABLE_PLANS: [&[Kind]; 2] = [ + &[Kind::Table, Kind::Undo, Kind::Redo, Kind::TypeOn], + &[ + Kind::Table, + Kind::ColumnResize, + Kind::TypeOn, + Kind::Undo, + Kind::Undo, + Kind::Redo, + ], +]; + +const STYLE_PLANS: [&[Kind]; 5] = [ + &[Kind::Style, Kind::Undo, Kind::Redo], + &[Kind::Style, Kind::EnterEnd, Kind::Type, Kind::Undo], + &[Kind::Theme, Kind::Style, Kind::TypeEnd, Kind::Undo], + &[Kind::Style, Kind::Theme, Kind::EnterEnd, Kind::Type], + &[Kind::Theme, Kind::Theme, Kind::Style, Kind::Redo], +]; + +/// A theme's styles for the sweep: OneNote's names in another look, headings followed by +/// Normal; `alternate` picks one of two, so a second restyle changes the page again. +fn theme(alternate: bool) -> BTreeMap { + use onestore::document::Format; + [ + "h1", + "h2", + "h3", + "PageTitle", + "cite", + "blockquote", + "code", + "p", + ] + .into_iter() + .enumerate() + .map(|(at, name)| { + let definition = onestore::page::Definition { + kind: Node::Style { + name: Some(name.into()), + next: name.starts_with('h').then(|| "p".into()), + }, + format: Format { + bold: Some(name.starts_with('h')), + italic: Some(name == "blockquote"), + underline: Some(false), + strike: Some(false), + superscript: Some(false), + subscript: Some(false), + font: Some(if alternate { "Georgia" } else { "Arial" }.into()), + font_size: Some(10.0 + at as f32), + color: Some(if alternate { 0x0022_3344 } else { 0xff00_0000 }), + highlight: Some(0xff00_0000), + space_before: Some(if alternate { 6.0 } else { 0.0 }), + space_after: Some(0.0), + line_spacing: Some(0.0), + ..Format::default() + }, + }; + (name.to_owned(), definition) + }) + .collect() +} + /// Page colour, rule lines and art undone and redone across each other. const PAPER_PLANS: [&[Kind]; 3] = [ &[Kind::Art, Kind::Art, Kind::Undo, Kind::Undo, Kind::Redo], @@ -327,6 +405,7 @@ fn perform( Kind::Undo | Kind::Redo | Kind::TypeOn + | Kind::ColumnResize | Kind::PlaceImage | Kind::RemoveImage | Kind::CreateOutline @@ -468,6 +547,29 @@ fn perform( let id = outline.id; editor.move_outline(id, [x + 36.0, y + 18.0]).is_ok() } + Kind::Table => { + place(editor, middle) + && editor.insert_table(engine, 2, 2).is_ok() + && editor + .insert(engine, "A line long enough to widen its column") + .is_ok() + } + Kind::ColumnResize => { + let table = editor + .active_outline() + .document() + .nodes() + .iter() + .find_map(|node| match &node.content { + onestore::page::ParagraphContent::Table(table) => { + Some((table.id, table.columns[0].width)) + } + _ => None, + }); + table.is_some_and(|(id, width)| { + editor.resize_column(engine, id, 0, width + 30.0).is_ok() + }) + } Kind::ResizeOutline => { let width = editor.active_outline().wrap_width(); editor.resize(engine, width + 72.0).is_ok() @@ -602,6 +704,9 @@ fn perform( alt: None, background: true, printout: None, + tags: Vec::new(), + link: None, + text: None, }; let shown = editor.page().unwrap().objects.iter().any(|object| { matches!(object, onestore::page::PageObject::Image(image) if image.background) @@ -630,6 +735,7 @@ fn perform( kind: 1, duration_ms: Some(1000), }), + tags: Vec::new(), }; editor.insert(engine, "noted").is_ok() && editor.finish_recording(engine, file).is_ok() @@ -645,6 +751,7 @@ fn perform( bytes: Some(std::sync::Arc::from(b"notes".as_slice())), preview: Some(std::sync::Arc::from(ART.as_slice())), recording: None, + tags: Vec::new(), }; place(editor, middle) && editor.insert_attachment(engine, file).is_ok() } @@ -658,6 +765,7 @@ fn perform( bytes: Some(std::sync::Arc::from(b"float".as_slice())), preview: Some(std::sync::Arc::from(ART.as_slice())), recording: None, + tags: Vec::new(), }; let x = 36.0 + 18.0 * random.below(20) as f32; editor.place_caret(engine, [x, 1440.0], 240.0).is_ok() @@ -778,6 +886,17 @@ fn perform( _ => editor.delete_ink(&[ink.id]).is_ok(), } } + Kind::Style => { + let sheet = theme(random.below(2) == 0); + let names = ["h1", "h2", "blockquote", "code", "p"]; + let style = sheet[names[random.below(names.len())]].clone(); + (if random.below(2) == 0 { + place(editor, middle) + } else { + range(editor, random) + }) && editor.format(engine, Formatting::Style(style)).is_ok() + } + Kind::Theme => unreachable!("The sweep restyles what is stored"), Kind::Undo => editor.undo(engine).unwrap_or(false), Kind::Redo => editor.redo(engine).unwrap_or(false), } @@ -843,6 +962,66 @@ fn comparable(page: &Page) -> serde_json::Value { } impl Stored<'_> { + /// Restyles the stored page to a theme, as opening a themed page does, checks it against + /// the model and the sealed image, and refreshes the editor from it, which then shows it. + fn theme( + &mut self, + editor: &mut CanvasEditor, + engine: &mut TextEngine, + alternate: bool, + ) -> Option { + self.at += 10_000_000; + let before = self.section.page(self.space).unwrap(); + let sheet = theme(alternate); + let ops = onestore::op::restyle(&before, &sheet).unwrap(); + if ops.is_empty() { + return None; + } + editor.styles = sheet; + let edit = Edit { + at: self.at, + ops: ops + .iter() + .map(|op| Op::Page { + space: self.space, + op: op.clone(), + }) + .collect(), + }; + if let Err(error) = self.section.apply("Sweep", &edit) { + return Some(Outcome::Differs(format!("a theme is refused: {error}"))); + } + let stored = self.section.page(self.space).unwrap(); + let mut predicted = before; + for op in &ops { + onestore::op::predict(&mut predicted, op).unwrap(); + } + if comparable(&predicted) != comparable(&stored) { + return Some(Outcome::Differs(format!( + "the model predicts a theme otherwise: {}", + difference(&stored, &predicted) + ))); + } + self.section.seal().unwrap(); + let arena = Arena::default(); + let reread = Section::open(&arena, self.section.image()) + .unwrap() + .page(self.space) + .unwrap(); + if reread != stored { + return Some(Outcome::Differs("a theme reads back otherwise".into())); + } + editor.refresh(stored.clone(), engine).unwrap(); + Some(if reads_as(&stored, &editor.page().unwrap()) { + Outcome::Same + } else { + Outcome::Differs(format!( + "the refreshed editor shows otherwise: {}", + difference(&stored, &editor.page().unwrap()) + )) + }) + } + /// Stores the editor's step as its ops, checks the stored page against what the model /// oracle predicts from them and against the sealed image reread, and, unless it is the /// editor's page, compares with that page lowered whole onto the section as it was @@ -1071,6 +1250,8 @@ fn sweep( .chain(RECORD_PLANS.map(<[Kind]>::to_vec)) .chain(FLOATING_PLANS.map(<[Kind]>::to_vec)) .chain(TYPING_PLANS.map(<[Kind]>::to_vec)) + .chain(STYLE_PLANS.map(<[Kind]>::to_vec)) + .chain(TABLE_PLANS.map(<[Kind]>::to_vec)) .collect::>>(); for plan in plans { let arena = Arena::default(); @@ -1082,13 +1263,20 @@ fn sweep( let mut editor = CanvasEditor::from_page(page.clone(), engine).unwrap(); for kind in &plan { let model = editor.page().unwrap(); - if !perform(&mut editor, engine, *kind, random) { - continue; - } - if std::env::var_os("OPS_SWEEP_DEBUG").is_some() { - eprintln!("STEP {kind:?} of {plan:?} on {title:?}"); - } - let outcome = stored.step(&mut editor, &model); + let outcome = if *kind == Kind::Theme { + match stored.theme(&mut editor, engine, random.below(2) == 0) { + Some(outcome) => outcome, + None => continue, + } + } else { + if !perform(&mut editor, engine, *kind, random) { + continue; + } + if std::env::var_os("OPS_SWEEP_DEBUG").is_some() { + eprintln!("STEP {kind:?} of {plan:?} on {title:?}"); + } + stored.step(&mut editor, &model) + }; *tally.outcomes.entry((*kind, outcome.clone())).or_default() += 1; if let Outcome::Differs(reason) = &outcome { tally diff --git a/crates/canvas/tests/picture_paste.rs b/crates/canvas/tests/picture_paste.rs index 940b40d8fe90a6a35efcf21a3692f84b99055feb..9ad6f1fb9398ccc166d951a8b5ae1c9ec7dd6c25 100644 --- a/crates/canvas/tests/picture_paste.rs +++ b/crates/canvas/tests/picture_paste.rs @@ -6,13 +6,13 @@ use canvas::{ document::TextPosition, - editor::{CanvasEditor, Selection}, + editor::{CanvasEditor, Piece, Selection}, layout::TextEngine, }; use onestore::{ - Arena, Section, Store, + Arena, PageCreation, Section, Store, document::Layout, - op::{Edit, Op}, + op::{Edit, Op, SectionOp}, page::{Image, PageObject, ParagraphContent}, }; use parley::Affinity; @@ -35,6 +35,9 @@ fn picture(size: [f32; 2]) -> Image { alt: None, background: false, printout: None, + tags: Vec::new(), + link: None, + text: None, } } @@ -123,6 +126,72 @@ fn pasted_pictures_split_the_text_or_lie_on_the_page_as_onenote_places_them() { store(&mut editor); assert_eq!(body(&editor), ["Pasted pictures"]); + // A web page's text and pictures in order, undone in one step. + editor.select(caret(0, 7)).unwrap(); + let png = picture([1.0; 2]).bytes.unwrap().to_vec(); + editor + .paste_pieces( + &mut engine, + vec![ + Piece::Text("web ".into()), + Piece::Picture(png, [150.0, 75.0]), + Piece::Text("page\ntext ".into()), + ], + 0x0409, + ) + .unwrap(); + store(&mut editor); + assert_eq!( + body(&editor), + [ + "Pasted web ", + "[150.0, 75.0]", + "", + "page", + "text ", + "pictures" + ] + ); + editor.undo(&mut engine).unwrap(); + store(&mut editor); + assert_eq!(body(&editor), ["Pasted pictures"]); + + // A web picture still on its way ends the paragraph and goes in when it arrives, an + // undo step of its own, while the caret stays. + editor.select(caret(0, 7)).unwrap(); + let awaited = editor + .paste_pieces( + &mut engine, + vec![ + Piece::Text("web".into()), + Piece::Awaited, + Piece::Text("page ".into()), + ], + 0x0409, + ) + .unwrap(); + store(&mut editor); + assert_eq!(body(&editor), ["Pasted web", "page pictures"]); + let typing = editor.selection(); + let arrived = picture([150.0, 75.0]); + assert!( + editor + .insert_awaited(&mut engine, awaited[0], arrived) + .unwrap() + ); + store(&mut editor); + assert_eq!( + body(&editor), + ["Pasted web", "[150.0, 75.0]", "page pictures"] + ); + assert_eq!(editor.selection(), typing); + editor.undo(&mut engine).unwrap(); + store(&mut editor); + assert_eq!(body(&editor), ["Pasted web", "page pictures"]); + editor.undo(&mut engine).unwrap(); + store(&mut editor); + assert_eq!(body(&editor), ["Pasted pictures"]); + // At the end, an empty paragraph follows for the caret; at an empty paragraph's start // the picture takes its place. 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() { .unwrap(); } } + +/// From the title, OneNote 2010 (lab, 2026-09-30) put a pasted picture at the end of the +/// outline where the body starts; on a page with no body, in a new outline there; and on a +/// page whose content lay elsewhere, on the page two grid rows below it. +#[test] +fn a_picture_pasted_from_the_title_goes_into_the_body() { + let source = onestore::create_section("pictures.one", "First page", "Author").unwrap(); + let arena = Arena::default(); + let mut section = Section::open(&arena, source).unwrap(); + let creation = PageCreation::new(None, Some("Title paste"), "Author").unwrap(); + section + .apply( + "Author", + &Edit { + at: 133_000_000_000_000_000, + ops: vec![Op::Section(SectionOp::Create(creation))], + }, + ) + .unwrap(); + let (space, ..) = section.pages().unwrap()[1].clone(); + let page = section.page(space).unwrap(); + let mut engine = TextEngine::default(); + let opened = |engine: &mut TextEngine| { + let mut editor = CanvasEditor::from_page(page.clone(), engine).unwrap(); + let title = editor.outlines().iter().find(|o| o.title).unwrap().id; + editor.focus_outline(title).unwrap(); + editor + }; + + // No body: a new outline where it starts. + let mut editor = opened(&mut engine); + let start = editor.body_start().unwrap(); + editor + .insert_picture(&mut engine, picture([150.0, 75.0])) + .unwrap(); + assert_eq!(body(&editor), ["[150.0, 75.0]", ""]); + assert_eq!(editor.active_outline().origin(), start); + + // A body where it starts: at its end. + let mut editor = opened(&mut engine); + editor.leave_title(&mut engine).unwrap(); + editor.insert(&mut engine, "body").unwrap(); + assert_eq!(editor.active_outline().origin(), start); + let title = editor.outlines().iter().find(|o| o.title).unwrap().id; + editor.focus_outline(title).unwrap(); + editor + .insert_picture(&mut engine, picture([150.0, 75.0])) + .unwrap(); + assert_eq!(body(&editor), ["body", "[150.0, 75.0]", ""]); + + // Content elsewhere: on the page two grid rows below it. + let mut editor = opened(&mut engine); + let margin = editor.margin_origin(); + editor + .place_caret(&mut engine, [start[0] + 270.0, start[1] + 72.0], 468.0) + .unwrap(); + editor.insert(&mut engine, "far").unwrap(); + let bottom = editor.active_outline().bounds().y1 as f32; + let title = editor.outlines().iter().find(|o| o.title).unwrap().id; + editor.focus_outline(title).unwrap(); + editor + .insert_picture(&mut engine, picture([150.0, 75.0])) + .unwrap(); + let placed = page_pictures(&editor).pop().unwrap(); + let row = ((bottom - margin[1]) / 18.0).floor() + 2.0; + assert_eq!( + [placed.layout.x, placed.layout.y], + [Some(start[0]), Some(margin[1] + row * 18.0)] + ); +} diff --git a/crates/canvas/tests/pictures.rs b/crates/canvas/tests/pictures.rs new file mode 100644 index 0000000000000000000000000000000000000000..d743e5e50633ad7c817d9014f1e307b01e8b10c3 --- /dev/null +++ b/crates/canvas/tests/pictures.rs @@ -0,0 +1,463 @@ +//! Pictures as OneNote 2010 stores them in the wild. + +use canvas::{editor::CanvasEditor, layout::TextEngine}; +use onestore::{ + Arena, Section, + op::{Edit, Op}, + page::{Page, PageObject, ParagraphContent}, +}; + +fn page(bytes: &[u8], title: &str) -> Page { + let arena = Arena::default(); + let mut section = Section::open(&arena, bytes.to_vec()).unwrap(); + let (space, ..) = section + .pages() + .unwrap() + .into_iter() + .find(|page| page.1 == title) + .unwrap(); + section.page(space).unwrap() +} + +/// A picture COM put on the page stores no layout size, only its own (PictureWidth and +/// PictureHeight), and shows at that size (`corpus/print`). +#[test] +fn a_page_picture_without_a_layout_size_shows_at_its_own() { + let page = page( + include_bytes!("../../../corpus/print/native/Print.one"), + "Printing test", + ); + let [picture] = &page + .objects + .iter() + .filter_map(|object| match object { + PageObject::Image(image) => Some(image), + _ => None, + }) + .collect::>()[..] + else { + panic!("one picture"); + }; + assert_eq!(picture.layout.max_width, None); + let mut engine = TextEngine::default(); + let editor = CanvasEditor::from_page(page.clone(), &mut engine).unwrap(); + let origin = [picture.layout.x.unwrap(), picture.layout.y.unwrap()]; + assert_eq!( + editor.image_placement(picture.id), + Some((origin, [150.0, 90.0])) + ); + #[cfg(feature = "gpu")] + { + let (scene, editor) = canvas::gpu::page::PageScene::from_page(page, &mut engine).unwrap(); + assert_eq!(scene.read_only(Some(&editor)).count(), 0); + } +} + +const OBJECTS: &[u8] = include_bytes!("../../../corpus/object-tags/native/notebook/files.one"); + +/// The picture or file `id` holds, wherever it is on the page. +fn tags_of(page: &Page, id: onestore::ExGuid) -> Vec { + let contents = page.objects.iter().flat_map(|object| match object { + PageObject::Outline(outline) => outline + .paragraphs + .iter() + .map(|paragraph| match ¶graph.content { + ParagraphContent::Image(image) => (image.id, image.tags.clone()), + ParagraphContent::Attachment(file) => (file.id, file.tags.clone()), + _ => (paragraph.id, Vec::new()), + }) + .collect(), + PageObject::Image(image) => vec![(image.id, image.tags.clone())], + PageObject::Attachment(file) => vec![(file.id, file.tags.clone())], + _ => Vec::new(), + }); + contents + .filter(|(object, _)| *object == id) + .map(|(_, tags)| tags) + .next() + .unwrap() +} + +/// OneNote 2010 stores a tag given to a picture or file on the object itself, in an +/// outline or on the page, and draws it in a column to its left centred on it; a click on a +/// check box checks it, one revision each, and undo clears it again +/// (`corpus/object-tags`). `CANVAS_OBJECT_TAGS_EXPORT` names a new directory receiving the +/// candidate for a cold reopen. +#[test] +fn tags_on_pictures_and_files_draw_and_check() { + let arena = Arena::default(); + let mut section = Section::open(&arena, OBJECTS.to_vec()).unwrap(); + let (space, ..) = section + .pages() + .unwrap() + .into_iter() + .find(|page| page.1 == "Pictures and files") + .unwrap(); + let page = section.page(space).unwrap(); + let outline = page + .objects + .iter() + .find_map(|object| match object { + PageObject::Outline(outline) if outline.paragraphs.len() == 4 => Some(outline), + _ => None, + }) + .unwrap(); + let [(picture_paragraph, picture), (_, file)] = [1, 2].map(|index| { + let paragraph = &outline.paragraphs[index]; + let id = match ¶graph.content { + ParagraphContent::Image(image) => image.id, + ParagraphContent::Attachment(file) => file.id, + _ => panic!("a picture and a file"), + }; + (paragraph.id, id) + }); + let outline_id = outline.id; + let (page_picture, page_file) = ( + page.objects + .iter() + .find_map(|object| match object { + PageObject::Image(image) if !image.tags.is_empty() => Some(image.id), + _ => None, + }) + .unwrap(), + page.objects + .iter() + .find_map(|object| match object { + PageObject::Attachment(file) => Some(file.id), + _ => None, + }) + .unwrap(), + ); + for id in [picture, file, page_picture, page_file] { + let tags = tags_of(&page, id); + assert_eq!(tags.len(), 1); + assert_eq!(tags[0].status & 1, 0); + assert!(page.definitions.contains_key(&tags[0].definition.unwrap())); + } + + let mut engine = TextEngine::default(); + let mut editor = CanvasEditor::from_page(page.clone(), &mut engine).unwrap(); + // The outline's tags stand beside the picture and the file they mark. + let shaped = editor + .outlines() + .iter() + .find(|item| item.id == outline_id) + .unwrap() + .shaped(); + let marked: Vec<_> = shaped.tags().map(|(paragraph, ..)| paragraph).collect(); + assert_eq!(marked, [picture_paragraph, outline.paragraphs[2].id]); + #[cfg(feature = "gpu")] + { + // The page picture at (400, 86), 120 by 90, has its tag 24.75 points to its left, + // centred on it. + let (scene, shown) = + canvas::gpu::page::PageScene::from_page(page.clone(), &mut engine).unwrap(); + let hit = |point| scene.hit_test(point, Some(&shown), |_| None::<()>); + assert_eq!( + hit([376.0, 126.0]), + Some(canvas::gpu::page::SceneHit::Check(page_picture)) + ); + assert_eq!( + hit([374.0, 126.0]), + None, + "the tag's column starts 24.75 points left" + ); + } + + let save = |editor: &mut CanvasEditor, section: &mut Section| { + let ops = editor + .take_ops() + .unwrap() + .into_iter() + .map(|op| Op::Page { space, op }) + .collect(); + section + .apply( + "Author", + &Edit { + at: 134_000_000_000_000_000, + ops, + }, + ) + .unwrap(); + section.seal().unwrap().unwrap(); + }; + editor.click_object_check(page_picture).unwrap(); + save(&mut editor, &mut section); + editor.click_object_check(page_file).unwrap(); + save(&mut editor, &mut section); + editor + .click_check(&mut engine, outline_id, picture_paragraph) + .unwrap(); + save(&mut editor, &mut section); + let stored = section.page(space).unwrap(); + for (id, checked) in [ + (page_picture, true), + (page_file, true), + (picture, true), + (file, false), + ] { + assert_eq!(tags_of(&stored, id)[0].status & 1 != 0, checked, "{id:?}"); + } + if let Some(directory) = std::env::var_os("CANVAS_OBJECT_TAGS_EXPORT") { + let directory = std::path::PathBuf::from(directory); + std::fs::create_dir(&directory).unwrap(); + let written = section.image(); + std::fs::write(directory.join("files.one"), &written).unwrap(); + let file_id = onestore::Store::parse(&written).unwrap().header.file_id; + std::fs::write( + directory.join("Open Notebook.onetoc2"), + onestore::create_table_of_contents("Open Notebook.onetoc2", &[("files.one", file_id)]) + .unwrap(), + ) + .unwrap(); + } + assert!(editor.undo(&mut engine).unwrap()); + assert!(editor.undo(&mut engine).unwrap()); + save(&mut editor, &mut section); + let stored = section.page(space).unwrap(); + assert_eq!(tags_of(&stored, page_file)[0].status & 1, 0); + assert_eq!(tags_of(&stored, picture)[0].status & 1, 0); + assert_eq!(tags_of(&stored, page_picture)[0].status & 1, 1); +} + +/// Text OneNote recognised in a picture is found by search, as OneNote finds it +/// (`corpus/object-tags`). +#[test] +fn search_finds_text_recognised_in_pictures() { + let page = page(OBJECTS, "Pictures and files"); + let recognised: Vec<_> = page + .objects + .iter() + .filter_map(|object| match object { + PageObject::Image(image) => image.text.as_ref().map(|text| text.text.as_str()), + _ => None, + }) + .collect(); + assert_eq!(recognised, ["GLACIER HARBOR\nInvoice 4471"]); + let mut index = canvas::search::Index::default(); + index.set(canvas::search::Entry::new( + "files", + onestore::ExGuid::default(), + &page, + 0, + )); + let found = index.search(&canvas::search::Query::new("invoice 4471"), |_| true); + assert_eq!(found.len(), 1); + assert_eq!(found[0].snippet, "Invoice 4471"); + assert!( + index + .search(&canvas::search::Query::new("harb"), |_| true) + .len() + == 1 + ); +} + +/// A picture's link reads back from where OneNote stores it (WzHyperlinkUrl), and a linked +/// picture deleted and brought back by undo keeps it (`corpus/picture-link`). +/// `CANVAS_PICTURE_LINK_EXPORT` names a new directory receiving the candidate for a cold +/// reopen. +#[test] +fn a_linked_picture_keeps_its_link_through_undo() { + let source = include_bytes!("../../../corpus/m6/native-features-01/notebook/Features.one"); + let arena = Arena::default(); + let mut section = Section::open(&arena, source.to_vec()).unwrap(); + let (space, ..) = section + .pages() + .unwrap() + .into_iter() + .find(|page| page.1 == "Image png") + .unwrap(); + let page = section.page(space).unwrap(); + let linked = |page: &Page| -> Vec<(onestore::ExGuid, Option)> { + page.objects + .iter() + .filter_map(|object| match object { + PageObject::Image(image) => Some((image.id, image.link.clone())), + _ => None, + }) + .collect() + }; + let before = linked(&page); + let [(id, Some(link))] = &before[..] else { + panic!("one linked picture"); + }; + assert_eq!(link, "https://example.invalid/image/png"); + let mut engine = TextEngine::default(); + let mut editor = CanvasEditor::from_page(page, &mut engine).unwrap(); + editor.remove_image(&mut engine, *id).unwrap(); + assert!(editor.undo(&mut engine).unwrap()); + let ops = editor + .take_ops() + .unwrap() + .into_iter() + .map(|op| Op::Page { space, op }) + .collect(); + section + .apply( + "Author", + &Edit { + at: 134_000_000_000_000_000, + ops, + }, + ) + .unwrap(); + section.seal().unwrap(); + assert_eq!(linked(§ion.page(space).unwrap()), before); + if let Some(directory) = std::env::var_os("CANVAS_PICTURE_LINK_EXPORT") { + let directory = std::path::PathBuf::from(directory); + std::fs::create_dir(&directory).unwrap(); + let written = section.image(); + std::fs::write(directory.join("Features.one"), &written).unwrap(); + let file_id = onestore::Store::parse(&written).unwrap().header.file_id; + std::fs::write( + directory.join("Open Notebook.onetoc2"), + onestore::create_table_of_contents( + "Open Notebook.onetoc2", + &[("Features.one", file_id)], + ) + .unwrap(), + ) + .unwrap(); + } +} + +/// A tag command on a selected picture or file tags the object itself, as OneNote 2010 +/// does (`corpus/object-tags`): a tag it has comes off, one it lacks goes on unchecked. +#[test] +fn tag_commands_tag_a_selected_picture_or_file() { + use canvas::editor::{Formatting, NoteTag}; + let arena = Arena::default(); + let mut section = Section::open(&arena, OBJECTS.to_vec()).unwrap(); + let (space, ..) = section + .pages() + .unwrap() + .into_iter() + .find(|page| page.1 == "Pictures and files") + .unwrap(); + let page = section.page(space).unwrap(); + let page_picture = page + .objects + .iter() + .find_map(|object| match object { + PageObject::Image(image) if !image.tags.is_empty() => Some(image.id), + _ => None, + }) + .unwrap(); + let file = page + .objects + .iter() + .find_map(|object| match object { + PageObject::Outline(outline) => { + outline.paragraphs.iter().find_map(|p| match &p.content { + ParagraphContent::Attachment(file) => Some(file.id), + _ => None, + }) + } + _ => None, + }) + .unwrap(); + let mut engine = TextEngine::default(); + let mut editor = CanvasEditor::from_page(page, &mut engine).unwrap(); + let to_do = Formatting::Tag(NoteTag::defaults()[0].clone(), 0); + let important = Formatting::Tag(NoteTag::defaults()[1].clone(), 1); + assert!( + editor + .format_object(&mut engine, page_picture, &to_do) + .unwrap() + ); + assert!(editor.format_object(&mut engine, file, &important).unwrap()); + assert!( + !editor + .format_object(&mut engine, file, &Formatting::Bullets) + .unwrap() + ); + let ops = editor + .take_ops() + .unwrap() + .into_iter() + .map(|op| Op::Page { space, op }) + .collect(); + section + .apply( + "Author", + &Edit { + at: 134_000_000_000_000_000, + ops, + }, + ) + .unwrap(); + section.seal().unwrap(); + let stored = section.page(space).unwrap(); + assert!(tags_of(&stored, page_picture).is_empty()); + let tags = tags_of(&stored, file); + assert_eq!(tags.len(), 2); + assert!(matches!( + &stored.definitions[&tags[0].definition.unwrap()].kind, + onestore::document::Kind::TagDefinition { label: Some(label), .. } if label == "Important" + )); +} + +/// A picture deleted and brought back by undo keeps the text OneNote recognised in it, +/// written back as OneNote stores it (`corpus/object-tags/restored`). +/// `CANVAS_RECOGNIZED_EXPORT` names a new directory receiving the candidate for a cold +/// reopen. +#[test] +fn a_restored_picture_keeps_its_recognised_text() { + let arena = Arena::default(); + let mut section = Section::open(&arena, OBJECTS.to_vec()).unwrap(); + let (space, ..) = section + .pages() + .unwrap() + .into_iter() + .find(|page| page.1 == "Pictures and files") + .unwrap(); + let page = section.page(space).unwrap(); + let recognized = |page: &Page| { + page.objects + .iter() + .find_map(|object| match object { + PageObject::Image(image) if image.text.is_some() => { + Some((image.id, image.text.clone().unwrap())) + } + _ => None, + }) + .unwrap() + }; + let (id, text) = recognized(&page); + assert_eq!(text.stored.len(), 2, "its language and word layout"); + let mut engine = TextEngine::default(); + let mut editor = CanvasEditor::from_page(page, &mut engine).unwrap(); + editor.remove_image(&mut engine, id).unwrap(); + assert!(editor.undo(&mut engine).unwrap()); + let ops = editor + .take_ops() + .unwrap() + .into_iter() + .map(|op| Op::Page { space, op }) + .collect(); + section + .apply( + "Author", + &Edit { + at: 134_000_000_000_000_000, + ops, + }, + ) + .unwrap(); + section.seal().unwrap(); + assert_eq!(recognized(§ion.page(space).unwrap()), (id, text)); + if let Some(directory) = std::env::var_os("CANVAS_RECOGNIZED_EXPORT") { + let directory = std::path::PathBuf::from(directory); + std::fs::create_dir(&directory).unwrap(); + let written = section.image(); + std::fs::write(directory.join("files.one"), &written).unwrap(); + let file_id = onestore::Store::parse(&written).unwrap().header.file_id; + std::fs::write( + directory.join("Open Notebook.onetoc2"), + onestore::create_table_of_contents("Open Notebook.onetoc2", &[("files.one", file_id)]) + .unwrap(), + ) + .unwrap(); + } +} diff --git a/crates/canvas/tests/printouts.rs b/crates/canvas/tests/printouts.rs index 82e27cbac05f1db6892d8a2ed523b6ddd5b97827..0955395ecaf04b384c15af27673d845bc8e6ddab 100644 --- a/crates/canvas/tests/printouts.rs +++ b/crates/canvas/tests/printouts.rs @@ -1,7 +1,7 @@ //! A file printout, whose pictures OneNote 2010 stores as pages of an XPS package with a PNG //! rendering of each beside it (WebPictureContainer14), shows that rendering and edits like //! any picture: its pages stay printouts when moved, and a page deleted comes back on undo -//! as the picture OneNote showed of it (`corpus/printout`). +//! as the printout page it was (`corpus/printout`). use canvas::{document::TextPosition, editor::CanvasEditor, layout::TextEngine}; use onestore::{ @@ -100,9 +100,16 @@ fn printouts_show_onenotes_rendering_and_move_as_pictures() { let moved = after.iter().find(|picture| picture.id == moved).unwrap(); assert_eq!([moved.layout.x, moved.layout.y], origin.map(Some)); assert!(moved.printout.is_some() && moved.display.is_some()); + // The page deleted and brought back by undo is the printout page it was: its package, + // page number and the raster OneNote showed of it. + let original = printed + .iter() + .find(|picture| picture.id == restored) + .unwrap(); let restored = after.iter().find(|picture| picture.id == restored).unwrap(); - assert_eq!(restored.printout, None); - assert_eq!(restored.bytes, printed[0].display); + assert_eq!(restored.printout, original.printout); + assert_eq!(restored.bytes, original.bytes); + assert_eq!(restored.display, original.display); if let Some(directory) = std::env::var_os("SNOWBOUND_PRINTOUT_EXPORT") { let directory = std::path::PathBuf::from(directory); std::fs::create_dir(&directory).unwrap(); @@ -119,3 +126,48 @@ fn printouts_show_onenotes_rendering_and_move_as_pictures() { .unwrap(); } } + +/// A copy of a printout's page keeps its pages printouts, as a page copied in OneNote does. +#[test] +fn a_copied_printout_page_keeps_its_printouts() { + let arena = Arena::default(); + let mut section = Section::open(&arena, PRINTOUTS.to_vec()).unwrap(); + let (space, ..) = section + .pages() + .unwrap() + .into_iter() + .find(|page| page.1 == "Printout") + .unwrap(); + let page = section.page(space).unwrap(); + let creation = onestore::PageCreation::new(None, Some("Copied"), "Author").unwrap(); + let edit = Edit { + at: 134_000_000_000_000_000, + ops: vec![Op::Section(onestore::op::SectionOp::Import { + creation, + page: page.copy().unwrap(), + })], + }; + section.apply("Author", &edit).unwrap(); + section.seal().unwrap(); + let (copied, ..) = section + .pages() + .unwrap() + .into_iter() + .find(|page| page.1 == "Copied") + .unwrap(); + let copy = section.page(copied).unwrap(); + let shown = |page: &Page| -> Vec<_> { + pictures(page) + .into_iter() + .map(|picture| { + ( + picture.printout.clone(), + picture.bytes.clone(), + picture.display.clone(), + picture.text.clone(), + ) + }) + .collect() + }; + assert_eq!(shown(©), shown(&page)); +} diff --git a/crates/canvas/tests/recording.rs b/crates/canvas/tests/recording.rs index 8695865cd4716d05b203c6022a938a8a151f3758..d380899b47ad9e715a59633e6de04eed1473c21d 100644 --- a/crates/canvas/tests/recording.rs +++ b/crates/canvas/tests/recording.rs @@ -89,6 +89,7 @@ fn a_recording_stores_its_line_its_linked_notes_and_its_file() { kind: 1, duration_ms: Some(1000), }), + tags: Vec::new(), }; editor.finish_recording(&mut engine, file).unwrap(); assert_eq!(editor.recording(), None); @@ -161,6 +162,7 @@ fn a_recording_from_the_title_goes_to_the_body_s_start() { kind: 1, duration_ms: None, }), + tags: Vec::new(), }; editor.finish_recording(&mut engine, file).unwrap(); let ops = editor.take_ops().unwrap(); diff --git a/crates/canvas/tests/table_widths.rs b/crates/canvas/tests/table_widths.rs new file mode 100644 index 0000000000000000000000000000000000000000..883c733d70209aa1e47e81f71bf930f62dae8d80 --- /dev/null +++ b/crates/canvas/tests/table_widths.rs @@ -0,0 +1,241 @@ +//! Table columns fit their content as it is typed and lock where a border is dragged, each +//! keystroke's width stored in the revision with its text, and OneNote 2010 reads them back +//! as it sizes them itself (`corpus/table-widths`). + +use canvas::{document::TextPosition, editor::CanvasEditor, layout::TextEngine}; +use onestore::{ + Arena, ExGuid, Section, Store, + op::{Edit, Op}, + page::{Page, PageObject, PageParagraph, ParagraphContent, TableColumn}, +}; + +const TABLES: &[u8] = include_bytes!("../../../corpus/table-tags/native/notebook/Tables.one"); + +/// Each table's first cell text and columns, in page order. +fn tables(page: &Page) -> Vec<(String, Vec)> { + fn walk(nodes: &[PageParagraph], out: &mut Vec<(String, Vec)>) { + for node in nodes { + if let ParagraphContent::Table(table) = &node.content { + let first = table.rows[0].cells[0].paragraphs[0] + .text() + .map_or(String::new(), |text| text.text.text().to_owned()); + out.push((first, table.columns.clone())); + for cell in table.rows.iter().flat_map(|row| &row.cells) { + walk(&cell.paragraphs, out); + } + } + } + } + let mut out = Vec::new(); + for object in &page.objects { + if let PageObject::Outline(outline) = object { + walk(&outline.paragraphs, &mut out); + } + } + out +} + +fn body(page: &Page) -> ExGuid { + page.objects + .iter() + .find_map(|object| match object { + PageObject::Outline(outline) if !outline.paragraphs.is_empty() => Some(outline.id), + _ => None, + }) + .unwrap() +} + +fn publish(section: &mut Section<'_>, space: ExGuid, editor: &mut CanvasEditor, at: u64) { + let ops = editor + .take_ops() + .unwrap() + .into_iter() + .map(|op| Op::Page { space, op }) + .collect(); + section.apply("Author", &Edit { at, ops }).unwrap(); +} + +/// Opens `space` with the caret at the end of its body's last paragraph. +fn at_end(section: &Section<'_>, space: ExGuid, engine: &mut TextEngine) -> CanvasEditor { + let page = section.page(space).unwrap(); + let mut editor = CanvasEditor::from_page(page.clone(), engine).unwrap(); + editor.focus_outline(body(&page)).unwrap(); + let document = editor.active_outline().document(); + let paragraph = document.paragraphs().count() - 1; + let offset = document + .paragraphs() + .last() + .unwrap() + .text() + .encode_utf16() + .count() as u32; + editor + .select([TextPosition { paragraph, offset }; 2].into()) + .unwrap(); + editor +} + +fn type_rows(editor: &mut CanvasEditor, engine: &mut TextEngine, rows: &[&[&str]]) { + for (index, row) in rows.iter().enumerate() { + if index > 0 { + editor.enter(engine, false).unwrap(); + } + for (column, text) in row.iter().enumerate() { + if column > 0 { + editor.tab(engine, false).unwrap(); + } + editor.insert(engine, text).unwrap(); + } + } +} + +fn close(columns: &[TableColumn], expected: &[(f32, bool)]) -> bool { + columns.len() == expected.len() + && columns + .iter() + .zip(expected) + .all(|(column, (width, locked))| { + (column.width - width).abs() < 0.01 && column.locked == *locked + }) +} + +/// `SNOWBOUND_TABLE_WIDTHS_EXPORT` names a new directory receiving the candidate for a cold +/// reopen. +#[test] +fn typed_and_dragged_columns_store_onenote_s_widths() { + let arena = Arena::default(); + let mut section = Section::open(&arena, TABLES.to_vec()).unwrap(); + let pages = section.pages().unwrap(); + let space = |title: &str| pages.iter().find(|page| page.1 == title).unwrap().0; + let (fruit, typed, dragged) = ( + space("Check a table"), + space("Edit a tagged table"), + space("Delete and undo"), + ); + let mut engine = TextEngine::default(); + let mut at = 134_000_000_000_000_000; + let mut step = |section: &mut Section<'_>, space, editor: &mut CanvasEditor| { + at += 10_000_000; + publish(section, space, editor, at); + }; + + // A table typed after the page's text, one revision per keystroke. + let mut editor = at_end(§ion, fruit, &mut engine); + editor.enter(&mut engine, false).unwrap(); + editor.insert_table(&mut engine, 1, 1).unwrap(); + step(&mut section, fruit, &mut editor); + for (index, row) in [ + ["Fruit", "Colour", "Notes"], + ["Apple", "Red", "Crisp and sweet, good for pies"], + ["Watermelon", "Green", "Summer"], + ] + .iter() + .enumerate() + { + if index > 0 { + editor.enter(&mut engine, false).unwrap(); + step(&mut section, fruit, &mut editor); + } + for (column, text) in row.iter().enumerate() { + if column > 0 { + editor.tab(&mut engine, false).unwrap(); + step(&mut section, fruit, &mut editor); + } + for character in text.chars() { + editor.insert(&mut engine, &character.to_string()).unwrap(); + step(&mut section, fruit, &mut editor); + } + } + } + let stored = tables(§ion.page(fruit).unwrap()); + // OneNote 2010 typing the same table (lab, 2026-09-30). + assert!( + close( + &stored[1].1, + &[(60.759, false), (37.11, false), (139.086, false)] + ), + "{stored:?}" + ); + + // Typing into a native table's cell widens its column; deleting narrows it again. + let mut editor = CanvasEditor::from_page(section.page(typed).unwrap(), &mut engine).unwrap(); + editor + .focus_outline(body(§ion.page(typed).unwrap())) + .unwrap(); + editor + .select( + [TextPosition { + paragraph: 1, + offset: 10, + }; 2] + .into(), + ) + .unwrap(); + editor + .insert(&mut engine, " and typed on in Snowbound") + .unwrap(); + step(&mut section, typed, &mut editor); + let widened = tables(§ion.page(typed).unwrap()); + assert!(widened[0].1[0].width > 100.0, "{widened:?}"); + + // A dragged column locks and keeps its width while text wraps in it, and the column + // after it moves; a long line in that column stops at the outline's width. + let mut editor = at_end(§ion, dragged, &mut engine); + editor.enter(&mut engine, false).unwrap(); + editor.insert_table(&mut engine, 1, 1).unwrap(); + type_rows(&mut editor, &mut engine, &[&["Dragged", "Fitted"]]); + step(&mut section, dragged, &mut editor); + let table = editor + .active_outline() + .document() + .nodes() + .iter() + .find_map(|node| match &node.content { + ParagraphContent::Table(table) + if table.rows[0].cells[0].paragraphs[0] + .text() + .is_some_and(|text| text.text.text() == "Dragged") => + { + Some(table.id) + } + _ => None, + }) + .unwrap(); + editor.resize_column(&mut engine, table, 0, 96.0).unwrap(); + step(&mut section, dragged, &mut editor); + editor + .insert( + &mut engine, + " and then a line long enough to reach the outline's edge and wrap there", + ) + .unwrap(); + step(&mut section, dragged, &mut editor); + let resized = tables(§ion.page(dragged).unwrap()); + assert!( + resized[2].1[0].locked && resized[2].1[0].width == 96.0, + "{resized:?}" + ); + assert!( + !resized[2].1[1].locked && resized[2].1[1].width > 200.0, + "{resized:?}" + ); + + section.seal().unwrap(); + let written = section.image(); + let reread = Section::open(&arena, written.clone()).unwrap(); + assert_eq!(tables(&reread.page(fruit).unwrap()), stored); + assert_eq!(tables(&reread.page(typed).unwrap()), widened); + assert_eq!(tables(&reread.page(dragged).unwrap()), resized); + if let Some(directory) = std::env::var_os("SNOWBOUND_TABLE_WIDTHS_EXPORT") { + let directory = std::path::PathBuf::from(directory); + std::fs::create_dir(&directory).unwrap(); + std::fs::write(directory.join("Tables.one"), &written).unwrap(); + let file_id = Store::parse(&written).unwrap().header.file_id; + std::fs::write( + directory.join("Open Notebook.onetoc2"), + onestore::create_table_of_contents("Open Notebook.onetoc2", &[("Tables.one", file_id)]) + .unwrap(), + ) + .unwrap(); + } +} diff --git a/crates/draw/src/render.rs b/crates/draw/src/render.rs index 551e387be5a83c8d4606b97e8e801df5bc6f6c93..f8ead5876512d611a120a5a37417dae3db41b524 100644 --- a/crates/draw/src/render.rs +++ b/crates/draw/src/render.rs @@ -448,12 +448,14 @@ pub enum Primitive<'a> { round: bool, color: [f32; 4], }, - /// A round pen's stroke between two points, `widths` units across at each, joined by the - /// lines touching both ends, as a pressure pen's width changes along a stroke. + /// A pen's stroke between two points, `widths` units across at each, joined by the + /// lines touching both ends, as a pressure pen's width changes along a stroke. A square + /// pen tip (not `round`) squares the ends in a PDF; the screen rounds them either way. Taper { from: [f32; 2], to: [f32; 2], widths: [f32; 2], + round: bool, color: [f32; 4], }, /// A highlighter's stroke between two points, `width` units across with square ends: @@ -897,6 +899,7 @@ impl Renderer { to, widths, color, + .. } => self.segment(space, *from, *to, *widths, true, *color)?, Primitive::Highlight { from, diff --git a/crates/draw/src/render/pdf.rs b/crates/draw/src/render/pdf.rs index ab01d8fe5400740d90ec43617f1a83591ee01fcd..064a130032576ccea9ff37dd11cb75db6ad5a6e1 100644 --- a/crates/draw/src/render/pdf.rs +++ b/crates/draw/src/render/pdf.rs @@ -521,7 +521,45 @@ impl Document { from, to, widths, + round: false, color, + } => { + content.save_state(); + self.paint(content, used, *color, false); + // Square ends reach half their width past each point, as a square tip does. + let [from_radius, to_radius] = widths.map(|width| width / 2.0); + let [dx, dy] = [to[0] - from[0], to[1] - from[1]]; + let length = dx.hypot(dy); + let [ux, uy] = if length > 0.0 { + [dx / length, dy / length] + } else { + [1.0, 0.0] + }; + let corner = |center: &[f32; 2], radius: f32, ahead: f32, side: f32| { + [ + center[0] + radius * (ux * ahead - uy * side), + center[1] + radius * (uy * ahead + ux * side), + ] + }; + let corners = [ + corner(from, from_radius, -1.0, 1.0), + corner(to, to_radius, 1.0, 1.0), + corner(to, to_radius, 1.0, -1.0), + corner(from, from_radius, -1.0, -1.0), + ]; + content.move_to(corners[0][0], corners[0][1]); + for corner in &corners[1..] { + content.line_to(corner[0], corner[1]); + } + content.close_path().fill_nonzero(); + content.restore_state(); + } + Primitive::Taper { + from, + to, + widths, + color, + .. } => { content.save_state(); self.paint(content, used, *color, false); diff --git a/crates/mobile/Cargo.toml b/crates/mobile/Cargo.toml index 17872d7bc3d090968c663c7d63b04966b765163a..8848bbc9c7c15caf7c4007e8e6e7617fe0064312 100644 --- a/crates/mobile/Cargo.toml +++ b/crates/mobile/Cargo.toml @@ -9,7 +9,7 @@ publish = false crate-type = ["staticlib"] [dependencies] -canvas = { path = "../canvas", features = ["interaction"] } +canvas = { path = "../canvas", features = ["interaction", "pdf"] } draw = { path = "../draw" } notebook = { path = "../notebook", features = ["smb"] } onestore = { path = "../onestore" } diff --git a/crates/mobile/include/snowbound.h b/crates/mobile/include/snowbound.h index 8fc62185998e1927a29306bf237fe1b532fb035a..f46002ad893265e3cb5bc5ef31712bc6c4ea5060 100644 --- a/crates/mobile/include/snowbound.h +++ b/crates/mobile/include/snowbound.h @@ -52,6 +52,8 @@ char *sb_section_pages(const Section *section); uint32_t sb_section_poll(const Section *section, uint8_t *status); char *sb_section_new_page(const Section *section, const char *parent, const char *date, const char *time); bool sb_section_delete_page(const Section *section, const char *id, const char *date, const char *time); +char *sb_section_themes(const Section *section, const char *id); +bool sb_section_set_theme(const Section *section, const char *id, uint8_t scope, const char *theme); bool sb_section_flush(const Section *section, double seconds); void sb_section_wake(const Section *section); void sb_section_free(Section *section); @@ -70,6 +72,8 @@ bool sb_view_block(const View *view, float x, float y, float rect[4]); void sb_view_set_transform(View *view, float zoom, float x, float y); uint8_t sb_view_target(const View *view, float x, float y); void sb_view_pressure(View *view, float pressure); +void sb_set_snap_to_grid(bool on); +void sb_set_default_font(const char *face, float size); bool sb_view_press(View *view, float x, float y); bool sb_view_drag(View *view, float x, float y); bool sb_view_release(View *view); @@ -77,6 +81,8 @@ bool sb_view_undo(View *view, bool redo); bool sb_view_can_undo(const View *view, bool redo); uint64_t sb_view_format(const View *view); bool sb_view_apply(View *view, uint8_t command); +bool sb_view_style(View *view, uint8_t place); +int32_t sb_view_style_place(const View *view); char *sb_view_correction(const View *view); bool sb_view_correct(View *view, uint8_t command, const char *text); char *sb_view_title(const View *view); @@ -92,6 +98,8 @@ char *sb_view_paper(const View *view); bool sb_view_set_paper(View *view, int16_t red, uint8_t green, uint8_t blue, int8_t ruled); bool sb_view_set_art(View *view, const char *name); char *sb_view_page_text(const View *view); +uint8_t *sb_view_pdf(const View *view, float width, float height, const char *section, size_t *length); +void sb_bytes_free(uint8_t *bytes, size_t length); int8_t sb_view_date_request(View *view, int64_t *seconds); bool sb_view_change_date(View *view, int64_t seconds, const char *date, const char *time); bool sb_view_insert_picture(View *view, const uint8_t *bytes, size_t length, float width, float height); diff --git a/crates/mobile/src/lib.rs b/crates/mobile/src/lib.rs index 12a8df2fe9d1e251168758451c7d622345ededca..9aaccd99f466b2fa5065948f522574a471a351b7 100644 --- a/crates/mobile/src/lib.rs +++ b/crates/mobile/src/lib.rs @@ -122,6 +122,26 @@ struct Canvas { } impl Canvas { + /// The page on `paper` as OneNote 2010 prints it, footers naming `section`. + fn pdf(&self, paper: [f32; 2], section: &str) -> Option> { + let page = self.page.editor.page().ok()?; + let mut engine = ENGINES + .lock() + .ok() + .and_then(|mut engines| engines.pop()) + .unwrap_or_default(); + let setup = canvas::print::Setup { + paper, + fit_width: true, + footer: canvas::print::Footer::SectionAndPage, + }; + let pdf = canvas::print::pdf(vec![(section.to_owned(), vec![page])], &mut engine, &setup); + if let Ok(mut engines) = ENGINES.lock() { + engines.push(engine); + } + pdf.ok() + } + fn new(space: ExGuid, page: Page, pixels: [u32; 2], scale: f32) -> Result { let mut engine = ENGINES .lock() @@ -139,6 +159,7 @@ impl Canvas { ); page.touch = true; page.host_viewport = true; + (page.snap_to_grid, page.editor.default_font) = options(); Ok(Self { page, space, @@ -235,7 +256,7 @@ impl Canvas { return Target::Grip; } match self.page.hit(device) { - Some(Hit::Handle { .. } | Hit::Resize { .. }) => Target::Grip, + Some(Hit::Handle { .. } | Hit::Resize { .. } | Hit::Column { .. }) => Target::Grip, Some(Hit::Image { id, .. }) if self.page.object_focus() == Some(ObjectFocus::Image(id)) => { @@ -612,6 +633,40 @@ fn gpu() -> std::sync::MutexGuard<'static, Option> { /// rather than enumerating the system's fonts again. static ENGINES: Mutex> = Mutex::new(Vec::new()); +/// The reader's Snap To Grid and Default font, which every page opened from then on takes, +/// and new pages' titles the font's face. +static OPTIONS: Mutex> = Mutex::new(None); + +fn options() -> (bool, canvas::editor::DefaultFont) { + OPTIONS + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone() + .unwrap_or((true, canvas::editor::DefaultFont::default())) +} + +/// OneNote's Snap To Grid: taps, drags and shapes land on the placement grid. +#[unsafe(no_mangle)] +pub extern "C" fn sb_set_snap_to_grid(on: bool) { + let font = options().1; + *OPTIONS.lock().unwrap_or_else(|error| error.into_inner()) = Some((on, font)); +} + +/// OneNote's Default font: new text in `face` at `size` points, new titles in `face`. +/// +/// # Safety +/// `face` is NUL-terminated UTF-8. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_set_default_font(face: *const c_char, size: f32) { + let snap = options().0; + let font = canvas::editor::DefaultFont { + face: string(face), + size, + color: None, + }; + *OPTIONS.lock().unwrap_or_else(|error| error.into_inner()) = Some((snap, font)); +} + pub struct View { /// Taken apart on drop, which returns its text engine to `ENGINES`. canvas: ManuallyDrop, @@ -668,9 +723,24 @@ impl View { .get_default_config(&gpu.adapter, pixels[0], pixels[1]) .ok_or("No supported surface configuration")?; surface.configure(gpu.renderer.device(), &config); - let (page, read_only) = section.shared.page(space)?; + let (mut page, read_only) = section.shared.page(space)?; + // The page wears its theme: style objects it gives otherwise are restyled first. + let sheet = section + .theme(page.identity) + .map(|theme| theme.sheet()) + .unwrap_or_default(); + let restyle = onestore::op::restyle(&page, &sheet)?; + if !read_only && !restyle.is_empty() { + section.shared.apply(Edit { + at: library::filetime(), + ops: restyle + .into_iter() + .map(|op| Op::Page { space, op }) + .collect(), + })?; + page = section.shared.page(space)?.0; + } let frame = Arc::::default(); - #[cfg_attr(not(target_os = "ios"), expect(unused_mut))] let mut canvas = Canvas::new(space, page, pixels, scale)?; #[cfg(target_os = "ios")] { @@ -679,6 +749,7 @@ impl View { Waker::from(frame.clone()), )); } + canvas.page.editor.styles = sheet; Ok(Self { canvas: ManuallyDrop::new(canvas), surface, @@ -967,6 +1038,37 @@ pub extern "C" fn sb_view_apply(view: &mut View, command: u8) -> bool { view.stored(result) } +/// Gives the selected paragraphs style `place` of OneNote's Styles gallery (Heading 1 to 6, +/// Page Title, Citation, Quote, Code, Normal) in the page's theme, or OneNote 2010's. +#[unsafe(no_mangle)] +pub extern "C" fn sb_view_style(view: &mut View, place: u8) -> bool { + use notebook::sidecar::themes::{STYLES, built_in, definition}; + let Some((name, _)) = STYLES.get(usize::from(place)) else { + return false; + }; + let editor = &view.canvas.page.editor; + let style = editor.styles.get(*name).cloned().unwrap_or_else(|| { + let theme = built_in().swap_remove(0); + definition(name, &theme.styles[*name]) + }); + let result = view + .canvas + .page + .format(canvas::editor::Formatting::Style(style)) + .map(moved); + view.stored(result) +} + +/// The place in the Styles gallery of the style every selected paragraph shares, or -1. +#[unsafe(no_mangle)] +pub extern "C" fn sb_view_style_place(view: &View) -> i32 { + let state = view.canvas.page.editor.format_state().unwrap_or_default(); + notebook::sidecar::themes::STYLES + .iter() + .position(|(name, _)| state.style.as_deref() == Some(*name)) + .map_or(-1, |place| place as i32) +} + /// The marked word at the caret, or selected, as JSON `{"word", "suggestions", /// "repeated"}`; null where there is none. #[unsafe(no_mangle)] @@ -1241,6 +1343,33 @@ pub extern "C" fn sb_view_page_text(view: &View) -> *mut c_char { owned(view.canvas.page_text()) } +/// The page on paper `width` × `height` points as OneNote 2010 prints it, each sheet's +/// footer naming `section`: `length` bytes of PDF, freed with `sb_bytes_free`, or null. +/// +/// # Safety +/// `section` is NUL-terminated UTF-8. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_view_pdf( + view: &View, + width: f32, + height: f32, + section: *const c_char, + length: &mut usize, +) -> *mut u8 { + let Some(pdf) = view.canvas.pdf([width, height], &string(section)) else { + return std::ptr::null_mut(); + }; + *length = pdf.len(); + Box::into_raw(pdf.into_boxed_slice()).cast() +} + +/// # Safety +/// `bytes` and `length` came from this library and are not used again. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_bytes_free(bytes: *mut u8, length: usize) { + drop(unsafe { Box::from_raw(std::ptr::slice_from_raw_parts_mut(bytes, length)) }); +} + /// Puts the picture file `bytes` (JPEG or PNG), `width` × `height` points, at the caret: /// splitting its paragraph, or on the page at a caret on blank page. /// diff --git a/crates/mobile/src/library.rs b/crates/mobile/src/library.rs index 94fc77f69707a886ab50b28eebfe183df763e65d..d1afb35381eb36a9c89cd01949af5b80d97a1457 100644 --- a/crates/mobile/src/library.rs +++ b/crates/mobile/src/library.rs @@ -706,12 +706,62 @@ pub(crate) fn filetime() -> u64 { } /// A page OneNote 2010 would create now, titled `date` and `time` as the host formats -/// them: the long date and the short time. +/// them: the long date and the short time; titled in the Default font's face. fn dated(author: &str, date: &str, time: &str) -> Result { - Ok(PageCreation::new(None, Some(""), author)?.dated(date, time)?) + let font = crate::options().1; + Ok(PageCreation::new(None, Some(""), author)? + .titled_in(&font.face, font.color)? + .dated(date, time)?) } impl Section { + /// The notebook's themes; none for a lone section. + pub(crate) fn themes(&self) -> notebook::sidecar::themes::Themes { + let notebook = self + .library + .notebook + .lock() + .unwrap_or_else(|error| error.into_inner()); + notebook + .as_ref() + .and_then(|notebook| crate::report(notebook.themes().map_err(Into::into))) + .unwrap_or_default() + } + + /// Merges `change` into the notebook's themes. + pub(crate) fn save_themes(&self, change: notebook::sidecar::themes::Themes) -> Result<()> { + let notebook = self + .library + .notebook + .lock() + .unwrap_or_else(|error| error.into_inner()); + notebook + .as_ref() + .ok_or("A section on its own keeps no themes")? + .save_themes(change)?; + Ok(()) + } + + /// The theme scopes of page `id`: the page, its section and the notebook, where known. + pub(crate) fn scopes(&self, id: &str) -> Result<[Option; 3]> { + use notebook::sidecar::themes::Scope; + let (page, _) = self.shared.page(id.parse()?)?; + Ok([ + page.identity.map(Scope::page), + self.shared.section.identity().ok().map(Scope::section), + Some(Scope::Notebook), + ]) + } + + /// The theme page `identity` of this section wears, if any scope names one. + pub(crate) fn theme( + &self, + identity: Option<[u8; 16]>, + ) -> Option { + let section = self.shared.section.identity().ok(); + self.themes().effective(section, identity) + } + /// The section at catalog `path` of `library`, its edits naming `author`. pub(crate) fn open( library: Arc, @@ -862,8 +912,7 @@ impl Section { /// nothing waits to be published. pub(crate) fn flush(&self, limit: Duration) -> bool { let section = &self.shared.section; - // The section thread answers in order, so an answer means earlier edits are stored. - if report(section.pages().map_err(Into::into)).is_none() { + if report(section.written().map_err(Into::into)).is_none() { return false; } let deadline = Instant::now() + limit; @@ -1598,6 +1647,80 @@ pub unsafe extern "C" fn sb_section_new_page( report(page).map_or(std::ptr::null_mut(), |space| owned(space.to_string())) } +/// The notebook's themes for page `id` as JSON: `themes`, each with `id` and `name`, then +/// the id each of `page`, `section` and `notebook` names itself, or null. +/// +/// # Safety +/// `id` is NUL-terminated UTF-8. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_section_themes(section: &Section, id: *const c_char) -> *mut c_char { + use notebook::sidecar::themes::Scope; + #[derive(serde::Serialize)] + struct Listed { + id: String, + name: String, + } + #[derive(serde::Serialize)] + struct Shown { + themes: Vec, + page: Option, + section: Option, + notebook: Option, + } + let shown = (|| { + let scopes = section.scopes(&string(id))?; + let themes = section.themes(); + let named = |scope: &Option| themes.assigned(scope.as_ref()?).map(|theme| theme.id); + Ok(Shown { + themes: themes + .all() + .into_iter() + .map(|theme| Listed { + id: theme.id, + name: theme.name, + }) + .collect(), + page: named(&scopes[0]), + section: named(&scopes[1]), + notebook: named(&scopes[2]), + }) + })(); + json(shown) +} + +/// Gives theme `theme` (null for none of its own) to page `id` (`scope` 0), its section (1) +/// or its notebook (2); the page wears it when next opened. Writes to the notebook, so call +/// it off the main thread. +/// +/// # Safety +/// Every string is NUL-terminated UTF-8; `theme` may be null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_section_set_theme( + section: &Section, + id: *const c_char, + scope: u8, + theme: *const c_char, +) -> bool { + use notebook::sidecar::themes::{Assignment, Themes}; + let set = (|| { + let scope = section + .scopes(&string(id))? + .into_iter() + .nth(usize::from(scope)) + .flatten() + .ok_or("No such scope")?; + section.save_themes(Themes { + assignments: vec![Assignment { + scope, + theme: optional(theme), + assigned: filetime(), + }], + ..Default::default() + }) + })(); + report(set).is_some() +} + /// Deletes page `id` to the notebook's recycle bin, or a conflict page for good; a section /// left without pages gets one titled `date` and `time`. Writes to the notebook, so call it /// off the main thread. diff --git a/crates/mobile/src/tests.rs b/crates/mobile/src/tests.rs index c1c4f60b0e29b04e67442bc69e4eb4d43cddc4f8..261fb38afe3b03b136c89f42d96df4e126093d15 100644 --- a/crates/mobile/src/tests.rs +++ b/crates/mobile/src/tests.rs @@ -1088,3 +1088,20 @@ fn closing_refuses_while_an_edit_cannot_publish_then_deletes_nothing_pending() { std::fs::remove_dir_all(&root).unwrap(); assert!(eventually(|| replicas(&directory) == 0)); } + +/// Print and Export as PDF make a PDF of the open page on the paper asked for. +#[test] +fn the_page_prints_as_a_pdf() { + let (_directory, section) = features(); + let title = section.shared.section.pages().unwrap()[0].1.clone(); + let pdf = canvas(§ion, &title) + .pdf(canvas::print::A4, "Features") + .unwrap(); + assert!(pdf.starts_with(b"%PDF")); + let text = String::from_utf8_lossy(&pdf); + assert!( + text.contains("/MediaBox [0 0 595.276 841.89]"), + "{}", + &text[..400] + ); +} diff --git a/crates/notebook/README.md b/crates/notebook/README.md index e77633ff9235501fbdfd52a9d823a27eee470750..5755beacc3e293d73af0f229b7e2df42e398063f 100644 --- a/crates/notebook/README.md +++ b/crates/notebook/README.md @@ -56,6 +56,9 @@ the files its folders list otherwise than when last opened (`discover::Cache`, k a replica named by the file's location and the section's document identity, so the same file reopens the same queue after a relaunch and a copy of it elsewhere has its own (`notebook::location`: `folder`, `local`, `smb`, and `moved` for a notebook the app moves). +`Notebook::section(path, notify)` opens a mounted notebook's section; a replica already +named by the identity its discovery read resumes without reading the file, which the +worker checks next, as on a share. A section is `Send + Sync`: `apply(author, edit)` returns at once and reports a refusal as `Event::Rejected`; `events()` drains remote changes (`Changed(spaces)`), publication outcomes, unreachable files and failures; `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 operations, which touch only a TOC entry OneNote gave the copy's name. Of several groups with one TOC identity, the one its parent's TOC names, else the newest, lists; the others list in `unavailable` as `Reason::Copy`. iOS's -`.Name.one.icloud` placeholders list there under their real names as `Reason::Evicted`. +`.Name.one.icloud` placeholders, and macOS's dataless files, list there under their real names +as `Reason::Evicted`, unless listed unchanged since discovery last read them. Retain the last accepted catalog if discovery fails; a connection failure does not mean files were deleted. @@ -307,16 +311,18 @@ directly from the core document model. ## Notebook structure `Notebook::create`, `create_section`, `create_group`, `rename`, `move_entry`, -`set_section_color`, `reorder` and `delete` change a notebook the way OneNote does: the +`set_section_color`, `set_color`, `reorder` and `delete` change a notebook the way OneNote does: the table of contents (`Open Notebook.onetoc2`, created when a folder has none) gains, renames, reorders or loses entries; a new notebook holds "New Section 1", and a new section an empty section's file plus the page its `PageCreation` makes, in OneNote's -new-section colour order; a section's colour lives in its own metadata; a deleted +new-section colour order; a section's colour lives in its own metadata, the notebook's +in its root table of contents (`corpus/section-color`); a deleted section moves into `OneNote_RecycleBin`, a group with its own TOC, and a deleted group's sections move there too before its folders go. `recycle_pages` keeps copies of pages a section is about to delete in `OneNote_RecycleBin/OneNote_DeletedPages.one`, with their identities, titles, dates and creation times, as OneNote does -(`corpus/notebook-management`). A bin or deleted-pages file its TOC does not list is +(`corpus/notebook-management`); `unrecycle_pages` takes them out again by identity, as +OneNote's Undo of a page delete does. A bin or deleted-pages file its TOC does not list is placed and listed again, a bin whose TOC OneNote named otherwise keeps it, and an unavailable bin refuses the delete (`corpus/recycle-bin-repair`). An entry a TOC still lists for a file gone from its folder gives way to the file an edit gives its name. @@ -368,14 +374,16 @@ notebook. `sidecar` keeps Snowbound-only data in the notebook's `.snowbound` folder, which OneNote 2010 skips because it has the Windows hidden attribute: `Storage::hide` sets it through `smb::Client::hide` (SET_INFO FileBasicInformation, always, since Samba reports a -dot name hidden without storing it) or macOS's `UF_HIDDEN`, which an smbfs mount passes -to the share; Linux keeps no such attribute. Discovery skips dot names, and a notebook +dot name hidden without storing it), `SetFileAttributesW` on Windows, or macOS's +`UF_HIDDEN`, which an smbfs mount passes to the share; Linux keeps no such attribute. Discovery skips dot names, and a notebook without the folder is whole. ```text .snowbound/ ├ tags.json [{ name, shape, art, mapped }]: a tag's name and symbol → its art -└ tags/ .png | .svg, written through a temporary name, never rewritten +├ tags/ .png | .svg, written through a temporary name, never rewritten +└ themes.json { themes: [{ id, name, styles, modified, deleted }], + assignments: [{ scope, theme, assigned }] }: style themes and who wears them ``` `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, over entries that name no picture, and `tag_art_file` returns a picture once its bytes match its name. +`sidecar::themes` keeps style themes: each gives OneNote 2010's eleven gallery styles +(`STYLES`: `h1`…`h6`, `PageTitle`, `cite`, `blockquote`, `code`, `p`) a font, size, +weight, slant, colour and spacing, and `Theme::sheet` turns them into the paragraph style +definitions a page stores. `built_in()` lists the shipped themes, which never change once +shipped. A scope is the notebook, a section by file identity or a page by its +notebook-management identity; `Themes::effective(section, page)` takes the page's, else the +section's, else the notebook's. `Notebook::themes` reads the file and `save_themes(change)` +merges a change in as `map_tag_art` does: themes by id and assignments by scope, the later +timestamp winning, then the greater entry. `onestore::op::restyle(page, sheet)` gives the +`Restyle` ops that put a page in a sheet. + ## SMB (feature `smb`) `notebook::smb` provides blocking SMB access for OneNote sections and diff --git a/crates/notebook/src/discover.rs b/crates/notebook/src/discover.rs index e0dcc97d0faf93d38f722c11ee8fe0229f4ac974..35ff81ab5f8ec29198aa2760f1a9c0861be577f9 100644 --- a/crates/notebook/src/discover.rs +++ b/crates/notebook/src/discover.rs @@ -93,6 +93,8 @@ pub struct Toc { pub file_id: [u8; 16], /// Stale or unavailable TOC references; these are not inferred active sections. pub unresolved: Vec, + /// The notebook's colour as a COLORREF, which only a notebook's own TOC holds. + pub color: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -108,8 +110,8 @@ pub enum EntryKind { File, Directory, Other, - /// A notebook file kept elsewhere and not yet on this device, as iOS lists one that - /// iCloud Drive evicted. + /// A notebook file kept elsewhere and not yet on this device, as iCloud Drive's evicted + /// files list: a `.Name.icloud` placeholder, or on macOS 14 and later a dataless file. Evicted, } @@ -164,7 +166,10 @@ enum Held { document: [u8; 16], }, Locked, - Toc(Vec), + Toc { + unresolved: Vec, + color: Option, + }, } impl Cache { @@ -380,9 +385,14 @@ fn scan( } else { continue; }; - // Without an evicted TOC the folder lists in path order; nothing writes a second one, - // as its placeholder blocks creating it. - if entry.kind == EntryKind::Evicted { + let reused = cached + .get(&child) + .filter(|known| known.listed == entry.listed) + .and_then(|known| Some((known.file_id()?, known))); + // An evicted file listed as it was last read lists as then. Without an evicted TOC the + // folder lists in path order; nothing writes a second one, as its placeholder, or the + // dataless file itself, blocks creating it. + if entry.kind == EntryKind::Evicted && reused.is_none() { if expected == FileType::Section { result.unavailable.push(Unavailable { path: child, @@ -393,15 +403,11 @@ fn scan( } continue; } - if entry.kind != EntryKind::File + if !matches!(entry.kind, EntryKind::File | EntryKind::Evicted) || (expected == FileType::TableOfContents && result.toc.is_some()) { return Err(Error::Entry { path: child }); } - let reused = cached - .get(&child) - .filter(|known| known.listed == entry.listed) - .and_then(|known| Some((known.file_id()?, known))); let (file_id, held) = match reused { Some((file_id, known)) => { found.read.insert(child.clone(), known.clone()); @@ -522,11 +528,12 @@ fn scan( state: SectionState::Locked, copy: false, }), - Ok(Held::Toc(unresolved)) => { + Ok(Held::Toc { unresolved, color }) => { result.toc = Some(Toc { filename: entry.name.clone(), file_id, unresolved, + color, }) } Err(error) if expected == FileType::Section => result.sections.push(Section { @@ -751,7 +758,7 @@ fn held(store: &Store, expected: FileType) -> Result { }); } index.validate_current()?; - let Some(Kind::Toc { entries, .. }) = root(1).map(|node| &node.kind) else { + let Some(Kind::Toc { entries, color, .. }) = root(1).map(|node| &node.kind) else { return Err(onestore::Error { offset: 0, message: "Missing notebook TOC root", @@ -784,7 +791,10 @@ fn held(store: &Store, expected: FileType) -> Result { order: *order, }); } - Ok(Held::Toc(unresolved)) + Ok(Held::Toc { + unresolved, + color: *color, + }) } /// Why a file or group that failed lists as unavailable; `None` fails the whole discovery, diff --git a/crates/notebook/src/discover/source.rs b/crates/notebook/src/discover/source.rs index 5ac630f7d2b43a0b68f954ea62de7de965d9ab06..ee287e3a00da7a8d7534dfbb4ac6e6bc68181ef7 100644 --- a/crates/notebook/src/discover/source.rs +++ b/crates/notebook/src/discover/source.rs @@ -12,6 +12,22 @@ fn evicted(name: &str) -> Option<&str> { (lower.ends_with(".one") || lower.ends_with(".onetoc2")).then_some(real) } +/// Whether the system keeps the file's contents elsewhere until it is read, as macOS 14 and +/// later list a file iCloud Drive evicted (`SF_DATALESS`). +fn dataless(metadata: &std::fs::Metadata) -> bool { + const SF_DATALESS: u32 = 0x4000_0000; + #[cfg(target_os = "macos")] + let flags = std::os::macos::fs::MetadataExt::st_flags(metadata); + #[cfg(target_os = "ios")] + let flags = std::os::ios::fs::MetadataExt::st_flags(metadata); + #[cfg(not(any(target_os = "macos", target_os = "ios")))] + let flags = { + let _ = metadata; + 0 + }; + flags & SF_DATALESS != 0 +} + /// Where iOS lists the file at `path` while iCloud Drive keeps it elsewhere. pub(crate) fn placeholder(path: &Path) -> Option { let name = path.file_name()?.to_str()?; @@ -62,6 +78,8 @@ impl Source for Local { name, kind: if kind.is_dir() { EntryKind::Directory + } else if kind.is_file() && dataless(&metadata) { + EntryKind::Evicted } else if kind.is_file() { EntryKind::File } else { diff --git a/crates/notebook/src/lib.rs b/crates/notebook/src/lib.rs index d7db2dc031a1592a5751fe4055be3543fac0bc4f..d2adae845b478671db2681b1729dcfab0d5b6c6f 100644 --- a/crates/notebook/src/lib.rs +++ b/crates/notebook/src/lib.rs @@ -313,10 +313,16 @@ impl Replica { /// The section image the queued edits leave, the unsealed ones sealed as one more /// revision whose identities differ per call: O(section). pub(crate) fn snapshot(&self) -> Result> { - self.ask(|reply| working::Request::Flush { reply })?; + self.written()?; working::image(&*self.lock()?) } + /// Waits until the edits applied before it are written to the queue, as reads answer + /// before an open burst of edits is. + pub fn written(&self) -> Result<()> { + self.ask(|reply| working::Request::Flush { reply }) + } + /// The section file's identity, which internal links name as `section-id`. pub fn identity(&self) -> Result<[u8; 16]> { let stamp = base::stamp(&*self.lock()?, base::Image::Base)? diff --git a/crates/notebook/src/merge.rs b/crates/notebook/src/merge.rs index 79eb9c50ac27709f2faff2ed7c4e619e78e63c7d..745fc682809edc89c7f40585d206e9f48633b063 100644 --- a/crates/notebook/src/merge.rs +++ b/crates/notebook/src/merge.rs @@ -153,7 +153,7 @@ fn named(op: &PageOp) -> Vec { | PageOp::Equation { text, .. } | PageOp::Split { text, .. } => vec![*text], PageOp::Date { fields, .. } => fields.iter().map(|(text, _)| *text).collect(), - PageOp::Color(_) | PageOp::RuleLines(_) => Vec::new(), + PageOp::Color(_) | PageOp::RuleLines(_) | PageOp::Restyle { .. } => Vec::new(), PageOp::Join { left, right } => vec![*left, *right], PageOp::Insert { container, .. } => vec![*container], PageOp::Move { object, .. } @@ -164,6 +164,7 @@ fn named(op: &PageOp) -> Vec { PageOp::Level { paragraph, .. } | PageOp::Paragraph { paragraph, .. } | PageOp::Style { paragraph, .. } + | PageOp::Unstyle { paragraph } | PageOp::Media { paragraph, .. } | PageOp::List { paragraph, .. } => vec![*paragraph], PageOp::Tags { target, .. } => vec![*target], @@ -770,11 +771,19 @@ impl Diff { } PageOp::Paragraph { paragraph, .. } | PageOp::Style { paragraph, .. } + | PageOp::Unstyle { paragraph } | PageOp::Media { paragraph, .. } | PageOp::List { paragraph, .. } => { require(self.kept(*paragraph) && self.new_has(*paragraph))? } PageOp::Tags { target, .. } => require(self.same_tags(*target))?, + // A theme reaches the style's paragraphs as the remote left them; a style the + // remote no longer uses is restyled when the page next opens. + PageOp::Restyle { style, .. } => { + if self.old.styles.contains(style) && !self.new.styles.contains(style) { + return Ok(None); + } + } PageOp::Picture { picture: object, .. } @@ -1079,6 +1088,8 @@ impl Node { struct Index { nodes: BTreeMap, page: Vec, + /// The paragraph styles the page's paragraphs use. + styles: BTreeSet, } const PAGE: ExGuid = ExGuid { @@ -1088,7 +1099,17 @@ const PAGE: ExGuid = ExGuid { impl Index { fn of(page: &Page) -> Self { - let mut index = Self::default(); + let mut index = Self { + styles: page + .definitions + .iter() + .filter(|(_, definition)| { + matches!(definition.kind, onestore::document::Kind::Style { .. }) + }) + .map(|(id, _)| *id) + .collect(), + ..Self::default() + }; for object in &page.objects { index.page.push(object.id()); match object { diff --git a/crates/notebook/src/queue.rs b/crates/notebook/src/queue.rs index f151e6ba7f0d9090c404721307051eed7e63946f..6a3772541091f64fde08a160170b3f0fb844103c 100644 --- a/crates/notebook/src/queue.rs +++ b/crates/notebook/src/queue.rs @@ -282,6 +282,9 @@ mod tests { alt: None, background: false, printout: None, + tags: Vec::new(), + link: None, + text: None, }) }; let space = ExGuid { diff --git a/crates/notebook/src/session.rs b/crates/notebook/src/session.rs index 309ddf3bafc58fc0833fed69a2392a31ac04eb1e..d3ab0efd5cfd284200113d80f4994c2f555bee61 100644 --- a/crates/notebook/src/session.rs +++ b/crates/notebook/src/session.rs @@ -332,6 +332,8 @@ const LIMITS: discover::Limits = discover::Limits { const TOC: &str = "Open Notebook.onetoc2"; const RECYCLE_BIN: &str = "OneNote_RecycleBin"; +/// How long OneNote 2010 keeps what its recycle bin holds, its `DaysToKeepRecycledItems`. +pub const RECYCLE_DAYS: u32 = 60; /// A notebook's files and the cache directory holding its section replicas. pub struct Notebook { @@ -488,6 +490,20 @@ impl Notebook { crate::sidecar::map(&*self.storage, name, shape, bytes, extension) } + /// The notebook's style themes and which theme each scope takes (`sidecar::themes`). + pub fn themes(&self) -> Result { + crate::sidecar::themes::read(&*self.storage) + } + + /// Merges `change` into the notebook's themes, making its hidden `.snowbound` folder + /// where it has none; returns the themes as they then stand. + pub fn save_themes( + &self, + change: crate::sidecar::themes::Themes, + ) -> Result { + crate::sidecar::themes::write(&*self.storage, change) + } + /// Rereads the notebook and reports what changed since the last catalog, keyed by /// file identity so a renamed or moved section stays the same section. A failed read /// keeps the previous catalog: an unreachable notebook is not an empty one. @@ -742,6 +758,13 @@ impl Notebook { Ok(()) } + /// Sets the notebook's colour (COLORREF) in its root table of contents, as OneNote 2010's + /// Notebook Properties does (`corpus/section-color`). + pub fn set_color(&mut self, color: u32) -> Result<()> { + self.edit_toc("", &[onestore::TocEdit::Color(color)])?; + self.refresh().map(drop) + } + /// Orders a folder's sections and groups; entries left out follow in their current order. pub fn reorder(&mut self, folder: &str, paths: &[&str]) -> Result<()> { let mut identities = Vec::new(); @@ -932,6 +955,102 @@ impl Notebook { self.refresh().map(drop) } + /// Takes the pages `identities` names out of the recycle bin's Deleted Pages in one + /// revision, as OneNote 2010 does when Undo brings deleted pages back. Pages not there + /// are passed over. + pub fn unrecycle_pages(&mut self, identities: &[[u8; 16]]) -> Result<()> { + let path = catalog_path(RECYCLE_BIN, "OneNote_DeletedPages.one"); + if !self.storage.exists(&path) { + return Ok(()); + } + let bytes = self.storage.read(&path)?; + let binned: Vec = stored_pages(&bytes)? + .into_iter() + .filter(|stored| { + stored + .page + .identity + .is_some_and(|id| identities.contains(&id)) + }) + .map(|stored| stored.space) + .collect(); + if binned.is_empty() { + return Ok(()); + } + let arena = onestore::Arena::default(); + let mut section = onestore::Section::open(&arena, bytes)?; + section.apply( + "", + &Edit { + at: crate::now(), + ops: vec![Op::Section(SectionOp::Delete(binned))], + }, + )?; + if let Some(transaction) = section.seal()? { + self.storage.commit(&path, &transaction)?; + } + self.refresh().map(drop) + } + + /// Deletes for good what OneNote 2010 prunes from the recycle bin at `now`, Time32 seconds + /// since 1980: each page of Deleted Pages last changed over `RECYCLE_DAYS` before, in one + /// revision, and each binned section none of whose pages changed since + /// (`corpus/recycle-purge`). OneNote judges by the pages' own last change, not when they + /// were deleted, and leaves a pruned section's entry in the bin's TOC, as this does. + /// Returns how many pages and sections went. + pub fn purge_recycle_bin(&mut self, now: u32) -> Result { + const DELETED: &str = "OneNote_DeletedPages.one"; + let expired = |modified: Option| { + modified.is_some_and(|modified| now.saturating_sub(modified) > RECYCLE_DAYS * 86_400) + }; + let Ok(bin) = self.folder(RECYCLE_BIN) else { + return Ok(0); + }; + let sections: Vec = bin + .sections + .iter() + .map(|section| section.path.clone()) + .collect(); + let mut purged = 0; + for path in sections { + let bytes = self.storage.read(&path)?; + // A protected section, or one the model cannot read, stays. + let Ok(pages) = stored_pages(&bytes) else { + continue; + }; + if split(&path).1.eq_ignore_ascii_case(DELETED) { + let old: Vec = pages + .iter() + .filter(|page| expired(page.modified)) + .map(|page| page.space) + .collect(); + if old.is_empty() { + continue; + } + let arena = onestore::Arena::default(); + let mut section = onestore::Section::open(&arena, bytes)?; + section.apply( + "", + &Edit { + at: crate::now(), + ops: vec![Op::Section(SectionOp::Delete(old.clone()))], + }, + )?; + if let Some(transaction) = section.seal()? { + self.storage.commit(&path, &transaction)?; + } + purged += old.len(); + } else if !pages.is_empty() && pages.iter().all(|page| expired(page.modified)) { + self.storage.delete(&path)?; + purged += 1; + } + } + if purged > 0 { + self.refresh()?; + } + Ok(purged) + } + /// Moves a section or section group into the folder at catalog path `folder`, last, as /// OneNote moves one dragged onto a group: the file or folder moves and each TOC's /// entry follows it. Returns its new catalog path. @@ -1222,6 +1341,16 @@ impl Notebook { if !file.starts_with(root) { return Err(io::Error::from(io::ErrorKind::PermissionDenied).into()); } + // A replica the catalog's identity names resumes without reading the file, which its + // worker checks next, as a share's does. + if let discover::SectionState::Readable { document, .. } = §ion.state { + let cache = replica_file(&replicas, document); + if cache.exists() { + let (replica, remote, mut connect) = + (Replica::open(&cache)?, file.clone(), connect); + return Section::start(file, replica, move || connect(&remote), notify); + } + } let replica = |identity: &[u8; 16], _: &[u8]| { std::fs::create_dir_all(&replicas)?; Ok(replica_file(&replicas, identity)) @@ -1722,6 +1851,11 @@ impl Section { self.replica.pending() } + /// See [`Replica::written`]. + pub fn written(&self) -> Result<()> { + self.replica.written() + } + /// The conflict pages of each page that has them, as the local edits leave them /// (`onestore::Section::conflicts`); `page` reads one, `delete_pages` removes it. pub fn conflicts(&self) -> Result)>> { diff --git a/crates/notebook/src/sidecar.rs b/crates/notebook/src/sidecar.rs index ee360360bdace2a6233d49748993394e9d80d5b1..22aa7df13fe6b4147bde092b5bb34c2d0e2afa69 100644 --- a/crates/notebook/src/sidecar.rs +++ b/crates/notebook/src/sidecar.rs @@ -162,5 +162,7 @@ fn temporary(path: &str) -> String { format!("{path}.{unique:016x}.tmp") } +pub mod themes; + #[cfg(test)] mod tests; diff --git a/crates/notebook/src/sidecar/tests.rs b/crates/notebook/src/sidecar/tests.rs index 39b01a69d3046608f361e7acdc497b03a5dcad5d..ce34bdd42eec244d26d0c14b1961a6e20e6598be 100644 --- a/crates/notebook/src/sidecar/tests.rs +++ b/crates/notebook/src/sidecar/tests.rs @@ -265,8 +265,8 @@ fn unreadable_mappings_and_pictures_are_passed_over() { assert!(map(&folder, "Tag", 13, b"x", "gif").is_err()); } -/// On this computer the folder takes the attribute macOS keeps, and the notebook's catalog -/// never lists it. +/// On this computer the folder takes the hidden attribute Windows or macOS keeps, and the +/// notebook's catalog never lists it. #[test] fn a_notebook_folder_keeps_the_art_hidden() { let temporary = tempfile::tempdir().unwrap(); @@ -288,7 +288,48 @@ fn a_notebook_folder_keeps_the_art_hidden() { let flags = FileFlag::from_bits_retain(stat(&folder).unwrap().st_flags); assert!(flags.contains(FileFlag::UF_HIDDEN)); } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + // FILE_ATTRIBUTE_HIDDEN. + assert_ne!(folder.metadata().unwrap().file_attributes() & 2, 0); + } notebook.refresh().unwrap(); assert!(notebook.catalog().groups.is_empty()); assert_eq!(notebook.catalog().sections.len(), 1); } + +/// Themes go in the hidden folder beside the tag art, and a writer another overtook merges +/// again until its change holds; entries it can't read are passed over, built-ins refused. +#[test] +fn themes_merge_into_the_hidden_folder() { + use themes::{Assignment, Scope, Themes}; + let folder = Folder::default(); + let assign = |scope, theme: &str, assigned| Themes { + assignments: vec![Assignment { + scope, + theme: Some(theme.into()), + assigned, + }], + ..Default::default() + }; + let theirs = assign(Scope::Notebook, "modern", 1); + *folder.race.lock().unwrap() = Some(serde_json::to_vec(&theirs).unwrap()); + let kept = themes::write(&folder, assign(Scope::section([7; 16]), "editorial", 2)).unwrap(); + assert!(folder.hidden.lock().unwrap().contains(".snowbound")); + assert_eq!(kept.assignments.len(), 2); + assert_eq!(kept, themes::read(&folder).unwrap()); + assert_eq!(kept.effective(Some([7; 16]), None).unwrap().id, "editorial"); + folder.files.lock().unwrap().insert( + ".snowbound/themes.json".into(), + br#"{"themes": [{"id": 3}], "assignments": [{"scope": "notebook", "theme": "manuscript", "assigned": 4}, "junk"]}"#.to_vec(), + ); + let read = themes::read(&folder).unwrap(); + assert!(read.themes.is_empty()); + assert_eq!(read.effective(None, None).unwrap().id, "manuscript"); + let built_in = Themes { + themes: vec![themes::built_in().remove(0)], + ..Default::default() + }; + assert!(themes::write(&folder, built_in).is_err()); +} diff --git a/crates/notebook/src/sidecar/themes.rs b/crates/notebook/src/sidecar/themes.rs new file mode 100644 index 0000000000000000000000000000000000000000..24a55ae49fc673d7b1cefa9bf523893ca4659fe0 --- /dev/null +++ b/crates/notebook/src/sidecar/themes.rs @@ -0,0 +1,560 @@ +//! Style themes: `themes.json` in `.snowbound` keeps a notebook's own themes and which +//! theme its notebook, sections and pages take. A theme gives OneNote 2010's eleven gallery +//! styles their formatting; a page wears it as its paragraph style objects, so OneNote draws +//! the look and keeps the names (`resources/styles.md`). Writers merge as `tags.json`'s do: +//! themes by id and assignments by scope, the later timestamp winning, then the greater +//! entry, so writers agree whatever order they read in. + +use crate::{Result, session::Storage}; +use onestore::{ + document::{Format, Kind}, + page::Definition, +}; +use serde::{Deserialize, Serialize}; +use std::{collections::BTreeMap, io}; + +const FILE: &str = ".snowbound/themes.json"; + +/// OneNote 2010's gallery, in its order: each style's stored name and the name it shows. +pub const STYLES: [(&str, &str); 11] = [ + ("h1", "Heading 1"), + ("h2", "Heading 2"), + ("h3", "Heading 3"), + ("h4", "Heading 4"), + ("h5", "Heading 5"), + ("h6", "Heading 6"), + ("PageTitle", "Page Title"), + ("cite", "Citation"), + ("blockquote", "Quote"), + ("code", "Code"), + ("p", "Normal"), +]; + +/// A style's look in a theme. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ThemeStyle { + pub font: String, + /// Points, in half points. + pub size: f32, + pub bold: bool, + pub italic: bool, + /// `#rrggbb`, or none for automatic. + pub color: Option, + /// Space above and below the paragraph, in points. + pub before: f32, + pub after: f32, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Theme { + pub id: String, + pub name: String, + /// By stored style name; a theme names all eleven. + pub styles: BTreeMap, + /// When it last changed, as a FILETIME; built-in themes have none. + #[serde(default)] + pub modified: u64, + #[serde(default)] + pub deleted: bool, +} + +/// What a theme is assigned to. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Scope { + Notebook, + /// A section, by its file identity, which survives renames and moves. + Section(String), + /// A page, by its notebook-management identity. + Page(String), +} + +impl Scope { + pub fn section(identity: [u8; 16]) -> Self { + Self::Section(hex(identity)) + } + + pub fn page(identity: [u8; 16]) -> Self { + Self::Page(hex(identity)) + } +} + +fn hex(identity: [u8; 16]) -> String { + identity.iter().map(|byte| format!("{byte:02x}")).collect() +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Assignment { + pub scope: Scope, + /// None takes the scope's override away. + pub theme: Option, + pub assigned: u64, +} + +/// A notebook's themes file. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Themes { + #[serde(default)] + pub themes: Vec, + #[serde(default)] + pub assignments: Vec, +} + +impl Themes { + /// The built-in themes, then the notebook's own that aren't deleted. + pub fn all(&self) -> Vec { + let mut all = built_in(); + all.extend(self.themes.iter().filter(|theme| !theme.deleted).cloned()); + all + } + + pub fn theme(&self, id: &str) -> Option { + self.all().into_iter().find(|theme| theme.id == id) + } + + /// The theme `scope` names itself, if one it can find. + pub fn assigned(&self, scope: &Scope) -> Option { + let assignment = self.assignments.iter().find(|kept| kept.scope == *scope)?; + self.theme(assignment.theme.as_deref()?) + } + + /// The theme a page wears: its own, else its section's, else the notebook's. + pub fn effective(&self, section: Option<[u8; 16]>, page: Option<[u8; 16]>) -> Option { + page.map(Scope::page) + .into_iter() + .chain(section.map(Scope::section)) + .chain([Scope::Notebook]) + .find_map(|scope| self.assigned(&scope)) + } +} + +/// `into` with `other`'s entries merged by the rule the module describes. +pub fn merge(into: &mut Themes, other: Themes) { + fn later(new: &T, new_at: u64, kept: &T, kept_at: u64) -> bool { + let json = |value: &T| serde_json::to_string(value).unwrap_or_default(); + (new_at, json(new)) > (kept_at, json(kept)) + } + for theme in other.themes { + match into.themes.iter_mut().find(|kept| kept.id == theme.id) { + Some(kept) => { + if later(&theme, theme.modified, kept, kept.modified) { + *kept = theme; + } + } + None => into.themes.push(theme), + } + } + for assignment in other.assignments { + match into + .assignments + .iter_mut() + .find(|kept| kept.scope == assignment.scope) + { + Some(kept) => { + if later(&assignment, assignment.assigned, kept, kept.assigned) { + *kept = assignment; + } + } + None => into.assignments.push(assignment), + } + } +} + +/// The themes file: empty without one, and without entries it holds unreadably. +pub(crate) fn read(storage: &dyn Storage) -> Result { + let bytes = match storage.read_file(FILE, super::LIMIT) { + Err(crate::Error::Io(error)) if error.kind() == io::ErrorKind::NotFound => { + return Ok(Themes::default()); + } + bytes => bytes?, + }; + let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or_default(); + let entries = |key: &str| -> Vec { + value + .get(key) + .and_then(|list| list.as_array().cloned()) + .unwrap_or_default() + }; + let mut themes = Themes::default(); + merge( + &mut themes, + Themes { + themes: entries("themes") + .into_iter() + .filter_map(|entry| serde_json::from_value(entry).ok()) + .filter(|theme: &Theme| !is_built_in(&theme.id)) + .collect(), + assignments: entries("assignments") + .into_iter() + .filter_map(|entry| serde_json::from_value(entry).ok()) + .collect(), + }, + ); + Ok(themes) +} + +/// Merges `change` into the notebook's themes file; returns the file as it then stands. +pub(crate) fn write(storage: &dyn Storage, change: Themes) -> Result { + if change.themes.iter().any(|theme| is_built_in(&theme.id)) { + return Err(io::Error::from(io::ErrorKind::InvalidInput).into()); + } + match storage.create_directory(super::FOLDER) { + Err(crate::Error::Io(error)) if error.kind() == io::ErrorKind::AlreadyExists => {} + created => created?, + } + storage.hide(super::FOLDER)?; + for _ in 0..3 { + let mut merged = read(storage)?; + merge(&mut merged, change.clone()); + let written = super::temporary(FILE); + let json = serde_json::to_vec_pretty(&merged).map_err(io::Error::from)?; + storage.create(&written, &json)?; + storage.replace(&written, FILE)?; + let kept = read(storage)?; + let mut held = kept.clone(); + merge(&mut held, change.clone()); + if held == kept { + return Ok(kept); + } + } + Err(io::Error::from(io::ErrorKind::ResourceBusy).into()) +} + +fn is_built_in(id: &str) -> bool { + built_in().iter().any(|theme| theme.id == id) +} + +/// Stored style `name`'s paragraph style as `style` gives it: every character flag set, as +/// OneNote 2010 writes its own, headings followed by Normal. +pub fn definition(name: &str, style: &ThemeStyle) -> Definition { + let color = style + .color + .as_deref() + .and_then(|hex| u32::from_str_radix(hex.strip_prefix('#')?, 16).ok()) + .map_or(0xff00_0000, |rgb| { + (rgb >> 16) | (rgb & 0xff00) | ((rgb & 0xff) << 16) + }); + // Paragraph spacing is stored in half inches. + let stored = |points: f32| points / 36.0 * 36.0; + Definition { + kind: Kind::Style { + name: Some(name.into()), + next: matches!(name, "h1" | "h2" | "h3" | "h4" | "h5" | "h6").then(|| "p".into()), + }, + format: Format { + bold: Some(style.bold), + italic: Some(style.italic), + underline: Some(false), + strike: Some(false), + superscript: Some(false), + subscript: Some(false), + font: Some(style.font.clone()), + font_size: Some((style.size * 2.0).round() / 2.0), + color: Some(color), + highlight: Some(0xff00_0000), + space_before: Some(stored(style.before)), + space_after: Some(stored(style.after)), + line_spacing: Some(0.0), + ..Format::default() + }, + } +} + +impl Theme { + /// The paragraph style definitions the theme gives, by stored name. + pub fn sheet(&self) -> BTreeMap { + self.styles + .iter() + .map(|(name, style)| (name.clone(), definition(name, style))) + .collect() + } +} + +/// A style in `font` at `size`, `bold` and `italic`, in `color`, with spacing. +fn style( + font: &str, + size: f32, + [bold, italic]: [bool; 2], + color: Option<&str>, + [before, after]: [f32; 2], +) -> ThemeStyle { + ThemeStyle { + font: font.into(), + size, + bold, + italic, + color: color.map(Into::into), + before, + after, + } +} + +const PLAIN: [bool; 2] = [false, false]; +const BOLD: [bool; 2] = [true, false]; +const ITALIC: [bool; 2] = [false, true]; +const BOTH: [bool; 2] = [true, true]; +const NONE: [f32; 2] = [0.0; 2]; + +/// The themes Snowbound ships. Once shipped a built-in never changes: two versions +/// disagreeing would restyle a page back and forth as each opens it. A changed look ships +/// under a new id. +pub fn built_in() -> Vec { + let theme = |id: &str, name: &str, styles: [ThemeStyle; 11]| Theme { + id: id.into(), + name: name.into(), + styles: STYLES + .iter() + .zip(styles) + .map(|((stored, _), style)| ((*stored).into(), style)) + .collect(), + modified: 0, + deleted: false, + }; + let blue = Some("#366092"); + vec![ + // OneNote 2010's own (lab, 2026-09-30). + theme( + "onenote", + "OneNote", + [ + style("Calibri", 16.0, BOLD, Some("#17365D"), NONE), + style("Calibri", 13.0, BOLD, blue, NONE), + style("Calibri", 11.0, BOLD, blue, NONE), + style("Calibri", 11.0, BOTH, blue, NONE), + style("Calibri", 11.0, PLAIN, blue, NONE), + style("Calibri", 11.0, ITALIC, blue, NONE), + style("Calibri", 17.0, PLAIN, None, NONE), + style("Calibri", 9.0, PLAIN, Some("#595959"), NONE), + style("Calibri", 11.0, ITALIC, Some("#595959"), NONE), + style("Courier New", 11.0, PLAIN, None, NONE), + style("Calibri", 11.0, PLAIN, None, NONE), + ], + ), + theme( + "manuscript", + "Manuscript", + [ + style("Georgia", 20.0, PLAIN, Some("#3B2F2A"), [12.0, 4.0]), + style("Georgia", 16.0, BOLD, Some("#3B2F2A"), [10.0, 2.0]), + style("Georgia", 13.0, BOLD, Some("#3B2F2A"), [8.0, 2.0]), + style("Georgia", 12.0, BOTH, Some("#3B2F2A"), [6.0, 0.0]), + style("Georgia", 12.0, PLAIN, Some("#3B2F2A"), [6.0, 0.0]), + style("Georgia", 12.0, ITALIC, Some("#3B2F2A"), [6.0, 0.0]), + style("Georgia", 26.0, PLAIN, Some("#1F1A17"), NONE), + style("Georgia", 9.0, PLAIN, Some("#7A6E66"), NONE), + style("Georgia", 12.0, ITALIC, Some("#6B5E55"), [0.0, 4.0]), + style("Courier New", 10.5, PLAIN, Some("#2B2B2B"), NONE), + style("Georgia", 12.0, PLAIN, Some("#2B2B2B"), [0.0, 4.0]), + ], + ), + theme( + "editorial", + "Editorial", + [ + style("Georgia", 18.0, BOLD, Some("#9A3B1F"), [12.0, 2.0]), + style("Georgia", 14.0, BOLD, Some("#202020"), [10.0, 2.0]), + style("Georgia", 12.0, BOLD, Some("#202020"), [8.0, 0.0]), + style("Georgia", 11.0, BOTH, Some("#202020"), [6.0, 0.0]), + style("Georgia", 11.0, PLAIN, Some("#9A3B1F"), [6.0, 0.0]), + style("Georgia", 11.0, ITALIC, Some("#9A3B1F"), [6.0, 0.0]), + style("Georgia", 24.0, PLAIN, Some("#202020"), NONE), + style("Calibri", 9.0, PLAIN, Some("#707070"), NONE), + style("Georgia", 12.0, ITALIC, Some("#555555"), [0.0, 3.0]), + style("Courier New", 10.0, PLAIN, Some("#262626"), NONE), + style("Calibri", 11.5, PLAIN, Some("#262626"), [0.0, 3.0]), + ], + ), + theme( + "modern", + "Modern", + [ + style("Arial", 16.0, BOLD, Some("#0E6E6E"), [10.0, 2.0]), + style("Arial", 13.0, BOLD, Some("#1B2631"), [8.0, 2.0]), + style("Arial", 11.0, BOLD, Some("#1B2631"), [6.0, 0.0]), + style("Arial", 10.5, BOTH, Some("#1B2631"), [6.0, 0.0]), + style("Arial", 10.5, BOLD, Some("#0E6E6E"), [6.0, 0.0]), + style("Arial", 10.5, ITALIC, Some("#0E6E6E"), [6.0, 0.0]), + style("Arial", 22.0, BOLD, Some("#1B2631"), NONE), + style("Arial", 8.0, PLAIN, Some("#7B868C"), NONE), + style("Arial", 10.5, ITALIC, Some("#5F6B73"), [0.0, 3.0]), + style("Courier New", 10.0, PLAIN, Some("#1B2631"), NONE), + style("Arial", 10.5, PLAIN, Some("#2E2E2E"), [0.0, 3.0]), + ], + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + /// OneNote 2010's theme is what OneNote writes, as Snowbound reads it back, so a page + /// OneNote styled has nothing to restyle under it. + #[test] + fn the_onenote_theme_is_what_onenote_writes() { + let source = include_bytes!("../../../../corpus/styles/onenote/Styles.one"); + let store = onestore::Store::parse(source).unwrap(); + let index = onestore::RevisionIndex::parse(&store).unwrap(); + let document = onestore::document::Document::parse(&index).unwrap(); + let (space, _) = document.pages().unwrap()[0]; + let page = onestore::page::Page::from_space(&document, space).unwrap(); + let sheet = built_in()[0].sheet(); + for (name, definition) in &sheet { + assert!( + page.definitions.values().any(|stored| stored == definition), + "{name}" + ); + } + // Only the heading pasted in from a page styled through COM differs. + let ops = onestore::op::restyle(&page, &sheet).unwrap(); + assert_eq!(ops.len(), 1); + let onestore::op::PageOp::Restyle { style, into, .. } = &ops[0] else { + unreachable!() + }; + assert_eq!( + page.definitions[style].format.font.as_deref(), + Some("Georgia") + ); + assert_eq!(page.definitions[into], sheet["h1"]); + } + + /// OneNote 2010 on a page Snowbound dressed in Manuscript: Ctrl+Alt+2 and Enter add its + /// own `h2` and `p` beside the theme's (`corpus/styles/onenote-edit`). Snowbound reads + /// them under those names, and restyling heals both: one edit, every style the theme's. + #[test] + fn styles_onenote_adds_to_a_themed_page_are_healed() { + let source = include_bytes!("../../../../corpus/styles/onenote-edit/Manuscript.one"); + let arena = onestore::Arena::default(); + let mut section = onestore::Section::open(&arena, source.to_vec()).unwrap(); + let (space, _, _) = section.pages().unwrap()[0]; + let page = section.page(space).unwrap(); + let sheet = built_in() + .into_iter() + .find(|theme| theme.id == "manuscript") + .unwrap() + .sheet(); + let calibri: Vec<&str> = page + .definitions + .values() + .filter_map(|stored| match &stored.kind { + Kind::Style { + name: Some(name), .. + } if stored.format.font.as_deref() == Some("Calibri") => Some(name.as_str()), + _ => None, + }) + .collect(); + assert_eq!(calibri, ["h2", "p"]); + let ops = onestore::op::restyle(&page, &sheet).unwrap(); + assert_eq!(ops.len(), 2); + let edit = onestore::op::Edit { + at: 134_000_000_000_000_000, + ops: ops + .into_iter() + .map(|op| onestore::op::Op::Page { space, op }) + .collect(), + }; + section.apply("Author", &edit).unwrap(); + section.seal().unwrap(); + let healed = onestore::Section::open(&arena, section.image()) + .unwrap() + .page(space) + .unwrap(); + for stored in healed.definitions.values() { + if let Kind::Style { + name: Some(name), .. + } = &stored.kind + { + assert_eq!(Some(stored), sheet.get(name), "{name}"); + } + } + assert!(onestore::op::restyle(&healed, &sheet).unwrap().is_empty()); + } + + #[test] + fn every_built_in_theme_names_every_gallery_style() { + for theme in built_in() { + let sheet = theme.sheet(); + for (name, _) in STYLES { + assert!(sheet.contains_key(name), "{} {name}", theme.id); + } + } + } + + /// A page takes its own theme, else its section's, else the notebook's; a scope naming a + /// deleted theme, or none, falls through. + #[test] + fn a_page_takes_the_nearest_theme() { + let (section, page) = ([1; 16], [2; 16]); + let assign = |scope, theme: Option<&str>, assigned| Assignment { + scope, + theme: theme.map(Into::into), + assigned, + }; + let mut custom = built_in().remove(1); + custom.id = "essay".into(); + custom.deleted = true; + let mut themes = Themes { + themes: vec![custom], + assignments: vec![assign(Scope::Notebook, Some("modern"), 1)], + }; + let effective = |themes: &Themes| themes.effective(Some(section), Some(page)).unwrap().id; + assert_eq!(effective(&themes), "modern"); + merge( + &mut themes, + Themes { + assignments: vec![ + assign(Scope::section(section), Some("editorial"), 2), + assign(Scope::page(page), Some("essay"), 2), + ], + ..Default::default() + }, + ); + assert_eq!(effective(&themes), "editorial"); + themes.themes[0].deleted = false; + assert_eq!(effective(&themes), "essay"); + merge( + &mut themes, + Themes { + assignments: vec![assign(Scope::page(page), None, 3)], + ..Default::default() + }, + ); + assert_eq!(effective(&themes), "editorial"); + assert_eq!(themes.effective(None, None).unwrap().id, "modern"); + } + + /// Writers merging in either order agree: the later entry wins, then the greater. + #[test] + fn merges_agree_in_either_order() { + let theme = |name: &str, modified| { + let mut theme = built_in().remove(2); + theme.id = "mine".into(); + theme.name = name.into(); + theme.modified = modified; + theme + }; + let a = Themes { + themes: vec![theme("A", 5)], + assignments: vec![Assignment { + scope: Scope::Notebook, + theme: Some("mine".into()), + assigned: 7, + }], + }; + let b = Themes { + themes: vec![theme("B", 5)], + assignments: vec![Assignment { + scope: Scope::Notebook, + theme: None, + assigned: 9, + }], + }; + let mut ab = a.clone(); + merge(&mut ab, b.clone()); + let mut ba = b; + merge(&mut ba, a); + assert_eq!(ab, ba); + assert_eq!(ab.themes[0].name, "B"); + assert_eq!(ab.assignments[0].theme, None); + } +} diff --git a/crates/notebook/tests/discovery.rs b/crates/notebook/tests/discovery.rs index 1b28f8cd7b0944da08831a18f6f594bbe5a83a42..5d9689260efbb2f07c9c59bd4581399c2120baf3 100644 --- a/crates/notebook/tests/discovery.rs +++ b/crates/notebook/tests/discovery.rs @@ -197,6 +197,46 @@ fn evicted_files_list_under_their_names_until_downloaded() { assert!(!root.join("Open Notebook.onetoc2").exists()); } +/// A source whose files list as macOS lists those iCloud Drive evicted: dataless, under their +/// names and listings, and never read. +struct Dataless(Local); + +impl Source for Dataless { + fn entries(&mut self, path: &str, limit: usize) -> io::Result> { + let mut entries = self.0.entries(path, limit)?; + for entry in &mut entries { + entry.kind = notebook::discover::EntryKind::Evicted; + } + Ok(entries) + } + + fn read(&mut self, path: &str, _: usize) -> io::Result> { + panic!("read {path}, which is not on this device"); + } +} + +/// A dataless file lists as its last read found it, where it lists unchanged since, and as not +/// downloaded otherwise. +#[test] +fn dataless_files_list_as_last_read_or_wait_for_their_download() { + let root = tempfile::tempdir().unwrap(); + fixture(root.path()); + let mut cache = Cache::default(); + cache + .discover(&mut Local::open(root.path()).unwrap(), limits()) + .unwrap(); + let catalog = cache + .discover(&mut Dataless(Local::open(root.path()).unwrap()), limits()) + .unwrap(); + assert_eq!(catalog.sections[0].path, "one.one"); + assert!(catalog.toc.is_some() && catalog.unavailable.is_empty()); + + let catalog = discover(&mut Dataless(Local::open(root.path()).unwrap()), limits()).unwrap(); + assert!(catalog.sections.is_empty() && catalog.toc.is_none()); + assert_eq!(catalog.unavailable[0].path, "one.one"); + assert_eq!(catalog.unavailable[0].reason, Reason::Evicted); +} + #[test] fn locked_and_unreadable_sections_retain_their_storage_identity() { let root = tempfile::tempdir().unwrap(); diff --git a/crates/notebook/tests/structure.rs b/crates/notebook/tests/structure.rs index 8ccfc51d95f6ac3df93cec7afe1e3e61e4f5966f..70ba792ec49782f4821954bfa80d36515126b948 100644 --- a/crates/notebook/tests/structure.rs +++ b/crates/notebook/tests/structure.rs @@ -336,3 +336,55 @@ fn a_page_delete_lists_the_recycle_bin_it_finds_or_makes_and_leaves_an_unreadabl assert_eq!(names.len(), 2, "{names:?}"); } } + +/// What the recycle bin holds goes for good once its pages last changed over 60 days +/// before, as OneNote 2010 prunes it; a pruned section's TOC entry stays, as OneNote leaves it +/// (`corpus/recycle-purge`). `NOTEBOOK_PURGE_EXPORT` names a new directory receiving the +/// purged notebook for a cold reopen. +#[test] +fn the_recycle_bin_forgets_pages_and_sections_unchanged_for_sixty_days() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("notebook"); + let bin = root.join("OneNote_RecycleBin"); + let page = onestore::PageCreation::new(None, Some("Kept"), "Author").unwrap(); + let mut notebook = + Notebook::create(&root, temporary.path().join("cache"), 0x00d7ff, &page).unwrap(); + let old = onestore::PageCreation::new(None, Some("Old page"), "Author").unwrap(); + notebook.create_section("", "Old", &old).unwrap(); + let image = notebook.read_section("New Section 1.one").unwrap(); + let pages = notebook::session::stored_pages(&image).unwrap(); + notebook + .recycle_pages(&[pages[0].page.clone()], "Author") + .unwrap(); + notebook.delete("Old.one").unwrap(); + let toc = bin.join("Open Notebook.onetoc2"); + let before = listed(&toc); + assert!(before.iter().any(|(name, _)| name == "Old.one")); + let deleted = bin.join("OneNote_DeletedPages.one"); + let recycled = |notebook: &Notebook| { + let image = notebook + .read_section("OneNote_RecycleBin/OneNote_DeletedPages.one") + .unwrap(); + notebook::session::stored_pages(&image).unwrap().len() + }; + assert_eq!(recycled(¬ebook), 1); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as u32 + - 315_532_800; + let day = 86_400; + assert_eq!(notebook.purge_recycle_bin(now + 59 * day).unwrap(), 0); + assert!(bin.join("Old.one").exists()); + let length = std::fs::metadata(&deleted).unwrap().len(); + assert_eq!(notebook.purge_recycle_bin(now + 61 * day).unwrap(), 2); + assert!(!bin.join("Old.one").exists()); + assert_eq!(recycled(¬ebook), 0); + // One appended revision; the bin's TOC untouched. + assert!(std::fs::metadata(&deleted).unwrap().len() > length); + assert_eq!(listed(&toc), before); + assert_eq!(notebook.purge_recycle_bin(now + 61 * day).unwrap(), 0); + if let Some(directory) = std::env::var_os("NOTEBOOK_PURGE_EXPORT") { + copy_dir(&root, std::path::Path::new(&directory)); + } +} diff --git a/crates/notebook/tests/sync_outline.rs b/crates/notebook/tests/sync_outline.rs index 2cf48fe03ad7d38eb6aed80e48e876562bb6f122..d2134e230cd8c467409d3b2eb666672e3e2a57d4 100644 --- a/crates/notebook/tests/sync_outline.rs +++ b/crates/notebook/tests/sync_outline.rs @@ -634,6 +634,7 @@ fn with_file(source: &[u8], space: ExGuid) -> (Vec, ExGuid) { bytes: Some(std::sync::Arc::from(&b"Floating bytes"[..])), preview: None, recording: None, + tags: Vec::new(), }; let image = remote_with(source, space, |page| { page.objects.push(PageObject::Attachment(file)) diff --git a/crates/onestore/README.md b/crates/onestore/README.md index 93e7d1b4170dae0bd44f6cce75ae34901c866be5..3f7f9b98c2c192cd879732ec221ccb7f1346db93 100644 --- a/crates/onestore/README.md +++ b/crates/onestore/README.md @@ -56,6 +56,7 @@ harness also accepts `--client-profile release`. | `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 | | `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 | | `TextAttribute`, `PageOp::Format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs | +| `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 | | `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 | | `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 | | `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 | diff --git a/crates/onestore/examples/opts29_dump.rs b/crates/onestore/examples/opts29_dump.rs deleted file mode 100644 index 9fd4bf57f6bce83ca59d4fb7f4742eb7e855f30f..0000000000000000000000000000000000000000 --- a/crates/onestore/examples/opts29_dump.rs +++ /dev/null @@ -1,29 +0,0 @@ -use onestore::{ObjectData, PropertySets, RevisionIndex, Store, Value}; -use std::{env, fs}; - -fn main() -> Result<(), Box> { - for path in env::args_os().skip(1) { - let bytes = fs::read(&path)?; - let store = Store::parse(&bytes)?; - let index = RevisionIndex::parse(&store)?; - println!("== {path:?}"); - for space in index.spaces.keys() { - let Ok(resolved) = index.resolve_active(*space) else { continue }; - println!("-- space {space:?}"); - for (id, object) in &resolved.objects { - let ObjectData::Properties(data) = object.data else { continue }; - let sets = PropertySets::parse(data)?; - println!("{id:?} jcid={:#x}", object.jcid); - for p in &sets.sets[0] { - let v = match &p.value { - Value::Bytes(b) if b.len() <= 64 => format!("{:02x?}", b), - Value::Bytes(b) => format!("{} bytes", b.len()), - other => format!("{other:?}").chars().take(120).collect(), - }; - println!(" {:#010x} {v}", p.id); - } - } - } - } - Ok(()) -} diff --git a/crates/onestore/src/document.rs b/crates/onestore/src/document.rs index 069fa5b5638cf5a02615c7833bacaa42df2e10a6..1df66edf660b164bbbe1964d4813b3e76c8f55e8 100644 --- a/crates/onestore/src/document.rs +++ b/crates/onestore/src/document.rs @@ -527,6 +527,9 @@ pub enum Kind<'a> { }, Style { name: Option, + /// NextStyle: the style Enter gives the paragraph after this one's end. + #[serde(default, skip_serializing_if = "Option::is_none")] + next: Option, }, List { font: Option, @@ -1279,6 +1282,7 @@ impl<'a> Element<'a> { } 0x12004d => Kind::Style { name: f.text(0x1c00345a)?, + next: f.text(0x1c00348a)?, }, 0x60012 => { let format = f diff --git a/crates/onestore/src/formatting.rs b/crates/onestore/src/formatting.rs index 61d0da02fc7f81fc05727c6e1fc6911bdd9ef3f9..555cccb4640f42c3a207d4d580bbdf43abf4e195 100644 --- a/crates/onestore/src/formatting.rs +++ b/crates/onestore/src/formatting.rs @@ -108,6 +108,28 @@ pub(crate) fn format_changes( range: Range, attributes: &[TextAttribute], cleared: &[crate::op::TextProperty], +) -> Result { + formatted(active, object, range, attributes, cleared, false) +} + +/// `format_changes` clearing `cleared` from whole runs, fields and equations among them, +/// so they inherit what their paragraph style gives, as a restyle does. +pub(crate) fn cleared_changes( + active: &ActivePage<'_>, + object: ExGuid, + range: Range, + cleared: &[crate::op::TextProperty], +) -> Result { + formatted(active, object, range, &[], cleared, true) +} + +fn formatted( + active: &ActivePage<'_>, + object: ExGuid, + range: Range, + attributes: &[TextAttribute], + cleared: &[crate::op::TextProperty], + fields: bool, ) -> Result { let cleared: Vec = cleared.iter().map(|property| property.id()).collect(); if (attributes.is_empty() && cleared.is_empty()) || range.start > range.end { @@ -163,8 +185,9 @@ pub(crate) fn format_changes( }; if selected { let format = &resolved[i].format; - if [format.math, format.embedded_object].contains(&Some(true)) - || resolved[i].text.contains('\u{fffc}') + if !fields + && ([format.math, format.embedded_object].contains(&Some(true)) + || resolved[i].text.contains('\u{fffc}')) { return Err(invalid( "This format range contains a field or embedded data", diff --git a/crates/onestore/src/op/apply.rs b/crates/onestore/src/op/apply.rs index 930e14878a8e12dbee4f5e598337510b26d0a06b..78584962b7cabc2f02ee6431996e6e1c63af6c12 100644 --- a/crates/onestore/src/op/apply.rs +++ b/crates/onestore/src/op/apply.rs @@ -10,8 +10,8 @@ use super::{ table::{self, Structure}, }; use crate::{ - Error, ExGuid, Insertion, OutlineEdit, ParagraphJoin, ParagraphSplit, Section, TextAttribute, - TreeEdit, + Error, ExGuid, Insertion, ObjectData, OutlineEdit, ParagraphJoin, ParagraphSplit, Section, + TextAttribute, TreeEdit, active::{ActivePage, Changes}, document::Kind, page::{ @@ -517,6 +517,16 @@ impl<'a> Writer<'_, 'a> { } /// Requires `id` reachable on the page. + /// Clears run properties over run ranges, fields and equations included. + fn clear_runs(&mut self, clears: &properties::Clears) -> Result<(), Failure> { + for (text, range, clear) in clears { + self.write(|page| { + crate::formatting::cleared_changes(page, *text, range.clone(), clear) + })?; + } + Ok(()) + } + fn target(&mut self, id: ExGuid) -> Result<(), Failure> { let page = self.page()?; if page.live.is_reachable(id) && page.view.nodes.contains_key(&id) { @@ -786,7 +796,89 @@ impl<'a> Writer<'_, 'a> { } => { self.target(*paragraph)?; let text = self.text_of(*paragraph)?; - self.write(|page| properties::style_changes(page, text, *style, Some(definition))) + // What the old style gave, the new one gives instead. + let page = self.page()?; + let spacing = match page.view.nodes[&text].kind { + Kind::RichText { + paragraph_style: Some(old), + .. + } if old != *style => { + let (clears, spacing) = properties::restyle_plan(page, old, Some(text))?; + self.clear_runs(&clears)?; + spacing + } + _ => Vec::new(), + }; + self.write(|page| { + let mut changes = + properties::style_changes(page, text, *style, Some(definition))?; + if let Some((_, ids)) = spacing.first() { + changes + .get_mut(&text) + .expect("the text is restyled") + .remove(ids)?; + } + Ok(changes) + }) + } + PageOp::Unstyle { paragraph } => { + self.target(*paragraph)?; + let text = self.text_of(*paragraph)?; + let page = self.page()?; + let Kind::RichText { + paragraph_style: Some(old), + .. + } = page.view.nodes[&text].kind + else { + return Ok(()); + }; + let (clears, spacing) = properties::restyle_plan(page, old, Some(text))?; + self.clear_runs(&clears)?; + self.write(|page| { + let mut target = crate::write::PropertyObject::from_object( + &page.live.revision.objects[&text], + )?; + let mut removed = vec![0x2000342c]; + removed.extend(spacing.into_iter().flat_map(|(_, ids)| ids)); + target.remove(&removed)?; + Ok(std::collections::BTreeMap::from([(text, target)])) + }) + } + PageOp::Restyle { + style, + into, + definition, + } => { + self.target(*style)?; + let page = self.page()?; + let (Kind::Style { name, .. }, Kind::Style { name: renamed, .. }) = + (&page.view.nodes[style].kind, &definition.kind) + else { + return Err(OpError::Unsupported("Restyle a paragraph style").into()); + }; + if name.is_none() || name != renamed { + return Err( + OpError::Unsupported("A restyled paragraph style keeps its name").into(), + ); + } + if page.live.is_reachable(*into) { + let ObjectData::Properties(stored) = page.live.revision.objects[into].data + else { + return Err(OpError::DuplicateIdentity(*into).into()); + }; + let given = + crate::create::properties(&properties::style_object_values(definition)?)?; + if stored != given.as_slice() { + return Err(OpError::DuplicateIdentity(*into).into()); + } + } else if into.guid == [0; 16] { + return Err(OpError::DuplicateIdentity(*into).into()); + } + let (clears, spacing) = properties::restyle_plan(page, *style, None)?; + self.clear_runs(&clears)?; + self.write(|page| { + properties::restyle_changes(page, *style, *into, definition, &spacing) + }) } PageOp::Media { paragraph, media } => { self.target(*paragraph)?; @@ -1179,10 +1271,22 @@ impl<'a> Writer<'_, 'a> { let Some(bytes) = &image.bytes else { return Err(OpError::Unsupported("A new picture needs its payload").into()); }; + if !image.tags.is_empty() { + return Err(OpError::Unsupported("Set a picture's tags with their own op").into()); + } let (file, payload) = (fresh()?, crate::write::fresh_guid()?); let bytes: &[u8] = bytes; - self.write_with(&[(payload, bytes)], |page| { - content::picture_changes(page, image, image.id, file, payload, holder) + let mut payloads = vec![(payload, bytes)]; + let shown = match &image.display { + Some(raster) => { + let shown = (fresh()?, crate::write::fresh_guid()?); + payloads.push((shown.1, raster)); + Some(shown) + } + None => None, + }; + self.write_with(&payloads, |page| { + content::picture_changes(page, image, (image.id, file, payload), shown, holder) }) } @@ -1194,6 +1298,9 @@ impl<'a> Writer<'_, 'a> { let Some(bytes) = &attachment.bytes else { return Err(OpError::Unsupported("A new attachment needs its payload").into()); }; + if !attachment.tags.is_empty() { + return Err(OpError::Unsupported("Set a file's tags with their own op").into()); + } let ids = AttachmentIds { object: attachment.id, file: fresh()?, diff --git a/crates/onestore/src/op/content.rs b/crates/onestore/src/op/content.rs index 4008d8d88113a4c128376116ce150ff5a0bc0ee2..c6d8bd2f322691db477ce155192fdf0a80545eba 100644 --- a/crates/onestore/src/op/content.rs +++ b/crates/onestore/src/op/content.rs @@ -132,9 +132,11 @@ pub(crate) fn picture_fixed_fields(stored: &Image, image: &Image) -> Result<(), || stored.bytes != image.bytes || stored.size != image.size || stored.background != image.background + || stored.link != image.link + || stored.text != image.text { return Err(invalid( - "A stored picture keeps its payload, intrinsic size and background state", + "A stored picture keeps its payload, intrinsic size, background state, link and text", )); } Ok(()) @@ -201,13 +203,14 @@ fn icon_size( /// What OneNote stores for an inserted picture: a file-data object declaring payload /// `payload` by identity and extension, and picture object `id` that paragraph `holder` -/// holds as content or the page lists as a child. The caller embeds the payload. +/// holds as content or the page lists as a child; with `shown`, the file-data object and +/// payload of the raster OneNote shows of it (WebPictureContainer14), as a printout page +/// stores beside its XPS package. The caller embeds the payloads. pub(crate) fn picture_changes( active: &ActivePage<'_>, image: &Image, - id: ExGuid, - file: ExGuid, - payload: [u8; 16], + (id, file, payload): (ExGuid, ExGuid, [u8; 16]), + shown: Option<(ExGuid, [u8; 16])>, holder: Option, ) -> Result { let Some(bytes) = &image.bytes else { @@ -220,8 +223,19 @@ pub(crate) fn picture_changes( [b'B', b'M', ..] => ".bmp", // OneNote keeps an imported TIFF as it is (`corpus/m6/native-features-01`). [b'I', b'I', 0x2a, 0x00, ..] | [b'M', b'M', 0x00, 0x2a, ..] => ".tif", + // A file printout's page: its XPS package (`corpus/printout`). + [b'P', b'K', 3, 4, ..] if image.printout.is_some() && shown.is_some() => ".xps", _ => return Err(invalid("Choose a PNG, JPEG, GIF, BMP or TIFF picture")), }; + if image + .display + .as_ref() + .is_some_and(|raster| !raster.starts_with(b"\x89PNG")) + { + return Err(invalid( + "A picture's shown raster is a PNG stored beside it", + )); + } let modified = crate::create::current_timestamps()?.0.to_le_bytes(); let mut values: Values = vec![(0x14001d7a, modified.to_vec())]; if let Some([width, height]) = image.size { @@ -235,6 +249,18 @@ pub(crate) fn picture_changes( if let Some(alt) = &image.alt { values.push((0x1c001e58, crate::create::string(alt))); } + if let Some(link) = &image.link { + values.push((0x1c001e20, crate::create::string(link))); + } + if let Some(recognized) = &image.text { + values.push((0x1c001c22, crate::create::string(&recognized.text))); + for (id, value) in &recognized.stored { + if !crate::page::RECOGNIZED_PROPERTIES.contains(id) { + return Err(invalid("A picture's text stores only its known properties")); + } + values.push((*id, value.clone())); + } + } if image.background { values.push((0x08001d13 | (1 << 31), Vec::new())); } @@ -244,14 +270,21 @@ pub(crate) fn picture_changes( 0x08001d85 | (u32::from(image.printout.is_some()) << 31), Vec::new(), )); - if let Some(page) = image.printout { - values.push((0x14001df9, page.to_le_bytes().to_vec())); + if let Some(printout) = &image.printout { + values.push((0x14001df9, printout.page.to_le_bytes().to_vec())); + for (id, value) in &printout.stored { + if !crate::page::PRINTOUT_PROPERTIES.contains(id) { + return Err(invalid("A printout page stores only its known properties")); + } + values.push((*id, value.clone())); + } } let mut changed = BTreeMap::new(); - changed.insert( - file, - PropertyObject::file(file, &payload_reference(payload), extension)?, - ); + let mut container = PropertyObject::file(file, &payload_reference(payload), extension)?; + if extension == ".xps" { + container.jcid = crate::write::PRINTOUT_FILE_JCID; + } + changed.insert(file, container); let mut picture = PropertyObject { jcid: 0x60011, bytes: crate::create::properties(&values)?, @@ -260,6 +293,14 @@ pub(crate) fn picture_changes( picture.reference(id)?; let container = picture.reference(file)?; picture.set(&[(0x20001c3f, &container)])?; + if let Some((raster, payload)) = shown { + changed.insert( + raster, + PropertyObject::file(raster, &payload_reference(payload), ".png")?, + ); + let web = picture.reference(raster)?; + picture.set(&[(0x200034c8, &web)])?; + } changed.insert(id, picture); hold(active, &mut changed, id, holder, &modified)?; Ok(changed) diff --git a/crates/onestore/src/op/lower.rs b/crates/onestore/src/op/lower.rs index 47755ed1d26d715e0842ebfa484db21cd7f419a4..7f6f5638c06495ef977aebd2380b4fa496af92a9 100644 --- a/crates/onestore/src/op/lower.rs +++ b/crates/onestore/src/op/lower.rs @@ -316,7 +316,9 @@ pub(crate) fn validate( Some(None) => paragraph.style, None => previous.style, }; - if paragraph.style != style || paragraph.format != previous.format { + // `Style` and `Unstyle` give a paragraph of text a style and take it away. + let restyled = paragraph.style != style && paragraph.text().is_none(); + if restyled || paragraph.format != previous.format { return Err(invalid( "Paragraph styles and paragraph formatting cannot be edited", )); @@ -743,6 +745,8 @@ fn bare(paragraph: &PageParagraph) -> PageParagraph { stripped.media = Default::default(); match &mut stripped.content { ParagraphContent::Text(text) => text.tags.clear(), + ParagraphContent::Image(image) => image.tags.clear(), + ParagraphContent::Attachment(file) => file.tags.clear(), ParagraphContent::Table(table) => { table.tags.clear(); for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) { @@ -991,16 +995,18 @@ impl Lowering { }; let stored = pictures(old.page); for (id, image) in pictures(new.page) { - if let Some(previous) = stored.get(&id) - && *previous != image - { - super::content::picture_fixed_fields(previous, &image)?; + let Some(previous) = stored.get(&id) else { + continue; + }; + super::content::picture_fixed_fields(previous, &image)?; + if (&previous.layout, &previous.alt) != (&image.layout, &image.alt) { self.emit(PageOp::Picture { picture: id, layout: image.layout.clone(), alt: image.alt.clone(), })?; } + self.set_tags(new.page, id, &image.tags, &previous.tags)?; } let files = |page: &Page| -> BTreeMap { page.objects @@ -1049,6 +1055,7 @@ impl Lowering { size: file.size, })?; } + self.set_tags(new.page, id, &file.tags, &previous.tags)?; } let inks = |page: &Page| -> BTreeMap { page.objects @@ -1098,7 +1105,7 @@ impl Lowering { if new.title_outlines.contains(&id) { return Err(invalid("Title outlines cannot be added")); } - let object = match object { + let added = match object { PageObject::Outline(outline) => { if outline .paragraphs @@ -1115,9 +1122,17 @@ impl Lowering { ..(*outline).clone() }) } - PageObject::Image(_) | PageObject::Attachment(_) | PageObject::Ink(_) => { - (*object).clone() + PageObject::Image(image) => PageObject::Image(crate::page::Image { + tags: Vec::new(), + ..image.clone() + }), + PageObject::Attachment(file) => { + PageObject::Attachment(crate::page::Attachment { + tags: Vec::new(), + ..file.clone() + }) } + PageObject::Ink(_) => (*object).clone(), PageObject::Title(_) | PageObject::Unsupported(_) => { return Err(invalid( "Titles and unsupported objects cannot be edited through the page model", @@ -1125,9 +1140,15 @@ impl Lowering { } }; self.emit(PageOp::Add { - object, + object: added, before: next, })?; + let tags = match object { + PageObject::Image(image) => image.tags.as_slice(), + PageObject::Attachment(file) => file.tags.as_slice(), + _ => &[], + }; + self.set_tags(new.page, id, tags, &[])?; } else if !kept.contains(&id) { self.emit(PageOp::Move { object: id, @@ -1694,11 +1715,14 @@ impl Lowering { fn styles(&mut self, after: &Page, new: &View<'_>) -> Result<(), Error> { for (id, paragraph) in &new.paragraphs { + let current = model::paragraph(&self.current, *id) + .ok_or_else(|| invalid("A paragraph is missing after text edits"))?; let Some(style) = paragraph.style else { + if current.style.is_some() { + self.emit(PageOp::Unstyle { paragraph: *id })?; + } continue; }; - let current = model::paragraph(&self.current, *id) - .ok_or_else(|| invalid("A paragraph is missing after text edits"))?; if current.style == Some(style) { continue; } @@ -1771,33 +1795,50 @@ impl Lowering { (ParagraphContent::Table(table), ParagraphContent::Table(stored)) => { targets.push((table.id, &table.tags, stored.tags.clone())) } + (ParagraphContent::Image(image), ParagraphContent::Image(stored)) => { + targets.push((image.id, &image.tags, stored.tags.clone())) + } + (ParagraphContent::Attachment(file), ParagraphContent::Attachment(stored)) => { + targets.push((file.id, &file.tags, stored.tags.clone())) + } _ => {} } for (target, tags, stored) in targets { - if same_tags(tags, &stored) { - continue; - } - let mut definitions = Vec::new(); - for tag in tags { - let Some(definition) = tag.definition else { - continue; - }; - if let Some(model) = after.definitions.get(&definition) - && !definitions.iter().any(|(id, _)| *id == definition) - { - definitions.push((definition, model.clone())); - } - } - self.emit(PageOp::Tags { - target, - tags: tags.clone(), - definitions, - })?; + self.set_tags(after, target, tags, &stored)?; } } Ok(()) } + /// Emits the op giving `target` `tags` where it stores others. + fn set_tags( + &mut self, + after: &Page, + target: ExGuid, + tags: &[Tag], + stored: &[Tag], + ) -> Result<(), Error> { + if same_tags(tags, stored) { + return Ok(()); + } + let mut definitions = Vec::new(); + for tag in tags { + let Some(definition) = tag.definition else { + continue; + }; + if let Some(model) = after.definitions.get(&definition) + && !definitions.iter().any(|(id, _)| *id == definition) + { + definitions.push((definition, model.clone())); + } + } + self.emit(PageOp::Tags { + target, + tags: tags.to_vec(), + definitions, + }) + } + fn media(&mut self, new: &View<'_>) -> Result<(), Error> { for (id, paragraph) in &new.paragraphs { let current = model::paragraph(&self.current, *id) diff --git a/crates/onestore/src/op/mod.rs b/crates/onestore/src/op/mod.rs index 4de89028000f8a4b1bc8ac8bdd4e2291aab9d105..87c649658b58639d1249351570b16eba83648a7e 100644 --- a/crates/onestore/src/op/mod.rs +++ b/crates/onestore/src/op/mod.rs @@ -151,6 +151,20 @@ pub enum PageOp { style: ExGuid, definition: Definition, }, + /// Takes a paragraph's style away; values its style gave are cleared from its text, as + /// `Restyle` clears them. + Unstyle { + paragraph: ExGuid, + }, + /// Moves every paragraph of style `style` to style `into`, created from `definition` + /// (the same name) where the page lacks it: style objects are read-only, so a restyled + /// style is a new one. Run and paragraph values equal to what `style` gave are cleared, + /// so they follow `into`; values set otherwise stay. + Restyle { + style: ExGuid, + into: ExGuid, + definition: Definition, + }, /// Links a paragraph's text or file to a moment in recordings on the page, or with /// empty `media` unlinks it. Media { @@ -270,6 +284,44 @@ pub enum SectionOp { }, } +/// The `Restyle` ops giving the paragraphs of every style of `page` named in `sheet` +/// that definition instead, one per style that differs; styles sharing a name become one. +pub fn restyle( + page: &Page, + sheet: &std::collections::BTreeMap, +) -> Result, crate::Error> { + let mut into = std::collections::BTreeMap::new(); + let mut ops = Vec::new(); + for (id, stored) in &page.definitions { + let crate::document::Kind::Style { + name: Some(name), .. + } = &stored.kind + else { + continue; + }; + let Some(definition) = sheet.get(name).filter(|definition| *definition != stored) else { + continue; + }; + let target = match into.get(name) { + Some(target) => *target, + None => { + let target = page + .definitions + .iter() + .find(|(_, kept)| *kept == definition) + .map_or_else(crate::page::text::new_id, |(id, _)| Ok(*id))?; + *into.entry(name.clone()).or_insert(target) + } + }; + ops.push(PageOp::Restyle { + style: *id, + into: target, + definition: definition.clone(), + }); + } + Ok(ops) +} + impl SectionOp { /// `RestoreVersion` of `page`'s `version`, its new objects under a fresh identity. pub fn restore(page: ExGuid, version: ExGuid) -> Result { diff --git a/crates/onestore/src/op/model.rs b/crates/onestore/src/op/model.rs index f10e95de8cbceb6f527d2942eb02c608eea87d14..adefb6755d888744748d15a20ea518df5308e55f 100644 --- a/crates/onestore/src/op/model.rs +++ b/crates/onestore/src/op/model.rs @@ -614,11 +614,21 @@ pub fn apply(page: &mut Page, op: &PageOp) -> Result<(), Error> { let tags = paragraph.tags.iter().chain(match ¶graph.content { ParagraphContent::Text(text) => text.tags.as_slice(), ParagraphContent::Table(table) => table.tags.as_slice(), + ParagraphContent::Image(image) => image.tags.as_slice(), + ParagraphContent::Attachment(file) => file.tags.as_slice(), _ => &[], }); referenced.extend(tags.filter_map(|tag| tag.definition)); } } + for object in &page.objects { + let tags = match object { + PageObject::Image(image) => image.tags.as_slice(), + PageObject::Attachment(file) => file.tags.as_slice(), + _ => &[], + }; + referenced.extend(tags.iter().filter_map(|tag| tag.definition)); + } page.definitions.retain(|id, _| referenced.contains(id)); Ok(()) } @@ -1000,54 +1010,59 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> { let paragraph = paragraph_mut(page, *paragraph)?; paragraph.style = Some(style); if let Some(text) = paragraph.text_mut() { - let mut previous = 0; - let runs: Vec<(String, Format)> = text - .text - .spans() - .iter() - .map(|span| { - // What the old style gave, the new one gives instead. - let mut format = span.format.clone(); - macro_rules! restyle { - ($($field:ident),*) => {$( - if old.$field.is_some() && format.$field == old.$field { - format.$field = new.$field.clone(); - } else if format.$field.is_none() { - format.$field = new.$field.clone(); - } - )*}; + text.text = restyled(&text.text, &old, &new); + } + } + PageOp::Unstyle { paragraph: id } => { + let Some(style) = paragraph_mut(page, *id)?.style.take() else { + return Ok(()); + }; + let old = page + .definitions + .get(&style) + .ok_or_else(|| invalid("The page has no such paragraph style"))? + .format + .clone(); + if let Some(text) = paragraph_mut(page, *id)?.text_mut() { + text.text = restyled(&text.text, &old, &Format::default()); + } + } + PageOp::Restyle { + style, + into, + definition, + } => { + let old = page + .definitions + .get(style) + .ok_or_else(|| invalid("The page has no such paragraph style"))? + .format + .clone(); + let mut definition = definition.clone(); + let format = &mut definition.format; + for points in [ + &mut format.space_before, + &mut format.space_after, + &mut format.line_spacing, + ] + .into_iter() + .flatten() + { + *points = stored(*points); + } + page.definitions + .entry(*into) + .or_insert_with(|| definition.clone()); + let paths: Vec = lists(page).into_iter().map(|(path, ..)| path).collect(); + for path in paths { + for paragraph in list_at(page, &path) { + if paragraph.style == Some(*style) { + paragraph.style = Some(*into); + if let Some(text) = paragraph.text_mut() { + text.text = restyled(&text.text, &old, &definition.format); } - restyle!( - bold, - italic, - underline, - strike, - superscript, - subscript, - hidden, - hyperlink, - hyperlink_label, - math, - embedded_object, - font, - font_size, - color, - highlight, - language, - alignment, - rtl, - space_before, - space_after, - line_spacing, - list_spacing, - math_object - ); - let run = (text.text.text()[previous..span.end].to_owned(), format); - previous = span.end; - run - }) - .collect(); - text.text = Paragraph::from_runs(runs); + } + } } } PageOp::Media { paragraph, media } => { @@ -1088,8 +1103,12 @@ fn interpret(page: &mut Page, op: &PageOp) -> Result<(), Error> { paragraph.tags = tags; } else if let Ok(text) = text_mut(page, *target) { text.tags = tags; + } else if let Ok(table) = table_mut(page, *target) { + table.tags = tags; + } else if let Ok(image) = image_mut(page, *target) { + image.tags = tags; } else { - table_mut(page, *target)?.tags = tags; + attachment_mut(page, *target)?.tags = tags; } } PageOp::Add { object, before } => { @@ -1271,6 +1290,52 @@ fn drop_emptied(page: &mut Page) { /// The identity a read-only definition is stored under: an equal one the page holds, as /// the writers share identical read-only objects, else `id`. +/// `text` moved from paragraph style `old` to `new`: what the old style gave, the new one +/// gives instead. +fn restyled(text: &Paragraph, old: &Format, new: &Format) -> Paragraph { + let mut previous = 0; + Paragraph::from_runs(text.spans().iter().map(|span| { + let mut format = span.format.clone(); + macro_rules! restyle { + ($($field:ident),*) => {$( + if old.$field.is_some() && format.$field == old.$field { + format.$field = new.$field.clone(); + } else if format.$field.is_none() { + format.$field = new.$field.clone(); + } + )*}; + } + restyle!( + bold, + italic, + underline, + strike, + superscript, + subscript, + hidden, + hyperlink, + hyperlink_label, + math, + embedded_object, + font, + font_size, + color, + highlight, + language, + alignment, + rtl, + space_before, + space_after, + line_spacing, + list_spacing, + math_object + ); + let run = (text.text()[previous..span.end].to_owned(), format); + previous = span.end; + run + })) +} + fn define(page: &mut Page, id: ExGuid, definition: &crate::page::Definition) -> ExGuid { if page.definitions.contains_key(&id) { return id; diff --git a/crates/onestore/src/op/properties.rs b/crates/onestore/src/op/properties.rs index 4d7aadd7f94dc035e14cc157128f5ee21ab2e1df..13d3271d0a6cb874c4ceb64d450a3ed7e4807f57 100644 --- a/crates/onestore/src/op/properties.rs +++ b/crates/onestore/src/op/properties.rs @@ -2,7 +2,7 @@ use super::Values; use crate::{ - Error, ExGuid, PropertySets, Value, + Error, ExGuid, ObjectData, PropertySets, Value, active::{ActivePage, Changes}, document::{Kind, Tag}, page::{Definition, MediaIndex}, @@ -293,26 +293,7 @@ pub(crate) fn style_changes( if !raw.objects.contains_key(&style) { let definition = definition .ok_or_else(|| invalid("A paragraph references a missing style definition"))?; - let Kind::Style { name } = &definition.kind else { - return Err(invalid("A paragraph style must be a style definition")); - }; - let format = &definition.format; - let mut values = super::content::style_values(format); - if let Some(name) = name { - values.push((0x1c00345a, crate::create::string(name))); - } - if let Some(alignment) = format.alignment { - values.push((0x0c003477, vec![alignment])); - } - for (property, value) in [ - (0x1400342e, format.space_before), - (0x1400342f, format.space_after), - (0x14003430, format.line_spacing), - ] { - if let Some(points) = value { - values.push((property, (points / 36.0).to_le_bytes().to_vec())); - } - } + let values = style_object_values(definition)?; let mut node = PropertyObject { jcid: 0x12004d, bytes: crate::create::properties(&values)?, @@ -328,6 +309,170 @@ pub(crate) fn style_changes( Ok(changed) } +/// Run properties to clear, by text and run range. +pub(crate) type Clears = Vec<(ExGuid, std::ops::Range, Vec)>; +/// Paragraph properties to remove, by text. +pub(crate) type Spacing = Vec<(ExGuid, Vec)>; + +/// Style-level values the texts of paragraph style `style` (or only text `only`) repeat: +/// run properties equal to the style's, by run range, and each text's paragraph properties +/// equal to the style's. Texts whose runs carry objects of their own stay as they are. +pub(crate) fn restyle_plan( + active: &ActivePage<'_>, + style: ExGuid, + only: Option, +) -> Result<(Clears, Spacing), Error> { + use super::TextProperty::*; + const CHARACTER: [super::TextProperty; 10] = [ + Bold, + Italic, + Underline, + Strike, + Superscript, + Subscript, + Font, + FontSize, + Color, + Highlight, + ]; + const PARAGRAPH: [u32; 4] = [0x0c003477, 0x1400342e, 0x1400342f, 0x14003430]; + let raw = &active.live.revision; + let values = |id: ExGuid| -> Result)>, Error> { + let ObjectData::Properties(bytes) = raw.objects[&id].data else { + return Err(invalid("A style holds no properties")); + }; + Ok(PropertySets::parse(bytes)?.sets[0] + .iter() + .map(|property| { + let value = match property.value { + Value::Bytes(bytes) => bytes.to_vec(), + _ => Vec::new(), + }; + (property.id & 0x7fffffff, (property.id, value)) + }) + .collect()) + }; + let stored = values(style)?; + let repeats = |own: &BTreeMap)>, key: u32| { + own.get(&key) + .is_some_and(|value| stored.get(&key) == Some(value)) + }; + let mut clears = Vec::new(); + let mut spacing = Vec::new(); + for (&text, node) in &active.view.nodes { + let Kind::RichText { + text: characters, + runs, + paragraph_style: Some(paragraph_style), + boilerplate: false, + } = &node.kind + else { + continue; + }; + if *paragraph_style != style || only.is_some_and(|only| only != text) { + continue; + } + let own = values(text)?; + if own.contains_key(&0x24003458) { + continue; + } + let repeated: Vec = PARAGRAPH + .into_iter() + .filter(|key| repeats(&own, *key)) + .collect(); + if !repeated.is_empty() { + spacing.push((text, repeated)); + } + let mut pending: Option<(std::ops::Range, Vec)> = None; + for run in runs { + let cleared: Vec = match run.format { + Some(id) => { + let own = values(id)?; + CHARACTER + .into_iter() + .filter(|property| repeats(&own, property.id())) + .collect() + } + None => Vec::new(), + }; + let range = if characters.is_empty() { + 0..0 + } else { + run.start..run.end + }; + match &mut pending { + Some((open, kept)) if *kept == cleared && open.end == range.start => { + open.end = range.end; + } + _ => { + if let Some((range, clear)) = pending.take() { + clears.push((text, range, clear)); + } + pending = Some((range, cleared)); + } + } + } + clears.extend(pending.map(|(range, clear)| (text, range, clear))); + } + clears.retain(|(_, _, clear)| !clear.is_empty()); + Ok((clears, spacing)) +} + +/// The texts of paragraph style `style` moved to style `into`, created from `definition` +/// where the page lacks it, each without its `spacing` properties. +pub(crate) fn restyle_changes( + active: &ActivePage<'_>, + style: ExGuid, + into: ExGuid, + definition: &Definition, + spacing: &[(ExGuid, Vec)], +) -> Result { + let mut changed = BTreeMap::new(); + for (&text, node) in &active.view.nodes { + if !matches!(node.kind, Kind::RichText { paragraph_style: Some(id), .. } if id == style) { + continue; + } + let mut moved = style_changes(active, text, into, Some(definition))?; + let mut target = moved.remove(&text).expect("the text is restyled"); + if let Some((_, ids)) = spacing.iter().find(|(id, _)| *id == text) { + target.remove(ids)?; + } + changed.insert(text, target); + changed.extend(moved); + } + Ok(changed) +} + +/// The properties of a paragraph style object holding `definition`: NextStyle first, as +/// OneNote 2010 writes it, then the character formatting, the name and paragraph spacing. +pub(crate) fn style_object_values(definition: &Definition) -> Result { + let Kind::Style { name, next } = &definition.kind else { + return Err(invalid("A paragraph style must be a style definition")); + }; + let format = &definition.format; + let mut values = Values::new(); + if let Some(next) = next { + values.push((0x1c00348a, crate::create::string(next))); + } + values.extend(super::content::style_values(format)); + if let Some(name) = name { + values.push((0x1c00345a, crate::create::string(name))); + } + if let Some(alignment) = format.alignment { + values.push((0x0c003477, vec![alignment])); + } + for (property, value) in [ + (0x1400342e, format.space_before), + (0x1400342f, format.space_after), + (0x14003430, format.line_spacing), + ] { + if let Some(points) = value { + values.push((property, (points / 36.0).to_le_bytes().to_vec())); + } + } + Ok(values) +} + /// The text-object properties of paragraph formatting, in the order they are written. pub(crate) fn paragraph_values( alignment: Option, diff --git a/crates/onestore/src/op/tests.rs b/crates/onestore/src/op/tests.rs index ed2af39d97bb69beabe8671d09c1f37d3491e1fc..255016c288532e73d3665d6435ccce910dea64bb 100644 --- a/crates/onestore/src/op/tests.rs +++ b/crates/onestore/src/op/tests.rs @@ -631,7 +631,7 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { }; let style = (family == Family::Style).then(|| { let existing = page.definitions.iter().find_map(|(id, d)| { - matches!(&d.kind, Kind::Style { name: Some(name) } if name == "Heading 2") + matches!(&d.kind, Kind::Style { name: Some(name), .. } if name == "Heading 2") .then_some(*id) }); existing.unwrap_or_else(|| { @@ -641,6 +641,7 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { Definition { kind: Kind::Style { name: Some("Heading 2".into()), + next: None, }, format: Format { font: Some("Calibri".into()), @@ -1103,6 +1104,9 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { alt: Some("dot".into()), background: false, printout: None, + tags: Vec::new(), + link: None, + text: None, }), Family::Attachment => ParagraphContent::Attachment(Attachment { id: id(), @@ -1113,6 +1117,7 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { bytes: Some(b"attached bytes".as_slice().into()), preview: None, recording: None, + tags: Vec::new(), }), _ => { let Some(equation) = equation else { @@ -1158,6 +1163,9 @@ pub(super) fn mutate(page: &mut Page, rng: &mut Rng, family: Family) -> bool { alt: None, background: false, printout: None, + tags: Vec::new(), + link: None, + text: None, }) } else { PageObject::Ink(Ink { @@ -1816,6 +1824,7 @@ fn random_object_op(page: &Page, rng: &mut Rng) -> Option { definition: Definition { kind: Kind::Style { name: Some("Heading 3".into()), + next: None, }, format: Format { bold: Some(true), @@ -3122,3 +3131,192 @@ fn deleted_objects_added_back_store_as_they_were() { assert_eq!(shape(&mut section, object), stored, "{title}"); } } + +/// A style definition named `name` in random formatting, as a theme gives one. +fn random_style(name: &str, rng: &mut Rng) -> Definition { + let fonts = ["Georgia", "Arial", "Calibri", "Courier New"]; + Definition { + kind: Kind::Style { + name: Some(name.into()), + next: rng.coin().then(|| "p".into()), + }, + format: Format { + bold: Some(rng.coin()), + italic: Some(rng.coin()), + underline: Some(false), + strike: Some(false), + superscript: Some(false), + subscript: Some(false), + font: Some(fonts[rng.next() as usize % fonts.len()].into()), + font_size: Some(8.0 + (rng.next() % 30) as f32 / 2.0), + color: Some(if rng.coin() { + 0xff00_0000 + } else { + rng.next() as u32 & 0xff_ffff + }), + highlight: Some(0xff00_0000), + space_before: Some((rng.next() % 4) as f32 * 3.0), + space_after: Some((rng.next() % 3) as f32 * 2.0), + line_spacing: Some(0.0), + ..Default::default() + }, + } +} + +/// Restyling a page's paragraph styles, after runs were given values their style already +/// gives, reads back as the model predicts; reopened, the section holds the same pages, and +/// a second restyle to the same sheet has nothing left to do. +#[test] +fn restyles_read_back_as_the_model_predicts() { + let sources = SOURCES.iter().copied().chain([( + "styles", + &include_bytes!("../../../../corpus/styles/onenote/Styles.one")[..], + )]); + let mut restyled = 0; + for (name, source) in sources { + for space in pages(source) { + let arena = Arena::default(); + let mut section = Section::open(&arena, source.to_vec()).unwrap(); + let mut rng = Rng(space.n as u64 * 7919 + name.len() as u64); + let before = section.page(space).unwrap(); + // Runs stating their style's font and size, which the restyle clears. + let mut ops = Vec::new(); + for (_, _, list) in model::lists(&before) { + for paragraph in list { + let (Some(style), Some(text)) = (paragraph.style, paragraph.text()) else { + continue; + }; + let format = &before.definitions[&style].format; + let length = text.text.utf16_offset(text.text.text().len()).unwrap(); + if length == 0 || text.date_field.is_some() || !rng.coin() { + continue; + } + let set: Vec = [ + format.font.clone().map(TextAttribute::Font), + format.font_size.map(TextAttribute::FontSize), + ] + .into_iter() + .flatten() + .collect(); + if !set.is_empty() && !crate::page::Math::is_equation(&text.text) { + ops.push(PageOp::Format { + text: text.id, + range: 0..length, + set, + clear: Vec::new(), + }); + } + } + } + let mut stated = before.clone(); + for op in ops.clone() { + if section + .apply( + "Author", + &Edit { + at: AT, + ops: vec![Op::Page { + space, + op: op.clone(), + }], + }, + ) + .is_ok() + { + model::apply(&mut stated, &op).unwrap(); + } + } + let names: BTreeSet = stated + .definitions + .values() + .filter_map(|definition| match &definition.kind { + Kind::Style { name, .. } => name.clone(), + _ => None, + }) + .collect(); + let sheet: BTreeMap = names + .iter() + .map(|style| (style.clone(), random_style(style, &mut rng))) + .collect(); + let ops = restyle(&stated, &sheet).unwrap(); + if ops.is_empty() { + continue; + } + restyled += ops.len(); + let edit = Edit { + at: AT + 1, + ops: ops + .iter() + .map(|op| Op::Page { + space, + op: op.clone(), + }) + .collect(), + }; + section + .apply("Author", &edit) + .unwrap_or_else(|error| panic!("{name} {space:?}: {error:?}")); + let mut predicted = section.page(space).unwrap(); + let stored = predicted.clone(); + predicted.clone_from(&stated); + for op in &ops { + model::apply(&mut predicted, op).unwrap(); + } + assert!( + normalize(&predicted) == normalize(&stored), + "{name} {space:?}: {}", + first_difference(&normalize(&stored), &normalize(&predicted)) + ); + assert!( + restyle(&stored, &sheet).unwrap().is_empty(), + "{name}: restyled twice" + ); + section.seal().unwrap(); + let reopened = Section::open(&arena, section.image()).unwrap(); + assert!(reopened.page(space).unwrap() == stored, "{name}: reopened"); + } + } + assert!(restyled > 20, "{restyled} styles restyled"); +} + +/// A restyle keeps the style's name and needs the page to hold the style. +#[test] +fn restyle_refuses_renames_and_missing_styles() { + let source = include_bytes!("../../../../corpus/styles/onenote/Styles.one"); + let arena = Arena::default(); + let mut section = Section::open(&arena, source.to_vec()).unwrap(); + let space = pages(source)[0]; + let page = section.page(space).unwrap(); + let (&h1, stored) = page + .definitions + .iter() + .find(|(_, d)| matches!(&d.kind, Kind::Style { name: Some(name), .. } if name == "h1")) + .unwrap(); + assert_eq!( + stored.kind, + Kind::Style { + name: Some("h1".into()), + next: Some("p".into()), + } + ); + let mut renamed = stored.clone(); + renamed.kind = Kind::Style { + name: Some("h2".into()), + next: None, + }; + for (style, definition) in [(h1, renamed), (id(), stored.clone())] { + let edit = Edit { + at: AT, + ops: vec![Op::Page { + space, + op: PageOp::Restyle { + style, + into: id(), + definition, + }, + }], + }; + assert!(section.apply("Author", &edit).is_err()); + assert!(section.page(space).unwrap() == page); + } +} diff --git a/crates/onestore/src/page/mod.rs b/crates/onestore/src/page/mod.rs index 580278d2b9bd7ec3ad8fbec7a6129ee64ed0f8c1..5f96483457a6c5701258e20b1a6a61ab3a5de5b0 100644 --- a/crates/onestore/src/page/mod.rs +++ b/crates/onestore/src/page/mod.rs @@ -285,6 +285,10 @@ pub struct Attachment { /// Set when the file is audio or video OneNote plays; notes on the page refer to it by /// identity (`PageParagraph::media`). pub recording: Option, + /// Note tags on the file itself, where OneNote stores a tagged file's, in a paragraph or + /// on the page. + #[serde(default)] + pub tags: Vec, } /// A recording's identity, whether it is audio (1) or video (2), and its length. @@ -324,6 +328,7 @@ impl PartialEq for Attachment { && self.size == other.size && self.layout == other.layout && self.recording == other.recording + && self.tags == other.tags } } @@ -376,6 +381,7 @@ impl Attachment { .unwrap_or(1), duration_ms: *recording_duration, }), + tags: node.tags.clone(), }) } } @@ -435,12 +441,52 @@ pub struct Image { pub display: Option>, pub alt: Option, pub background: bool, - /// The page of a file printout the picture shows, zero-based (DisplayedPageNumber); - /// OneNote frames printout pages. + /// Set when the picture is a page of a file printout, which OneNote frames. #[serde(default)] - pub printout: Option, + pub printout: Option, + /// Note tags on the picture itself, where OneNote stores a tagged picture's, in a + /// paragraph or on the page. + #[serde(default)] + pub tags: Vec, + /// The address the picture links to (WzHyperlinkUrl). + #[serde(default)] + pub link: Option, + /// Text OneNote recognised in the picture, or a printout page's text; OneNote searches + /// it. + #[serde(default)] + pub text: Option, +} + +/// Text a picture holds (RichEditTextUnicode). +#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Recognized { + pub text: String, + /// Its language (RichEditTextLangID) and the undocumented layout of its words, + /// `RECOGNIZED_PROPERTIES`, written back as read. + pub stored: Vec<(u32, Vec)>, +} + +/// RichEditTextLangID, and where OneNote 2010 found each word of a picture's text +/// (`corpus/object-tags`), not in MS-ONE. +pub(crate) const RECOGNIZED_PROPERTIES: [u32; 2] = [0x10001cfe, 0x1c001d61]; + +/// A page of a file printout. +#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Printout { + /// The page shown, zero-based (DisplayedPageNumber). + pub page: u32, + /// The undocumented properties tying the page to its XPS package, `PRINTOUT_PROPERTIES`, + /// written back as read; without them OneNote does not draw a page it reads back. + pub stored: Vec<(u32, Vec)>, } +/// The printout's identity, its file's index and the page's place in it, as OneNote 2010 +/// stores them on a printout page (`corpus/printout`), not in MS-ONE; with the picture's +/// file name (ImageFilename) and the package's description and hash, also undocumented. +pub(crate) const PRINTOUT_PROPERTIES: [u32; 6] = [ + 0x1c001d84, 0x14003480, 0x14003481, 0x1c001dd7, 0x1c001dfb, 0x1c001dfc, +]; + impl PartialEq for Image { fn eq(&self, other: &Self) -> bool { self.id == other.id @@ -449,6 +495,9 @@ impl PartialEq for Image { && self.alt == other.alt && self.background == other.background && self.printout == other.printout + && self.tags == other.tags + && self.link == other.link + && self.text == other.text } } @@ -466,6 +515,8 @@ impl Image { printout, picture_width, picture_height, + link, + filename, .. } = &node.kind else { @@ -498,8 +549,8 @@ impl Image { display: payload(web)?, alt: alt.clone(), background: background.unwrap_or(false), - printout: printout.unwrap_or(false).then(|| { - fields + printout: printout.unwrap_or(false).then(|| Printout { + page: fields .iter() .find_map(|field| match field.value { FieldValue::Bytes(&[a, b, c, d]) if field.id == 0x14001df9 => { @@ -507,8 +558,50 @@ impl Image { } _ => None, }) - .unwrap_or(0) + .unwrap_or(0), + stored: filename + .iter() + .map(|name| (0x1c001dd7, crate::create::string(name))) + .chain(fields.iter().filter_map(|field| match field.value { + FieldValue::Bytes(bytes) if PRINTOUT_PROPERTIES.contains(&field.id) => { + Some((field.id, bytes.to_vec())) + } + _ => None, + })) + .collect(), }), + tags: node.tags.clone(), + link: link.clone(), + text: fields + .iter() + .find_map(|field| match field.value { + FieldValue::Bytes(bytes) if field.id == 0x1c001c22 => Some(bytes), + _ => None, + }) + .map(|bytes| { + let units: Vec = bytes + .chunks_exact(2) + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect(); + String::from_utf16_lossy(&units) + .trim_end_matches('\0') + .to_owned() + }) + .filter(|text| !text.trim().is_empty()) + .map(|text| Recognized { + text, + stored: fields + .iter() + .filter_map(|field| match field.value { + FieldValue::Bytes(bytes) + if RECOGNIZED_PROPERTIES.contains(&field.id) => + { + Some((field.id, bytes.to_vec())) + } + _ => None, + }) + .collect(), + }), }) } } @@ -870,14 +963,14 @@ impl Page { page.objects.push(PageObject::Outline(outline)); } } - Kind::Image { .. } => { - page.objects - .push(PageObject::Image(Image::read(revision, id, node)?)); - } - Kind::Attachment { .. } => { - page.objects.push(PageObject::Attachment(Attachment::read( - revision, id, node, - )?)); + Kind::Image { .. } | Kind::Attachment { .. } => { + for tag in node.tags.iter().filter_map(|tag| tag.definition) { + define(revision, tag, false, &mut page.definitions)?; + } + page.objects.push(match node.kind { + Kind::Image { .. } => PageObject::Image(Image::read(revision, id, node)?), + _ => PageObject::Attachment(Attachment::read(revision, id, node)?), + }); } Kind::Ink { .. } => { page.objects @@ -990,6 +1083,55 @@ pub(crate) fn text_of( }) } +/// Adds list or tag definition `id` to `definitions`. +fn define( + revision: &Revision<'_>, + id: ExGuid, + is_list: bool, + definitions: &mut BTreeMap, +) -> Result<(), Error> { + let invalid = |message| Error { offset: 0, message }; + let definition = revision + .nodes + .get(&id) + .ok_or_else(|| invalid("Missing canvas list or tag definition"))?; + let kind = match &definition.kind { + Kind::List { + font, + format, + restart, + bullet, + } if is_list => Kind::List { + font: font.clone(), + format: format.clone(), + restart: *restart, + bullet: *bullet, + }, + Kind::TagDefinition { + label, + action_type, + shape, + color, + highlight, + } if !is_list => Kind::TagDefinition { + label: label.clone(), + action_type: *action_type, + shape: *shape, + color: *color, + highlight: *highlight, + }, + _ => return Err(invalid("Canvas list or tag definition has the wrong type")), + }; + definitions.insert( + id, + Definition { + kind, + format: definition.format.clone(), + }, + ); + Ok(()) +} + fn read_paragraphs( revision: &Revision<'_>, container: &Element<'_>, @@ -1137,67 +1279,28 @@ fn read_paragraphs( .chain(match &content { ParagraphContent::Text(text) => text.tags.as_slice(), ParagraphContent::Table(table) => table.tags.as_slice(), - ParagraphContent::Image(_) - | ParagraphContent::Attachment(_) - | ParagraphContent::Ink(_) - | ParagraphContent::Unsupported(_) => &[], + ParagraphContent::Image(image) => image.tags.as_slice(), + ParagraphContent::Attachment(file) => file.tags.as_slice(), + ParagraphContent::Ink(_) | ParagraphContent::Unsupported(_) => &[], }) .filter_map(|tag| tag.definition.as_ref()) .map(|id| (id, false)), ) { - let definition = revision - .nodes - .get(id) - .ok_or_else(|| invalid("Missing canvas list or tag definition"))?; - let kind = match &definition.kind { - Kind::List { - font, - format, - restart, - bullet, - } if is_list => Kind::List { - font: font.clone(), - format: format.clone(), - restart: *restart, - bullet: *bullet, - }, - Kind::TagDefinition { - label, - action_type, - shape, - color, - highlight, - } if !is_list => Kind::TagDefinition { - label: label.clone(), - action_type: *action_type, - shape: *shape, - color: *color, - highlight: *highlight, - }, - _ => { - return Err(invalid( - "Canvas list or tag definition has the wrong type", - )); - } - }; - definitions.insert( - *id, - Definition { - kind, - format: definition.format.clone(), - }, - ); + define(revision, *id, is_list, definitions)?; } if let Some(id) = base_style { let definition = revision .nodes .get(&id) .ok_or_else(|| invalid("Missing canvas paragraph style"))?; - let Kind::Style { name } = &definition.kind else { + let Kind::Style { name, next } = &definition.kind else { return Err(invalid("Canvas paragraph style has the wrong type")); }; definitions.entry(id).or_insert_with(|| Definition { - kind: Kind::Style { name: name.clone() }, + kind: Kind::Style { + name: name.clone(), + next: next.clone(), + }, format: definition.format.clone(), }); } @@ -1445,6 +1548,7 @@ mod tests { }); let mut style = element(Kind::Style { name: Some("Body".into()), + next: None, }); style.format.font_size = Some(12.0); let mut metadata = element(Kind::Metadata { @@ -1705,7 +1809,7 @@ mod tests { assert_eq!(paragraph.parent, None); assert_eq!(paragraph.style, Some(id(5))); assert!(matches!(&page.definitions[&id(5)].kind, - Kind::Style { name: Some(name) } if name == "Body")); + Kind::Style { name: Some(name), .. } if name == "Body")); assert_eq!(page.definitions[&id(5)].format.font_size, Some(12.0)); let text = ¶graph.text().unwrap().text; assert_eq!(text.text(), "ab"); diff --git a/crates/onestore/src/pages.rs b/crates/onestore/src/pages.rs index 12ec170225d4d572e8f7d3ec9b78137e40d7b1a2..f2ad0ddacf91761c881cd2f687ddb1b807d2e52c 100644 --- a/crates/onestore/src/pages.rs +++ b/crates/onestore/src/pages.rs @@ -506,6 +506,12 @@ pub struct PageCreation { /// The page identity and creation time (FILETIME) a page keeps from elsewhere. #[serde(default, skip_serializing_if = "Option::is_none")] kept: Option<([u8; 16], u64)>, + /// The title's font, OneNote's Default font face; none is Calibri. + #[serde(default, skip_serializing_if = "Option::is_none")] + title_font: Option, + /// The title's colour, OneNote's Default font colour as COLORREF; none is automatic. + #[serde(default, skip_serializing_if = "Option::is_none")] + title_color: Option, } impl PageCreation { @@ -522,6 +528,8 @@ impl PageCreation { created: current_timestamps()?.0, date: None, kept: None, + title_font: None, + title_color: None, }; page.validate()?; Ok(page) @@ -538,6 +546,16 @@ impl PageCreation { Ok(self) } + /// Sets the title in `font` and `color` (COLORREF; none is automatic), as OneNote 2010 + /// sets a new page's `PageTitle` quick style in its Default font's face and colour, + /// keeping the size (`corpus/default-font`). + pub fn titled_in(mut self, font: &str, color: Option) -> Result { + self.title_font = Some(font.to_owned()); + self.title_color = color; + self.validate()?; + Ok(self) + } + /// Keeps `identity`, the page identity internal links name, and `created`, as OneNote /// 2010 keeps both for a page it moves to the notebook's recycle bin. pub fn keeping(mut self, identity: [u8; 16], created: u64) -> Result { @@ -626,6 +644,10 @@ impl PageCreation { .iter() .chain(self.date.iter().flatten()) .any(|text| text.contains(['\0', '\r', '\n', '\u{fffc}', '\u{fddf}'])) + || self + .title_font + .as_ref() + .is_some_and(|font| font.is_empty() || font.contains('\0')) { return Err(invalid( "Use a new page identity, an existing page anchor, and ordinary single-line title text", @@ -924,11 +946,20 @@ impl PageCreation { ( 18, 0x12004d, - vec![ + [ (0x1c00345a, string("PageTitle")), - (0x1c001c0a, string("Calibri")), + ( + 0x1c001c0a, + string(self.title_font.as_deref().unwrap_or("Calibri")), + ), (0x10001c0b, 34_u16.to_le_bytes().to_vec()), - ], + ] + .into_iter() + .chain( + self.title_color + .map(|color| (0x14001c0c, color.to_le_bytes().to_vec())), + ) + .collect(), ), (19, 0x12004d, vec![(0x14001c3b, reference(0x409))]), ] { diff --git a/crates/onestore/src/write.rs b/crates/onestore/src/write.rs index 8a5d92cf12e606eb320006836f0f0f058a30c2da..3b517b11a5c411334ead07dcbab6a9f782839be1 100644 --- a/crates/onestore/src/write.rs +++ b/crates/onestore/src/write.rs @@ -419,9 +419,11 @@ pub(crate) fn replacement(id: ExGuid, object: &crate::Object<'_>) -> Result Result<(&[u8], &[u8])> { @@ -470,7 +472,10 @@ pub(crate) fn declared( } fn is_file_declaration(jcid: u32) -> bool { - jcid == FILE_DATA_JCID || jcid == EMBEDDED_FILE_JCID + matches!( + jcid, + FILE_DATA_JCID | EMBEDDED_FILE_JCID | PRINTOUT_FILE_JCID + ) } #[derive(Clone)] diff --git a/crates/onestore/tests/page_attachments.rs b/crates/onestore/tests/page_attachments.rs index 2a477e5cd3a580a69eb1dc362deb2339331b7ceb..74c591ee957307a5939a083ec8f5142084a3a31a 100644 --- a/crates/onestore/tests/page_attachments.rs +++ b/crates/onestore/tests/page_attachments.rs @@ -116,6 +116,7 @@ fn insert_attachment(section: &str, preview: Option>) -> Vec { bytes: Some(Arc::from(PAYLOAD)), preview: preview.clone(), recording: None, + tags: Vec::new(), }); body_paragraphs(&mut after).push(holder.clone()); 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() { bytes: Some(Arc::from(PAYLOAD)), preview: None, recording: None, + tags: Vec::new(), }); body_paragraphs(&mut after).push(holder); 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() { bytes: Some(Arc::from(PAYLOAD)), preview: attachments(&native)[0].preview.clone(), recording: None, + tags: Vec::new(), }; let steps: [&dyn Fn(&mut Page); 4] = [ &|page| page.objects.push(PageObject::Attachment(file.clone())), diff --git a/crates/onestore/tests/page_images.rs b/crates/onestore/tests/page_images.rs index 62a9972126c11b6b3c4e75ef875e1dbdf329542e..52c04567e3307232946d49ae2ff32329e7a6529e 100644 --- a/crates/onestore/tests/page_images.rs +++ b/crates/onestore/tests/page_images.rs @@ -119,6 +119,9 @@ fn a_picture_inserted_on_a_fresh_page_reads_back_and_can_be_removed() { alt: None, background: false, printout: None, + tags: Vec::new(), + link: None, + text: None, }; holder.content = ParagraphContent::Image(image.clone()); body_paragraphs(&mut after).push(holder.clone()); @@ -299,6 +302,9 @@ fn a_page_level_picture_is_inserted_moved_and_removed() { alt: Some("Placed in Rust".into()), background: false, printout: None, + tags: Vec::new(), + link: None, + text: None, }; after.objects.push(PageObject::Image(image.clone())); let written = ops::saved(&source, space, &after).unwrap(); @@ -366,6 +372,9 @@ fn pictures_need_a_recognised_payload_and_stored_ones_stay_fixed() { alt: None, background: false, printout: None, + tags: Vec::new(), + link: None, + text: None, }); body_paragraphs(&mut after).push(holder); assert!(ops::saved(&source, space, &after).is_err()); diff --git a/crates/onestore/tests/protected.rs b/crates/onestore/tests/protected.rs index 3fe69af3dfd091e9720a1e9b92db3ce347fea91d..0cfe4a135dfd392f9cda9b269dc7fbd235b50033 100644 --- a/crates/onestore/tests/protected.rs +++ b/crates/onestore/tests/protected.rs @@ -179,6 +179,7 @@ fn a_protected_page_edit_is_stored_under_the_section_key() { bytes: Some(std::sync::Arc::from(&b"A payload that stays sealed"[..])), preview: None, recording: None, + tags: Vec::new(), }); paragraphs.insert(at + 1, file); let text = paragraphs[at].text_mut().unwrap(); diff --git a/crates/snowbound/Cargo.toml b/crates/snowbound/Cargo.toml index a582ecf29cd0fbe27eff2fe1b7e75524912468bb..ce1831e53f3b44c9059a51f0cf9bdb7fbe622be6 100644 --- a/crates/snowbound/Cargo.toml +++ b/crates/snowbound/Cargo.toml @@ -54,8 +54,12 @@ windows-sys = { version = "0.61", features = [ "Win32_Security_Credentials", "Win32_Storage_FileSystem", "Win32_System_Com", + "Win32_System_Console", + "Win32_System_Diagnostics_Debug", "Win32_System_IO", + "Win32_System_Kernel", "Win32_System_LibraryLoader", + "Win32_System_Pipes", "Win32_System_Registry", "Win32_System_Threading", "Win32_System_Time", diff --git a/crates/snowbound/assets/icons/styles.svg b/crates/snowbound/assets/icons/styles.svg new file mode 100644 index 0000000000000000000000000000000000000000..e272c36985720fd9760a08ba8a0403e2063c4a66 --- /dev/null +++ b/crates/snowbound/assets/icons/styles.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/snowbound/linux/README.md b/crates/snowbound/linux/README.md index 17d4235b3d64ce57518173261e6c1af2dd71eafb..c78feb6ac15015205246ebeb08ab2f198c5a5344 100644 --- a/crates/snowbound/linux/README.md +++ b/crates/snowbound/linux/README.md @@ -21,8 +21,9 @@ Edits save to the notebook folder as you type. Settings live in puts the latter elsewhere. **Install Snowbound**, shown while no notebook is open, adds Snowbound to your app menu and -copies it to `~/.local/bin/snowbound`. Options, next to the update settings, -uninstalls it again; your notebooks stay where they are. +to the apps that open `.one` and `.onetoc2` files, and copies it to +`~/.local/bin/snowbound`. Options, next to the update settings, uninstalls it +again; your notebooks stay where they are. The window's title bar is your desktop's own: KDE's on KDE, and on GNOME and other desktops without server-side decorations an Adwaita-style one drawn by diff --git a/crates/snowbound/linux/onenote.xml b/crates/snowbound/linux/onenote.xml new file mode 100644 index 0000000000000000000000000000000000000000..ce77b5a79d8fc04c0e149428eec7fc065da419e3 --- /dev/null +++ b/crates/snowbound/linux/onenote.xml @@ -0,0 +1,9 @@ + + + + OneNote notebook file + + + + + diff --git a/crates/snowbound/linux/snowbound.desktop b/crates/snowbound/linux/snowbound.desktop index c60251a5e05e73757723637a208146d20d2a9fa2..8be4cfc5eb9a11497b987fcc77ed60c333c50e86 100644 --- a/crates/snowbound/linux/snowbound.desktop +++ b/crates/snowbound/linux/snowbound.desktop @@ -3,8 +3,9 @@ Type=Application Name=Snowbound GenericName=Note Taking Comment=Type and draw notes in OneNote notebooks -Exec=snowbound +Exec=snowbound %F Icon=net.paperclover.snowbound Terminal=false Categories=Office; +MimeType=application/onenote; StartupWMClass=net.paperclover.snowbound diff --git a/crates/snowbound/src/art.rs b/crates/snowbound/src/art.rs index afdadaa55d7d741eb992945239f9ab235d62d37c..932c3b08551e5ca5a90e440750eb5a16c2144e3f 100644 --- a/crates/snowbound/src/art.rs +++ b/crates/snowbound/src/art.rs @@ -41,6 +41,7 @@ pub const SERVER: &[&str] = art!("icons/server"); pub const SIDEBAR_COLLAPSE: &[&str] = art!("icons/sidebar-collapse"); pub const SIDEBAR_EXPAND: &[&str] = art!("icons/sidebar-expand"); pub const STRIKETHROUGH: &[&str] = art!("icons/strikethrough"); +pub const STYLES: &[&str] = art!("icons/styles"); pub const SUBSCRIPT: &[&str] = art!("icons/subscript"); pub const SUPERSCRIPT: &[&str] = art!("icons/superscript"); pub const TABLE: &[&str] = art!("icons/table"); diff --git a/crates/snowbound/src/attachment.rs b/crates/snowbound/src/attachment.rs index bfe8f3e9f8d47e8cdaf0ebcf98e6c14d37387ab4..a6faeb23d99707d241c27c3606f026bb62ee6058 100644 --- a/crates/snowbound/src/attachment.rs +++ b/crates/snowbound/src/attachment.rs @@ -35,19 +35,10 @@ impl State { preview: platform::file_icon(path).map(Into::into), recording: crate::recording::attached(name, &bytes), bytes: Some(bytes.into()), + tags: Vec::new(), }; let response = match at { - Some(point) => { - let scale = self.ui.scale(); - let corner = self.ui.rect(crate::page()).unwrap_or_default(); - self.view.drop_attachment( - [ - (point[0] - corner[0]) * scale, - (point[1] - corner[1]) * scale, - ], - file, - )? - } + Some(point) => self.view.drop_attachment(self.page_point(point), file)?, None => self.view.insert_attachment(file)?, }; self.respond(response); diff --git a/crates/snowbound/src/commands.rs b/crates/snowbound/src/commands.rs index f4413b09a2c528012ced184620cd63578e6096e0..42ce6b730edd494d8b9aa0acdd5f724d9eb09e1a 100644 --- a/crates/snowbound/src/commands.rs +++ b/crates/snowbound/src/commands.rs @@ -7,7 +7,11 @@ use canvas::editor::{Alignment, FormatState, Formatting, ListStyle, Pen, Toggle} use canvas::interaction::{Request, ink::Tool}; use draw::edit::{Key, Modifiers, NamedKey, Platform}; use onestore::page::ink::ShapeKind; -use std::{error::Error, sync::Arc}; +use std::{ + collections::BTreeMap, + error::Error, + sync::{Arc, PoisonError, RwLock, RwLockReadGuard}, +}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Id { @@ -24,7 +28,6 @@ pub enum Id { CopyPageLink, ShowNotebook, ExportPdf, - ExportSectionPdf, Print, Undo, Redo, @@ -84,6 +87,12 @@ pub enum Id { Indent, Outdent, ClearFormatting, + /// The style at this place in OneNote's Styles gallery (`notebook::sidecar::themes::STYLES`). + Style(usize), + /// The Styles gallery. + Styles, + /// The Themes dialog, assigning a theme to the page, its section or its notebook. + Theme(crate::themes::Scope), /// The tag at this place in the user's tag list. Tag(usize), CustomizeTags, @@ -240,7 +249,6 @@ pub const COMMANDS: &[Command] = &[ NONE, ), row!(Id::ExportPdf, "Export as PDF…", NONE, NONE), - row!(Id::ExportSectionPdf, "Export Section as PDF…", NONE, NONE), // Go to takes OneNote's Ctrl+P, and Pause its Ctrl+Alt+P. row!( Id::Print, @@ -307,7 +315,8 @@ pub const COMMANDS: &[Command] = &[ &[cmd('0')], &[cmd('0').option()] ), - row!(Id::Sidebar, "Notebook List", &[cmd('s').control()], NONE), + // Sidebars toggle on Command-Backslash, as in Notion; OneNote 2010 leaves Ctrl+\ free. + row!(Id::Sidebar, "Notebook List", &[cmd('\\')], &[cmd('\\')]), row!(Id::PageList, "Page List", NONE, NONE), row!(Id::PagesMatchTheme, "Pages Match UI Theme", NONE, NONE), row!(Id::HideSpelling, "Hide Spelling Errors", NONE, NONE), @@ -473,6 +482,67 @@ pub const COMMANDS: &[Command] = &[ &[cmd('n').shift()], &[cmd('n').shift()] ), + row!(Id::Styles, "Styles", NONE, NONE), + // OneNote 2010's Ctrl+Alt+1 to 6; OneNote for Mac's Option-Command ones. + row!( + Id::Style(0), + "Heading 1", + &[cmd('1').option()], + &[cmd('1').option()] + ), + row!( + Id::Style(1), + "Heading 2", + &[cmd('2').option()], + &[cmd('2').option()] + ), + row!( + Id::Style(2), + "Heading 3", + &[cmd('3').option()], + &[cmd('3').option()] + ), + row!( + Id::Style(3), + "Heading 4", + &[cmd('4').option()], + &[cmd('4').option()] + ), + row!( + Id::Style(4), + "Heading 5", + &[cmd('5').option()], + &[cmd('5').option()] + ), + row!( + Id::Style(5), + "Heading 6", + &[cmd('6').option()], + &[cmd('6').option()] + ), + row!(Id::Style(6), "Page Title", NONE, NONE), + row!(Id::Style(7), "Citation", NONE, NONE), + row!(Id::Style(8), "Quote", NONE, NONE), + row!(Id::Style(9), "Code", NONE, NONE), + row!(Id::Style(10), "Normal", NONE, NONE), + row!( + Id::Theme(crate::themes::Scope::Page), + "Page Theme…", + NONE, + NONE + ), + row!( + Id::Theme(crate::themes::Scope::Section), + "Section Theme…", + NONE, + NONE + ), + row!( + Id::Theme(crate::themes::Scope::Notebook), + "Notebook Theme…", + NONE, + NONE + ), row!(Id::CustomizeTags, "Customize Tags…", NONE, NONE), row!( Id::RemoveTags, @@ -499,6 +569,7 @@ pub fn command(id: Id) -> &'static Command { } impl Command { + /// The table's chords on `platform`, which the user's may replace. pub fn chords(&self, platform: Platform) -> &'static [Chord] { match platform { Platform::MacOs => self.mac, @@ -555,35 +626,118 @@ pub fn index(choice: &Choice) -> usize { .expect("Menus offer listed choices") } +/// The chords set in Options' Keyboard here, each list in place of its command's defaults. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Keymap(Vec<(Id, Vec)>); + +/// The keymap the keyboard, menus, palette and tooltips follow. +static KEYMAP: RwLock = RwLock::new(Keymap(Vec::new())); + +fn keymap() -> RwLockReadGuard<'static, Keymap> { + KEYMAP.read().unwrap_or_else(PoisonError::into_inner) +} + +impl Keymap { + /// The keymap in use. + pub fn current() -> Self { + keymap().clone() + } + + /// Puts `self` in use, reporting whether it differs from the one it replaces. + pub fn install(self) -> bool { + let mut installed = KEYMAP.write().unwrap_or_else(PoisonError::into_inner); + let changed = *installed != self; + *installed = self; + changed + } + + /// Command `id`'s chords on `platform`: the user's, or the table's. + pub fn chords(&self, id: Id, platform: Platform) -> &[Chord] { + self.0 + .iter() + .find(|(set, _)| *set == id) + .map_or_else(|| command(id).chords(platform), |(_, chords)| chords) + } + + /// Gives command `id` `chords` here, forgetting them where they are its defaults. + pub fn set(&mut self, id: Id, chords: Vec) { + self.0.retain(|(set, _)| *set != id); + if chords != command(id).chords(Platform::CURRENT) { + self.0.push((id, chords)); + } + } + + pub fn customized(&self, id: Id) -> bool { + self.0.iter().any(|(set, _)| *set == id) + } + + /// The command `chord` runs on `platform`. + pub fn ran(&self, chord: Chord, platform: Platform) -> Option { + COMMANDS + .iter() + .find(|command| self.chords(command.id, platform).contains(&chord)) + .map(|command| command.id) + .or_else(|| { + (0..9) + .find(|&place| self.tag_chord(place, platform) == Some(chord)) + .map(Id::Tag) + }) + } + + /// The chord applying the tag at `place` in the list: the first nine take Ctrl+1 to + /// Ctrl+9, as OneNote's do, but for one a command was given. + fn tag_chord(&self, place: usize, platform: Platform) -> Option { + let digit = u8::try_from(place).ok().filter(|place| *place < 9)?; + let chord = cmd(char::from(b'1' + digit)); + let taken = COMMANDS + .iter() + .any(|command| self.chords(command.id, platform).contains(&chord)); + (!taken).then_some(chord) + } + + /// As settings keep it: each set command's chords by its name in the table. + pub fn saved(&self) -> BTreeMap> { + (self.0.iter()) + .map(|(id, chords)| (format!("{id:?}"), chords.iter().map(Chord::saved).collect())) + .collect() + } + + /// The keymap settings kept, without commands or chords this build no longer has. + pub fn from_saved(saved: &BTreeMap>) -> Self { + Self( + COMMANDS + .iter() + .filter_map(|command| { + let chords = saved.get(&format!("{:?}", command.id))?; + let chords = chords.iter().filter_map(|chord| Chord::read(chord)); + Some((command.id, chords.collect())) + }) + .collect(), + ) + } +} + /// The command `key` with `modifiers` runs here. pub fn find(key: &Key, modifiers: Modifiers) -> Option { - ran( - pressed(key, modifiers, Platform::CURRENT)?, - Platform::CURRENT, - ) + keymap().ran(pressed(key, modifiers)?, Platform::CURRENT) } -/// The command `chord` runs on `platform`. -fn ran(chord: Chord, platform: Platform) -> Option { - COMMANDS - .iter() - .find(|command| command.chords(platform).contains(&chord)) - .map(|command| command.id) - .or_else(|| { - (0..9) - .find(|&place| tag_chord(place) == Some(chord)) - .map(Id::Tag) - }) +/// Command `id`'s chords here. +pub fn chords(id: Id) -> Vec { + keymap().chords(id, Platform::CURRENT).to_vec() } -/// The chord applying the tag at `place` in the list: the first nine take Ctrl+1 to Ctrl+9, -/// as OneNote's do. +/// The chord applying the tag at `place` in the list here. pub fn tag_chord(place: usize) -> Option { - let digit = u8::try_from(place).ok().filter(|place| *place < 9)?; - Some(cmd(char::from(b'1' + digit))) + keymap().tag_chord(place, Platform::CURRENT) } -fn pressed(key: &Key, modifiers: Modifiers, platform: Platform) -> Option { +/// The chord `key` with `modifiers` makes here. +pub fn pressed(key: &Key, modifiers: Modifiers) -> Option { + chord_on(key, modifiers, Platform::CURRENT) +} + +fn chord_on(key: &Key, modifiers: Modifiers, platform: Platform) -> Option { let (key, shifted) = match key { Key::Named(named) => (Press::Named(*named), false), Key::Character(text) => { @@ -630,6 +784,49 @@ const SHIFTED: [(char, char); 21] = [ ('(', '9'), ]; +/// The named keys a chord may take: AppKit's key equivalent for each, and how macOS's and +/// other desktops' menus show it. +const NAMED: [(NamedKey, char, &str, &str); 15] = [ + (NamedKey::Space, ' ', "Space", "Space"), + (NamedKey::Tab, '\t', "⇥", "Tab"), + (NamedKey::Enter, '\r', "↩", "Enter"), + (NamedKey::Backspace, '\u{8}', "⌫", "Backspace"), + (NamedKey::Delete, '\u{f728}', "⌦", "Delete"), + (NamedKey::ArrowLeft, '\u{f702}', "←", "Left"), + (NamedKey::ArrowRight, '\u{f703}', "→", "Right"), + (NamedKey::ArrowUp, '\u{f700}', "↑", "Up"), + (NamedKey::ArrowDown, '\u{f701}', "↓", "Down"), + (NamedKey::Home, '\u{f729}', "↖", "Home"), + (NamedKey::End, '\u{f72b}', "↘", "End"), + (NamedKey::PageUp, '\u{f72c}', "⇞", "Page Up"), + (NamedKey::PageDown, '\u{f72d}', "⇟", "Page Down"), + (NamedKey::F7, '\u{f70a}', "F7", "F7"), + (NamedKey::F11, '\u{f70e}', "F11", "F11"), +]; + +/// The chords AppKit's own menu items take on macOS, which no command may. +const APPKIT: [(Chord, &str); 5] = [ + (cmd('q'), "Quit Snowbound"), + (cmd('h'), "Hide Snowbound"), + (cmd('h').option(), "Hide Others"), + (cmd('m'), "Minimize"), + ( + named(NamedKey::Space).command().control(), + "Emoji & Symbols", + ), +]; + +/// Why a chord can't run a command. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Unusable { + /// A key no chord takes, or a character typed without a shortcut modifier. + Typing, + /// Moves or deletes text, as `draw::edit` reads it, and no command takes it by default. + Editing, + /// The system's: AppKit's item of this title. + System(&'static str), +} + impl Chord { /// The key AppKit matches and shows: a shifted symbol stands for Shift with its key. pub fn equivalent(self) -> (String, bool) { @@ -641,26 +838,27 @@ impl Chord { }) } Press::Char(base) => (base.to_string(), self.shift), - Press::Named(NamedKey::Space) => (" ".to_owned(), self.shift), - Press::Named(NamedKey::ArrowLeft) => ('\u{f702}'.to_string(), self.shift), - Press::Named(NamedKey::ArrowRight) => ('\u{f703}'.to_string(), self.shift), - // AppKit's NSF7FunctionKey. - Press::Named(NamedKey::F7) => ('\u{f70a}'.to_string(), self.shift), - Press::Named(named) => unreachable!("No command takes {named:?}"), + Press::Named(named) => ( + NAMED + .iter() + .find(|(listed, ..)| *listed == named) + .map_or_else(String::new, |(_, key, ..)| key.to_string()), + self.shift, + ), } } /// As a menu shows it on `platform`: ⌃⌥⇧⌘K on macOS, Ctrl+Alt+Shift+K elsewhere. pub fn label(self, platform: Platform) -> String { + let named = |pick: fn(&(NamedKey, char, &'static str, &'static str)) -> &'static str| { + let Press::Named(named) = self.key else { + return None; + }; + NAMED.iter().find(|(listed, ..)| *listed == named).map(pick) + }; if platform == Platform::MacOs { let (key, shift) = self.equivalent(); - let key = match self.key { - Press::Named(NamedKey::Space) => "Space".to_owned(), - Press::Named(NamedKey::ArrowLeft) => "←".to_owned(), - Press::Named(NamedKey::ArrowRight) => "→".to_owned(), - Press::Named(NamedKey::F7) => "F7".to_owned(), - _ => key.to_uppercase(), - }; + let key = named(|(.., mac, _)| mac).map_or_else(|| key.to_uppercase(), str::to_owned); [ (self.control, "⌃"), (self.option, "⌥"), @@ -675,9 +873,9 @@ impl Chord { } else { let key = match self.key { Press::Char(key) => key.to_uppercase().to_string(), - Press::Named(NamedKey::ArrowLeft) => "Left".to_owned(), - Press::Named(NamedKey::ArrowRight) => "Right".to_owned(), - Press::Named(named) => format!("{named:?}"), + Press::Named(key) => { + named(|(.., pc)| pc).map_or_else(|| format!("{key:?}"), str::to_owned) + } }; [ (self.command, "Ctrl+"), @@ -691,13 +889,92 @@ impl Chord { .collect() } } + + /// Why the chord can't be given a command on `platform`, if it can't. + pub fn unusable(self, platform: Platform) -> Option { + let modified = self.command || self.option || self.control; + let mac = platform == Platform::MacOs; + let typed = match self.key { + Press::Char(_) => !(self.command || self.control || !mac && self.option), + Press::Named(NamedKey::F7 | NamedKey::F11) => false, + Press::Named(named) => !modified || !NAMED.iter().any(|(listed, ..)| *listed == named), + }; + let key = match self.key { + Press::Char(key) => Key::Character(key.to_string()), + Press::Named(named) => Key::Named(named), + }; + let modifiers = Modifiers { + shift: self.shift, + option: self.option, + // Off macOS Control is the shortcut modifier, and comes with it. + control: self.control || !mac && self.command, + command: self.command, + }; + if typed { + Some(Unusable::Typing) + } else if platform.command(&key, modifiers).is_some() + // The table's own chords already take theirs from text editing. + && !(COMMANDS.iter()).any(|command| command.chords(platform).contains(&self)) + { + Some(Unusable::Editing) + } else { + (APPKIT.iter()) + .find(|(chord, _)| mac && *chord == self) + .map(|(_, title)| Unusable::System(title)) + } + } + + /// As settings keep it, Command+Shift+K: the modifiers and the unshifted key, named. + fn saved(&self) -> String { + let key = match self.key { + Press::Char(key) => key.to_string(), + Press::Named(named) => format!("{named:?}"), + }; + [ + (self.control, "Control+"), + (self.option, "Option+"), + (self.shift, "Shift+"), + (self.command, "Command+"), + ] + .iter() + .filter(|(held, _)| *held) + .map(|(_, modifier)| *modifier) + .chain([key.as_str()]) + .collect() + } + + /// The chord `saved` wrote as `text`. + fn read(text: &str) -> Option { + let (modifiers, key) = text.rsplit_once('+').unwrap_or(("", text)); + let mut chars = key.chars(); + let key = match (chars.next(), chars.next()) { + (Some(key), None) => Press::Char(key), + _ => Press::Named( + NAMED + .iter() + .map(|(named, ..)| *named) + .find(|named| format!("{named:?}") == key)?, + ), + }; + let mut chord = press(key); + for modifier in modifiers.split('+').filter(|modifier| !modifier.is_empty()) { + match modifier { + "Control" => chord.control = true, + "Option" => chord.option = true, + "Shift" => chord.shift = true, + "Command" => chord.command = true, + _ => return None, + } + } + Some(chord) + } } /// The chord a menu or hint shows for `id` here, empty where it has none. pub fn shortcut(id: Id) -> String { let chord = match id { Id::Tag(place) => tag_chord(place), - id => command(id).chords(Platform::CURRENT).first().copied(), + id => chords(id).first().copied(), }; chord.map_or_else(String::new, |chord| chord.label(Platform::CURRENT)) } @@ -722,11 +999,20 @@ impl State { self.window.request_redraw(); } - /// Every choice's status, in `choices` order, for the menu bar. + /// Every choice's status, in `choices` order, for the menu bar. Link gives up its chord, + /// ⌘K, to the open palette's actions. pub(crate) fn statuses(&self) -> Vec { + // A chord Options records reaches it past the menu bar's disabled items. + if self.recording_chord() { + return vec![Status::default(); choices().count()]; + } let format = self.format_state(); + let palette = self.ui.popup_open(crate::palette::id()); choices() - .map(|choice| self.status(&choice, &format)) + .map(|choice| match choice { + Choice::Command(Id::Link) if palette => Status::default(), + choice => self.status(&choice, &format), + }) .collect() } @@ -734,6 +1020,8 @@ impl State { let session = self.session.as_ref(); let welcome = session.is_none() && !self.temporary && self.sectionless.is_none(); let modal = self.options.is_some() + || self.themes.is_some() + || self.printing.open() || self.link.is_some() || self.tag_list.is_some() || self.server.is_some(); @@ -742,6 +1030,14 @@ impl State { // Picked drawings leave the text selection behind them unseen, so it takes no edits. let typing = self.view.accepts_text() && self.view.ink_selection().is_empty(); let text = writable && typing; + let tagged = writable + && matches!( + self.view.object_focus(), + Some( + canvas::interaction::ObjectFocus::Image(_) + | canvas::interaction::ObjectFocus::File(_) + ) + ); // Edit commands act on a focused field instead of the page. let field = self.ui.focused_field().is_some(); let [anchor, focus] = self.view.editor.selection().positions; @@ -793,7 +1089,6 @@ impl State { | Id::Find | Id::Search | Id::ExportPdf - | Id::ExportSectionPdf | Id::Print => enabled(!modal && session.is_some()), Id::PageVersions => session.filter(|_| !modal).map_or( Status { @@ -809,8 +1104,7 @@ impl State { enabled: !welcome && !modal, checked: Some(self.full_page), }, - Id::Undo => enabled(writable && !field && self.view.editor.can_undo()), - Id::Redo => enabled(writable && !field && self.view.editor.can_redo()), + Id::Undo | Id::Redo => enabled(writable && !field && self.can_step(id == Id::Redo)), Id::Cut => enabled(writable && selected), Id::Copy => enabled(selected), Id::Paste => enabled(text && !field), @@ -903,13 +1197,25 @@ impl State { | Id::Indent | Id::Outdent | Id::ClearFormatting - | Id::RemoveTags => enabled(text), + | Id::Styles => enabled(text), + Id::Style(place) => checked( + format.style.as_deref() + == notebook::sidecar::themes::STYLES + .get(place) + .map(|(name, _)| *name), + ), + Id::Theme(scope) => enabled(!modal && self.theme_target(scope).is_some()), + // A selected picture or file takes tags too, as in OneNote. + Id::RemoveTags => enabled(text || tagged), Id::Toggle(toggle) => checked(format.toggles.contains(&toggle)), Id::Bullets => checked(format.bullets), Id::Numbering => checked(format.numbering), Id::Align(alignment) => checked(format.alignment == Some(alignment)), Id::Tag(place) => match self.tags.get(place) { - Some(tag) => checked(format.tags.contains(&(tag.stored(), place as u16))), + Some(tag) => Status { + enabled: text || tagged, + checked: Some(format.tags.contains(&(tag.stored(), place as u16))), + }, None => Status::default(), }, } @@ -1032,19 +1338,11 @@ impl State { platform::reveal(&self.notebook().ok_or("No notebook is open")?.location); return Ok(()); } - Id::ExportPdf | Id::ExportSectionPdf | Id::Print => { - let scope = if id == Id::ExportSectionPdf { - crate::print::Scope::Section - } else { - crate::print::Scope::Page - }; - return self.print(scope, id != Id::Print); - } - Id::Undo | Id::Redo => { - let response = self.view.undo(id == Id::Redo)?; - self.respond(response); + Id::ExportPdf | Id::Print => { + self.open_print(id == Id::ExportPdf); return Ok(()); } + Id::Undo | Id::Redo => return self.step(id == Id::Redo), Id::Cut | Id::Copy => { let response = self.view.copy(id == Id::Cut)?; self.respond(response); @@ -1133,7 +1431,7 @@ impl State { else { return Ok(()); }; - return self.insert_picture(std::fs::read(path)?); + return self.insert_picture(std::fs::read(path)?, None); } Id::Attachment => { let Some(path) = platform::pick_file("Attach File", &[]) else { @@ -1227,7 +1525,27 @@ impl State { Id::Align(alignment) => return format(self, Formatting::Align(alignment)), Id::Indent => return format(self, Formatting::Indent), Id::Outdent => return format(self, Formatting::Outdent), - Id::ClearFormatting => return format(self, Formatting::Clear), + // Clear Formatting at a caret makes the paragraph Normal, as OneNote 2010's + // Ctrl+Shift+N does; over a selection it clears the characters' formatting. + Id::ClearFormatting => { + let [anchor, focus] = self.view.editor.selection().positions; + if anchor == focus { + return format(self, Formatting::Style(self.gallery_style("p"))); + } + return format(self, Formatting::Clear); + } + Id::Style(place) => { + let (name, _) = notebook::sidecar::themes::STYLES[place]; + return format(self, Formatting::Style(self.gallery_style(name))); + } + Id::Styles => { + self.ui.open_popup(crate::toolbar_popup("styles")); + return Ok(()); + } + Id::Theme(scope) => { + self.open_themes(scope); + return Ok(()); + } Id::Tag(place) => { let tag = self.tags[place].clone(); format(self, Formatting::Tag(tag, place as u16))?; @@ -1315,8 +1633,8 @@ mod tests { control, command, }; - ran( - pressed(&Key::Character(key.into()), modifiers, platform)?, + Keymap::default().ran( + chord_on(&Key::Character(key.into()), modifiers, platform)?, platform, ) }; @@ -1371,7 +1689,7 @@ mod tests { find(gtk, "y", false, false, true, true), cfg!(windows).then_some(Id::Redo) ); - let indent = pressed( + let indent = chord_on( &Key::Named(NamedKey::ArrowRight), Modifiers { shift: true, @@ -1408,6 +1726,72 @@ mod tests { assert_eq!(label(Id::Date, Platform::Windows), "Alt+Shift+D"); } + #[test] + fn keymaps_keep_chords_by_command_and_drop_the_unknown() { + let mut keymap = Keymap::default(); + let chord = named(NamedKey::PageDown).command().option().shift(); + keymap.set(Id::Sidebar, vec![chord, cmd('\\').control()]); + keymap.set( + Id::Toggle(Toggle::Bold), + command(Id::Toggle(Toggle::Bold)) + .chords(Platform::CURRENT) + .to_vec(), + ); + let mut saved = keymap.saved(); + assert_eq!( + saved["Sidebar"], + ["Option+Shift+Command+PageDown", "Control+Command+\\"] + ); + assert!(!saved.contains_key("Toggle(Bold)"), "defaults are not kept"); + saved.insert("Retired".into(), vec!["Command+J".into()]); + saved.insert( + "PageList".into(), + vec!["Command+Hyper+J".into(), "Command+j".into()], + ); + let read = Keymap::from_saved(&saved); + assert_eq!( + read.chords(Id::Sidebar, Platform::CURRENT), + [chord, cmd('\\').control()] + ); + assert_eq!(read.chords(Id::PageList, Platform::CURRENT), [cmd('j')]); + } + + #[test] + fn every_default_chord_is_one_a_user_could_record() { + for platform in PLATFORMS { + for command in COMMANDS { + for chord in command.chords(platform) { + assert_eq!( + chord.unusable(platform), + None, + "{platform:?}: {} for {}", + chord.label(platform), + command.title + ); + } + } + } + assert_eq!(key('j').unusable(Platform::MacOs), Some(Unusable::Typing)); + assert_eq!( + cmd('q').unusable(Platform::MacOs), + Some(Unusable::System("Quit Snowbound")) + ); + assert_eq!(cmd('q').unusable(Platform::Gtk), None); + assert_eq!( + named(NamedKey::ArrowLeft) + .command() + .unusable(Platform::MacOs), + Some(Unusable::Editing) + ); + assert_eq!( + command(Id::Sidebar).chords(Platform::MacOs)[0].label(Platform::MacOs), + "⌘\\" + ); + assert_eq!( + command(Id::Sidebar).chords(Platform::Windows)[0].label(Platform::Windows), + "Ctrl+\\" + ); + } /// Each toolbar button chooses a command or a list's entry, so it runs, enables and /// checks as the menu bar and keyboard do. #[test] diff --git a/crates/snowbound/src/conflict_render.rs b/crates/snowbound/src/conflict_render.rs index be2fc6fed4d5115b0caac248ed405fc861d58db2..d0605d76e734b2328541b237b7b35b105ea755e2 100644 --- a/crates/snowbound/src/conflict_render.rs +++ b/crates/snowbound/src/conflict_render.rs @@ -65,7 +65,7 @@ fn layout(ui: &mut Ui, session: &Session, menu: bool) -> Vec { §ion, session, &HashSet::new(), - 0.0, + (0.0, false), None, None, ); diff --git a/crates/snowbound/src/desktop_linux.rs b/crates/snowbound/src/desktop_linux.rs index 0217033fcbb06960ff67e094435de1c7b9e6f53b..8ef12227f170e360ec127dd9eadb2bcc9bd733f8 100644 --- a/crates/snowbound/src/desktop_linux.rs +++ b/crates/snowbound/src/desktop_linux.rs @@ -36,6 +36,8 @@ use winit::{ /// Wayland's app ID and X11's WM_CLASS, which name the desktop entry and the icon. pub const APP_ID: &str = "net.paperclover.snowbound"; const TEMPLATE: &str = include_str!("../linux/snowbound.desktop"); +/// The type of `.one` and `.onetoc2` files, which no shared-mime-info release names. +const MIME_TYPES: &str = include_str!("../linux/onenote.xml"); /// 512 pixels square; the smaller sizes are scaled from it. const ICON: &[u8] = include_bytes!("../linux/snowbound.png"); const SIZES: [u32; 8] = [16, 24, 32, 48, 64, 128, 256, 512]; @@ -75,6 +77,10 @@ fn entry(data: &Path) -> PathBuf { data.join(format!("applications/{APP_ID}.desktop")) } +fn mime_types(data: &Path) -> PathBuf { + data.join(format!("mime/packages/{APP_ID}.xml")) +} + fn theme_icon(data: &Path, side: u32) -> PathBuf { data.join(format!("icons/hicolor/{side}x{side}/apps/{APP_ID}.png")) } @@ -163,7 +169,12 @@ fn entry_text(exec: &Path, icon: &str) -> String { TEMPLATE .lines() .map(|line| match line.split_once('=') { - Some(("Exec", _)) => format!("Exec={}\n", quote(exec)), + Some(("Exec", command)) => { + let arguments = command + .split_once(' ') + .map_or("", |(_, arguments)| arguments); + format!("Exec={} {arguments}\n", quote(exec)) + } Some(("Icon", _)) => format!("Icon={icon}\n"), _ => format!("{line}\n"), }) @@ -339,7 +350,7 @@ pub fn set_toplevel_icon(window: &Window) -> Option<()> { } /// Copies this executable to `~/.local/bin` unless it runs from there, adds Snowbound to the -/// app menu, and says where Uninstall is. +/// app menu and to the apps opening OneNote's files, and says where Uninstall is. pub fn install() { match try_install() { Ok(()) => { @@ -377,10 +388,13 @@ fn try_install() -> io::Result<()> { .write_header()? .write_image_data(&scaled(&icon, side))?; } + let types = mime_types(&data); + fs::create_dir_all(types.parent().ok_or(io::ErrorKind::NotFound)?)?; + fs::write(types, MIME_TYPES)?; // Replaces this run's hidden entry, which exiting then leaves alone. write_entry(&entry(&data), &entry_text(&binary, APP_ID))?; let _ = runtime_icon().map(fs::remove_file); - refresh_icon_cache(&data); + refresh_caches(&data); Ok(()) } @@ -398,7 +412,10 @@ pub fn uninstall() { } let mut failed = None; let icons = SIZES.map(|side| theme_icon(&data, side)); - for path in [entry(&data), binary].iter().chain(&icons) { + for path in [entry(&data), mime_types(&data), binary] + .iter() + .chain(&icons) + { if let Err(error) = fs::remove_file(path) && error.kind() != io::ErrorKind::NotFound { @@ -411,21 +428,27 @@ pub fn uninstall() { let _ = fs::remove_dir(folder); } } - refresh_icon_cache(&data); + refresh_caches(&data); match failed { Some(error) => crate::platform::alert("Couldn't uninstall Snowbound", &error.to_string()), None => *INSTALLED.lock().unwrap() = Installed::No, } } -/// Brings the icon theme's cache up to date where the user keeps one. -fn refresh_icon_cache(data: &Path) { - let _ = Command::new("gtk-update-icon-cache") - .arg("-q") - .arg(data.join("icons/hicolor")) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(); +/// Brings the icon theme's cache, the file types and the apps opening them up to date, where +/// the desktop has the tools. +fn refresh_caches(data: &Path) { + for (tool, folder) in [ + ("gtk-update-icon-cache", "icons/hicolor"), + ("update-mime-database", "mime"), + ("update-desktop-database", "applications"), + ] { + let _ = Command::new(tool) + .arg(data.join(folder)) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } } #[cfg(test)] @@ -444,7 +467,7 @@ mod tests { #[test] fn only_the_hidden_entry_names_an_owner() { let installed = entry_text(Path::new("/bin/snowbound"), APP_ID); - assert!(installed.contains("\nExec=\"/bin/snowbound\"\n")); + assert!(installed.contains("\nExec=\"/bin/snowbound\" %F\n")); assert!(installed.contains(&format!("\nIcon={APP_ID}\n"))); assert_eq!(portable_owner(&installed), None); let hidden = installed + &format!("NoDisplay=true\n{PORTABLE}=42\n"); diff --git a/crates/snowbound/src/icloud_linux.rs b/crates/snowbound/src/icloud_linux.rs index f55457a7e772261ccdb5564884772f845d9086b7..c195b985b382f0fe78fe86c4426e0dd5aedf503f 100644 --- a/crates/snowbound/src/icloud_linux.rs +++ b/crates/snowbound/src/icloud_linux.rs @@ -11,7 +11,11 @@ pub fn folder() -> Option { None } -pub fn look_up(_: impl Fn() + Send + 'static) {} +pub fn look_up(_: impl Fn() + Send + Sync + 'static) {} + +pub fn download(_: &Path) -> usize { + 0 +} pub fn drive() -> Option { None @@ -19,6 +23,7 @@ pub fn drive() -> Option { pub fn section( notebook: &Notebook, + _: &Path, path: &str, notify: impl Fn() + Send + 'static, ) -> Result { diff --git a/crates/snowbound/src/icloud_macos.rs b/crates/snowbound/src/icloud_macos.rs index bcf81e177f9a3bde2b25ebee5dbcd69d11ea36e5..b02b53b9533ccd82b990fe91f869b09a29f34c31 100644 --- a/crates/snowbound/src/icloud_macos.rs +++ b/crates/snowbound/src/icloud_macos.rs @@ -77,29 +77,45 @@ pub fn ubiquitous(path: &Path) -> bool { }) } -/// The app's own folder in iCloud Drive as `look_up` last found it. -static FOLDER: Mutex> = Mutex::new(None); +/// The app's own folder in iCloud Drive as `look_up` last found it, and what reports its +/// notebooks coming and going. +static FOLDER: Mutex)>> = Mutex::new(None); /// The app's own folder in iCloud Drive (its container's Documents, shown as iCloud Drive's /// Snowbound folder), as `look_up` last found it: while the app carries the container's /// entitlement and iCloud Drive is on. pub fn folder() -> Option { - FOLDER.lock().ok()?.clone() + Some(FOLDER.lock().ok()?.as_ref()?.0.clone()) } /// Looks for the app's own folder in iCloud Drive on a thread of its own, as asking the -/// iCloud daemon can take a while, then calls `done`. -pub fn look_up(done: impl Fn() + Send + 'static) { +/// iCloud daemon can take a while, then calls `changed`, and again whenever a folder at its +/// top comes or goes, as another device adds or removes a notebook. +pub fn look_up(changed: impl Fn() + Send + Sync + 'static) { std::thread::spawn(move || { - let found = container(); + let changed = std::sync::Arc::new(changed); + let found = container().map(|root| { + let report = std::sync::Arc::clone(&changed); + let presenter = presenter(&root, move |paths| { + if paths.iter().any(|path| !path.contains('/')) { + report(); + } + }); + (root, presenter) + }); if let Ok(mut folder) = FOLDER.lock() { *folder = found; } - done(); + changed(); }); } +/// The container's Documents, or for trying the app out without its entitlement, the folder +/// `SNOWBOUND_ICLOUD_FOLDER` names in its place. fn container() -> Option { + if let Some(folder) = std::env::var_os("SNOWBOUND_ICLOUD_FOLDER") { + return Some(folder.into()); + } if !available() { return None; } @@ -183,6 +199,43 @@ fn present(path: &Path) -> io::Result<()> { )) } +/// Asks iCloud Drive for every file below `root` it keeps elsewhere, dataless or as a +/// `.Name.icloud` placeholder: how many are not here yet. +pub fn download(root: &Path) -> usize { + use std::os::macos::fs::MetadataExt; + let manager = unsafe { NSFileManager::defaultManager() }; + let mut missing = 0; + let mut folders = vec![root.to_owned()]; + while let Some(folder) = folders.pop() { + for entry in std::fs::read_dir(&folder).into_iter().flatten().flatten() { + let (path, name) = (entry.path(), entry.file_name()); + let Ok(metadata) = entry.metadata() else { + continue; + }; + if metadata.is_dir() { + folders.push(path); + continue; + } + let placeholder = name + .to_str() + .and_then(|name| name.strip_prefix('.')?.strip_suffix(".icloud")); + let file = match placeholder { + Some(real) => path.with_file_name(real), + None if metadata.st_flags() & SF_DATALESS != 0 => path, + None => continue, + }; + missing += 1; + if let Some(url) = url(&file) { + let _ = unsafe { manager.startDownloadingUbiquitousItemAtURL_error(&url) }; + } + } + } + missing +} + +/// `st_flags` of a file whose contents the system keeps elsewhere until read. +const SF_DATALESS: u32 = 0x4000_0000; + /// A section file in iCloud Drive, read and published under file coordination, with the /// conflict versions iCloud keeps beside it. pub struct Coordinated(pub PathBuf); @@ -303,23 +356,39 @@ impl Remote for Coordinated { } } -/// Opens the section at catalog `path` of a notebook in iCloud Drive. +/// Opens the section at catalog `path` of the notebook in iCloud Drive's folder `root`. One +/// without a replica here yet waits for iCloud Drive to bring its file down, which the sync +/// thread's reads would not. pub fn section( notebook: &Notebook, + root: &Path, path: &str, notify: impl Fn() + Send + 'static, ) -> Result { + if !notebook.replica_path(path)?.exists() { + fetch(&root.join(path))?; + } let section = notebook.section_with(path, |file| Ok(Coordinated(file.to_owned())), notify)?; section.set_pause(PAUSE); Ok(section) } -/// Opens a section file in iCloud Drive on its own. +/// Waits for iCloud Drive to bring down `file`, where it keeps it elsewhere. +fn fetch(file: &Path) -> io::Result<()> { + // Reading any of a dataless file brings all of it down. + coordinated(file, false, || { + use std::io::Read; + std::fs::File::open(file)?.read(&mut [0]).map(drop) + })? +} + +/// Opens a section file in iCloud Drive on its own, once here. pub fn lone_section( file: &Path, cache: &Path, notify: impl Fn() + Send + 'static, ) -> Result { + fetch(file)?; let section = Section::open_with(file, cache, |file| Ok(Coordinated(file.to_owned())), notify)?; section.set_pause(PAUSE); Ok(section) @@ -385,6 +454,22 @@ declare_class!( self.report(url); } + #[method(presentedSubitemAtURL:didMoveToURL:)] + unsafe fn subitem_did_move(&self, from: &NSURL, to: &NSURL) { + self.report(from); + self.report(to); + } + + #[method(accommodatePresentedSubitemDeletionAtURL:completionHandler:)] + unsafe fn accommodate_subitem_deletion( + &self, + url: &NSURL, + done: &block2::Block, + ) { + self.report(url); + done.call((std::ptr::null_mut(),)); + } + #[method(presentedSubitemAtURL:didResolveConflictVersion:)] unsafe fn subitem_did_resolve_conflict_version( &self, @@ -491,7 +576,7 @@ mod tests { let notebook = Notebook::create(&root, &cache, Notebook::NEW_COLOR, &page).unwrap(); assert!(ubiquitous(&root)); let path = notebook.catalog().sections[0].path.clone(); - let section = section(¬ebook, &path, || {}).unwrap(); + let section = section(¬ebook, &root, &path, || {}).unwrap(); let (space, ..) = section.pages().unwrap()[0].clone(); let title = section .page(space) diff --git a/crates/snowbound/src/instance.rs b/crates/snowbound/src/instance.rs new file mode 100644 index 0000000000000000000000000000000000000000..e475fbfbecfb75c75883356597cb780918582114 --- /dev/null +++ b/crates/snowbound/src/instance.rs @@ -0,0 +1,247 @@ +//! One running app per cache folder on Windows and Linux. A second launch hands the files it +//! was asked to open to the first and exits, as Launch Services does for the macOS bundle: two +//! processes on one cache would wait on each other's section replicas. The first listens on a +//! named pipe on Windows and on a Unix socket in the cache folder on Linux. + +use std::path::{Path, PathBuf}; +use winit::event_loop::EventLoopProxy; + +/// The files a launch asks to open, separated by NULs, which no path holds. +fn encode(paths: &[PathBuf]) -> Vec { + let mut bytes = Vec::new(); + for path in paths { + bytes.extend(path_bytes(path)); + bytes.push(0); + } + bytes +} + +fn decode(bytes: &[u8]) -> Vec { + bytes + .split(|&byte| byte == 0) + .filter(|path| !path.is_empty()) + .map(bytes_path) + .collect() +} + +/// Opens what each later launch hands over, on a thread of its own. +fn serve( + proxy: EventLoopProxy, + mut accept: impl FnMut() -> Option + Send + 'static, +) { + std::thread::spawn(move || { + while let Some(mut connection) = accept() { + let mut bytes = Vec::new(); + if connection.read_to_end(&mut bytes).is_ok() { + let _ = proxy.send_event(crate::UserEvent::Open(decode(&bytes))); + } + } + }); +} + +#[cfg(unix)] +fn path_bytes(path: &Path) -> Vec { + use std::os::unix::ffi::OsStrExt; + path.as_os_str().as_bytes().to_vec() +} + +#[cfg(unix)] +fn bytes_path(bytes: &[u8]) -> PathBuf { + use std::os::unix::ffi::OsStrExt; + std::ffi::OsStr::from_bytes(bytes).into() +} + +#[cfg(windows)] +fn path_bytes(path: &Path) -> Vec { + path.to_string_lossy().into_owned().into_bytes() +} + +#[cfg(windows)] +fn bytes_path(bytes: &[u8]) -> PathBuf { + String::from_utf8_lossy(bytes).into_owned().into() +} + +#[cfg(target_os = "linux")] +pub use self::linux::*; + +#[cfg(target_os = "linux")] +mod linux { + use super::*; + use std::{ + io::{ErrorKind, Write}, + os::unix::net::{UnixListener, UnixStream}, + }; + + /// Listening, unless the socket couldn't be made. + pub struct Instance(Option); + + /// This launch as the running app for `cache`, or None once the running one took `paths`. + pub fn claim(cache: &Path, paths: &[PathBuf]) -> Option { + let socket = cache.join("instance.sock"); + for _ in 0..3 { + match UnixStream::connect(&socket) { + Ok(mut stream) => { + if stream.write_all(&encode(paths)).is_ok() { + return None; + } + } + // The socket of an app that ended without removing it. + Err(error) if error.kind() == ErrorKind::ConnectionRefused => { + let _ = std::fs::remove_file(&socket); + } + Err(_) => {} + } + let _ = std::fs::create_dir_all(cache); + match UnixListener::bind(&socket) { + Ok(listener) => return Some(Instance(Some(listener))), + // Another launch bound it first. + Err(error) if error.kind() == ErrorKind::AddrInUse => {} + Err(error) => { + eprintln!("Cannot listen at {}: {error}", socket.display()); + break; + } + } + } + Some(Instance(None)) + } + + impl Instance { + pub fn serve(self, proxy: EventLoopProxy) { + if let Some(listener) = self.0 { + super::serve(proxy, move || listener.incoming().find_map(Result::ok)); + } + } + } +} + +#[cfg(windows)] +pub use self::windows::*; + +#[cfg(windows)] +mod windows { + use super::*; + use std::{fs::File, io::Write, os::windows::io::FromRawHandle}; + use windows_sys::Win32::{ + Foundation::{ + ERROR_PIPE_BUSY, ERROR_PIPE_CONNECTED, GENERIC_WRITE, GetLastError, + INVALID_HANDLE_VALUE, + }, + Storage::FileSystem::{ + CreateFileW, FILE_FLAG_FIRST_PIPE_INSTANCE, OPEN_EXISTING, PIPE_ACCESS_INBOUND, + }, + System::Pipes::{ + ConnectNamedPipe, CreateNamedPipeW, PIPE_REJECT_REMOTE_CLIENTS, PIPE_TYPE_BYTE, + PIPE_UNLIMITED_INSTANCES, PIPE_WAIT, WaitNamedPipeW, + }, + UI::WindowsAndMessaging::{ASFW_ANY, AllowSetForegroundWindow}, + }; + + pub struct Instance { + name: Vec, + /// The pipe's first end, unless the name couldn't be claimed. + first: Option, + } + + /// The pipe named for `cache`, which a pipe's name may hold but for its backslashes. + fn pipe_name(cache: &Path) -> Vec { + let cache = cache.to_string_lossy().replace('\\', "/").to_lowercase(); + format!(r"\\.\pipe\Snowbound\{cache}") + .encode_utf16() + .chain([0]) + .collect() + } + + /// A new end of the pipe for the next launch to write to; the first claims the name. + fn listen(name: &[u16], first: bool) -> Option { + let flags = if first { + FILE_FLAG_FIRST_PIPE_INSTANCE + } else { + 0 + }; + let handle = unsafe { + CreateNamedPipeW( + name.as_ptr(), + PIPE_ACCESS_INBOUND | flags, + PIPE_TYPE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS, + PIPE_UNLIMITED_INSTANCES, + 0, + 64 * 1024, + 0, + std::ptr::null(), + ) + }; + (handle != INVALID_HANDLE_VALUE).then(|| unsafe { File::from_raw_handle(handle) }) + } + + /// This launch as the running app for `cache`, or None once the running one took `paths`. + pub fn claim(cache: &Path, paths: &[PathBuf]) -> Option { + let name = pipe_name(cache); + for _ in 0..3 { + let handle = unsafe { + CreateFileW( + name.as_ptr(), + GENERIC_WRITE, + 0, + std::ptr::null(), + OPEN_EXISTING, + 0, + std::ptr::null_mut(), + ) + }; + if handle != INVALID_HANDLE_VALUE { + // The running app may then bring its window to the front. + unsafe { AllowSetForegroundWindow(ASFW_ANY) }; + let mut pipe = unsafe { File::from_raw_handle(handle) }; + if pipe.write_all(&encode(paths)).is_ok() { + return None; + } + } else if unsafe { GetLastError() } == ERROR_PIPE_BUSY { + unsafe { WaitNamedPipeW(name.as_ptr(), 2000) }; + continue; + } + if let Some(first) = listen(&name, true) { + return Some(Instance { + name, + first: Some(first), + }); + } + } + eprintln!("Cannot reach the running Snowbound; opening another."); + Some(Instance { name, first: None }) + } + + impl Instance { + pub fn serve(self, proxy: EventLoopProxy) { + let Instance { name, first } = self; + let mut waiting = first; + super::serve(proxy, move || { + loop { + let pipe = waiting.take()?; + let handle = std::os::windows::io::AsRawHandle::as_raw_handle(&pipe); + let connected = unsafe { ConnectNamedPipe(handle, std::ptr::null_mut()) } != 0 + || unsafe { GetLastError() } == ERROR_PIPE_CONNECTED; + // The next end opens before this one closes, so the name never lapses. + waiting = listen(&name, false); + if connected { + return Some(pipe); + } + } + }); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn handed_paths_read_back() { + let paths = [ + PathBuf::from("/notes/Personal/Garden.one"), + PathBuf::from("/notes/a b/Open Notebook.onetoc2"), + ]; + assert_eq!(decode(&encode(&paths)), paths); + assert!(decode(&encode(&[])).is_empty()); + } +} diff --git a/crates/snowbound/src/keys.rs b/crates/snowbound/src/keys.rs new file mode 100644 index 0000000000000000000000000000000000000000..aaa39dc4df6d50f87fa80215ce8033156807bf7c --- /dev/null +++ b/crates/snowbound/src/keys.rs @@ -0,0 +1,534 @@ +//! Options' Keyboard: every command in the table under its menu with its chords, which a +//! click records anew, and which OK puts in use. + +use crate::commands::{self, COMMANDS, Chord, Id as Command, Keymap, Unusable}; +use crate::options::found; +use accesskit::Role; +use canvas::editor::Toggle; +use draw::edit::{Key, NamedKey, Platform}; +use ui::{Axis, Flags, Spec, Ui, children, fill, fit, px}; + +/// The menus the table's commands fall under, each from its first command on. +const GROUPS: [(&str, Command); 9] = [ + ("Snowbound", Command::Settings), + ("File", Command::NewNotebook), + ("Edit", Command::Undo), + ("View", Command::GoTo), + ("Insert", Command::Table), + ("Draw", Command::SelectType), + ("Format", Command::Toggle(Toggle::Bold)), + ("Tags", Command::CustomizeTags), + ("Help", Command::Help), +]; + +/// The Keyboard section's keymap until OK keeps it, and the chord being recorded. +pub struct Keyboard { + pub keymap: Keymap, + recording: Option, +} + +struct Recording { + command: Command, + /// The place of the chord the new one replaces; `None` adds one. + replacing: Option, + refused: Option, +} + +enum Refusal { + Unusable(Chord, Unusable), + /// Run by another command, or a tag. + Taken(Chord, Command), +} + +/// Whether every word of `query` is in command `id`'s title, menu or `chords`. +fn hit(query: &[String], id: Command, chords: &[Chord]) -> bool { + let labels: Vec = chords.iter().map(|chord| label(*chord)).collect(); + let mut texts = vec![commands::command(id).title, group(id)]; + texts.extend(labels.iter().map(String::as_str)); + found(query, &texts) +} + +/// Whether the Options search finds a command by `query`, with the chords in use. +pub fn searched(query: &[String]) -> bool { + let keymap = Keymap::current(); + (COMMANDS.iter()).any(|command| { + hit( + query, + command.id, + keymap.chords(command.id, Platform::CURRENT), + ) + }) +} + +/// The heading of the menu `id` falls under. +fn group(id: Command) -> &'static str { + let at = |id| COMMANDS.iter().position(|command| command.id == id); + let place = at(id).unwrap_or_default(); + GROUPS + .iter() + .rev() + .find(|(_, first)| at(*first).is_some_and(|first| first <= place)) + .map_or("", |(name, _)| name) +} + +fn label(chord: Chord) -> String { + chord.label(Platform::CURRENT) +} + +fn title(id: Command) -> String { + match id { + Command::Tag(place) => format!("Tag {}", place + 1), + id => commands::command(id).title.trim_end_matches('…').to_owned(), + } +} + +impl Keyboard { + pub fn new() -> Self { + Self { + keymap: Keymap::current(), + recording: None, + } + } + + pub fn recording(&self) -> bool { + self.recording.is_some() + } + + /// Takes a key pressed while recording: Escape stops, and a chord is given the command + /// unless it is unusable or another's. Reports whether the key was taken. + pub fn key(&mut self, key: &Key, modifiers: draw::edit::Modifiers) -> bool { + let Some(recording) = &mut self.recording else { + return false; + }; + let chord = match key { + Key::Named(NamedKey::Escape) => { + self.recording = None; + return true; + } + Key::Named(NamedKey::Modifier | NamedKey::Other) => return true, + key => commands::pressed(key, modifiers), + }; + let Some(chord) = chord else { + return true; + }; + let platform = Platform::CURRENT; + if let Some(why) = chord.unusable(platform) { + recording.refused = Some(Refusal::Unusable(chord, why)); + return true; + } + match self.keymap.ran(chord, platform) { + Some(owner) if owner == recording.command => self.recording = None, + Some(owner) => recording.refused = Some(Refusal::Taken(chord, owner)), + None => self.bind(chord), + } + true + } + + /// Gives the recorded command `chord` in place of the one it replaces. + fn bind(&mut self, chord: Chord) { + let Some(recording) = self.recording.take() else { + return; + }; + let mut chords = self.chords(recording.command); + match recording.replacing { + Some(place) => chords[place] = chord, + None => chords.push(chord), + } + self.keymap.set(recording.command, chords); + } + + fn chords(&self, id: Command) -> Vec { + self.keymap.chords(id, Platform::CURRENT).to_vec() + } + + /// Takes `chord` from its owner, or with `swap` gives the owner the chord replaced, then + /// binds it. + fn take(&mut self, chord: Chord, owner: Command, swap: bool) { + let replaced = (self.recording.as_ref()) + .and_then(|recording| Some(self.chords(recording.command)[recording.replacing?])); + // A tag yields its chord to the command given it by itself. + if !matches!(owner, Command::Tag(_)) { + let chords = self + .chords(owner) + .into_iter() + .filter_map(|held| match (held == chord, replaced) { + (false, _) => Some(held), + (true, Some(replaced)) if swap => Some(replaced), + (true, _) => None, + }) + .collect(); + self.keymap.set(owner, chords); + } + self.bind(chord); + } + + /// Builds the section in the box open in `ui`: the commands matching every word of + /// `query` in their title, menu or chords, or all where none match. + pub fn build(&mut self, ui: &mut Ui, query: &[String]) { + let theme = ui.theme.clone(); + let row = theme.font_size * 2.0; + ui.open( + "keyboard", + Spec { + axis: Axis::Y, + size: [fill(), children()], + gap: 2.0, + ..Spec::default() + }, + ); + ui.open( + "bar", + Spec { + size: [fill(), px(row)], + gap: 8.0, + ..Spec::default() + }, + ); + ui.leaf( + "hint", + Spec { + flags: Flags::CLIP, + size: [fill(), px(row)], + text: Some("Click a shortcut to change it"), + color: Some(theme.text_dim), + ..Spec::default() + }, + ); + if ui::button(ui, "reset-all", "Reset All").clicked { + self.keymap = Keymap::default(); + self.recording = None; + } + ui.close(); + let listed: Vec<(Command, Vec)> = COMMANDS + .iter() + .filter(|command| commands::offered(command.id)) + .map(|command| (command.id, self.chords(command.id))) + .collect(); + let matching = |(id, chords): &&(Command, Vec)| hit(query, *id, chords); + let some = listed.iter().any(|command| matching(&command)); + let mut heading = ""; + for (id, chords) in listed.iter().filter(|command| !some || matching(command)) { + let group = group(*id); + if group != heading { + heading = group; + ui.leaf( + ("group", group), + Spec { + size: [fill(), px(row)], + text: Some(group), + bold: true, + color: Some(theme.text_dim), + role: Some(Role::Heading), + ..Spec::default() + }, + ); + } + self.command_row(ui, *id, chords); + if self + .recording + .as_ref() + .is_some_and(|recording| recording.command == *id) + { + self.status(ui); + } + } + ui.close(); + } + + /// A command's row: its title, a chip per chord to record anew, one to add a chord, and + /// Reset where the user changed its chords. + fn command_row(&mut self, ui: &mut Ui, id: Command, chords: &[Chord]) { + let theme = ui.theme.clone(); + let height = theme.font_size * 1.9; + let row = ui.open( + ("command", format!("{id:?}")), + Spec { + size: [fill(), px(height)], + gap: 4.0, + pad: [6.0, 2.0], + radius: 4.0, + hover_fill: Some(theme.hover()), + role: Some(Role::ListItem), + ..Spec::default() + }, + ); + let title = title(id); + if let Some(node) = ui.access(row) { + node.set_label(title.as_str()); + } + ui.leaf( + "title", + Spec { + flags: Flags::CLIP, + size: [fill(), px(height - 4.0)], + text: Some(&title), + bold: self.keymap.customized(id), + ..Spec::default() + }, + ); + let recording = self + .recording + .as_ref() + .filter(|recording| recording.command == id) + .map(|recording| recording.replacing); + let chip = |ui: &mut Ui, part: (usize, bool), text: &str, live: bool| { + ui.leaf( + part, + Spec { + flags: Flags::CLICKABLE, + size: [fit(), px(height - 4.0)], + text: Some(text), + color: Some(if live { theme.accent } else { theme.text }), + fill: Some(theme.chip), + hover_border: Some(theme.accent), + border: live.then_some(theme.accent), + radius: 4.0, + pad: [8.0, 0.0], + center: true, + role: Some(Role::Button), + ..Spec::default() + }, + ) + .clicked + }; + for (place, chord) in chords.iter().enumerate() { + let live = recording == Some(Some(place)); + let text = if live { + "Type shortcut…".to_owned() + } else { + label(*chord) + }; + if chip(ui, (place, false), &text, live) { + self.record(ui, id, Some(place)); + } + } + let adding = recording == Some(None); + let text = if adding { "Type shortcut…" } else { "+" }; + if chip(ui, (chords.len(), true), text, adding) { + self.record(ui, id, None); + } + if let Some(node) = ui.access(ui.id((chords.len(), true))) { + node.set_label(format!("Add shortcut for {title}")); + } + if self.keymap.customized(id) && ui::button(ui, "reset", "Reset").clicked { + self.keymap + .set(id, commands::command(id).chords(Platform::CURRENT).to_vec()); + self.recording = None; + } + ui.close(); + } + + /// Records a chord for `id`, replacing its chord at `replacing` or adding one; the + /// keys go to the recording, not the search. + fn record(&mut self, ui: &mut Ui, id: Command, replacing: Option) { + self.recording = Some(Recording { + command: id, + replacing, + refused: None, + }); + ui.set_focus(None); + } + + /// The line under the list: what the recording waits for, or why it refused a chord, + /// with what can be done about it. + fn status(&mut self, ui: &mut Ui) { + let theme = ui.theme.clone(); + let row = theme.font_size * 2.0; + let mac = Platform::CURRENT == Platform::MacOs; + let text = match &self.recording { + None => "Click a shortcut to change it".to_owned(), + Some(recording) => match recording.refused { + None => format!("Type a shortcut for {}", title(recording.command)), + Some(Refusal::Unusable(chord, Unusable::Typing)) => format!( + "{} types text. Include {}", + label(chord), + if mac { "⌘ or ⌃" } else { "Ctrl or Alt" } + ), + Some(Refusal::Unusable(chord, Unusable::Editing)) => { + format!("{} moves through text. Type another", label(chord)) + } + Some(Refusal::Unusable(chord, Unusable::System(item))) => { + format!("{} is macOS's {item}. Type another", label(chord)) + } + Some(Refusal::Taken(chord, owner)) => { + format!("{} already runs {}", label(chord), title(owner)) + } + }, + }; + let status = ui.open( + "status", + Spec { + size: [fill(), px(row)], + gap: 8.0, + ..Spec::default() + }, + ); + if let Some(node) = ui.access(status) { + node.set_live(accesskit::Live::Polite); + } + ui.leaf( + "text", + Spec { + flags: Flags::CLIP, + size: [fill(), px(row)], + text: Some(&text), + color: Some(theme.text_dim), + pad: [6.0, 0.0], + role: Some(Role::Label), + ..Spec::default() + }, + ); + if let Some(recording) = &self.recording { + let taken = match recording.refused { + Some(Refusal::Taken(chord, owner)) => Some((chord, owner)), + _ => None, + }; + let (command, replacing) = (recording.command, recording.replacing); + if let Some((chord, owner)) = taken { + // Only a command, not a tag, can take the chord replaced in exchange. + if replacing.is_some() + && !matches!(owner, Command::Tag(_)) + && ui::button(ui, "swap", "Swap").clicked + { + self.take(chord, owner, true); + } + if ui::button(ui, "replace", "Replace").clicked { + self.take(chord, owner, false); + } + } else if let Some(place) = replacing + && ui::button(ui, "remove", "Remove").clicked + { + let mut chords = self.chords(command); + chords.remove(place); + self.keymap.set(command, chords); + self.recording = None; + } + if self.recording.is_some() && ui::button(ui, "cancel", "Cancel").clicked { + self.recording = None; + } + } + ui.close(); + } +} + +impl crate::State { + /// Gives a key pressed while Options records a chord to the recording. + pub(crate) fn record_chord(&mut self, event: &ui::Event) -> bool { + let (Some(options), ui::Event::Key { key, .. }) = (&mut self.options, event) else { + return false; + }; + // A field focused since recording began takes the keys instead. + if self.ui.focused().is_some() { + options.keyboard.recording = None; + return false; + } + let modifiers = ui::edit_modifiers(self.ui.modifiers()); + let taken = options.keyboard.key(&ui::edit_key(key), modifiers); + if taken { + self.window.request_redraw(); + } + taken + } + + /// Whether Options is recording a chord, which the menu bar must then leave to it. + pub(crate) fn recording_chord(&self) -> bool { + self.options + .as_ref() + .is_some_and(|options| options.keyboard.recording()) + } + + /// Puts `keymap` in use, and in the menu bar where it changes. + pub(crate) fn install_keymap(&self, keymap: Keymap) { + if keymap.install() { + crate::platform::install_menu(); + crate::platform::update_tag_menu(&self.tags); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use draw::edit::Modifiers; + + fn shortcut() -> Modifiers { + Modifiers { + command: true, + control: Platform::CURRENT != Platform::MacOs, + ..Modifiers::default() + } + } + + fn typed(keyboard: &mut Keyboard, key: &str, modifiers: Modifiers) { + assert!(keyboard.key(&Key::Character(key.into()), modifiers)); + } + + fn recording(id: Command, replacing: Option) -> Keyboard { + Keyboard { + keymap: Keymap::default(), + recording: Some(Recording { + command: id, + replacing, + refused: None, + }), + } + } + + #[test] + fn a_recorded_chord_replaces_adds_or_swaps_and_refuses_typing() { + let bold = Command::Toggle(Toggle::Bold); + let italic = Command::Toggle(Toggle::Italic); + let platform = Platform::CURRENT; + let mut keyboard = recording(Command::PageList, None); + typed(&mut keyboard, "j", Modifiers::default()); + assert!(matches!( + keyboard.recording.as_ref().unwrap().refused, + Some(Refusal::Unusable(_, Unusable::Typing)) + )); + typed(&mut keyboard, "j", shortcut()); + assert!(!keyboard.recording()); + let chord = commands::cmd('j'); + assert_eq!(keyboard.keymap.chords(Command::PageList, platform), [chord]); + assert_eq!( + keyboard.keymap.ran(chord, platform), + Some(Command::PageList) + ); + + // Bold's chord, taken from Italic, which gets Bold's in exchange. + let mut keyboard = recording(bold, Some(0)); + typed(&mut keyboard, "i", shortcut()); + assert!(matches!( + keyboard.recording.as_ref().unwrap().refused, + Some(Refusal::Taken(_, owner)) if owner == italic + )); + keyboard.take(commands::cmd('i'), italic, true); + assert_eq!(keyboard.keymap.chords(bold, platform), [commands::cmd('i')]); + assert_eq!( + keyboard.keymap.chords(italic, platform), + [commands::cmd('b')] + ); + + // A tag's chord, which the tag then goes without. + let mut keyboard = recording(Command::PageList, None); + typed(&mut keyboard, "1", shortcut()); + keyboard.take(commands::cmd('1'), Command::Tag(0), false); + assert_eq!( + keyboard.keymap.ran(commands::cmd('1'), platform), + Some(Command::PageList) + ); + + // Chords set back to the defaults keep nothing. + let mut keyboard = recording(bold, Some(0)); + typed(&mut keyboard, "b", shortcut()); + assert_eq!(keyboard.keymap, Keymap::default()); + } + + #[test] + fn every_command_falls_under_a_menu() { + assert_eq!(group(Command::Settings), "Snowbound"); + assert_eq!(group(Command::Paste), "Edit"); + assert_eq!(group(Command::Sidebar), "View"); + assert_eq!(group(Command::Toggle(Toggle::Italic)), "Format"); + assert_eq!(group(Command::CheckForUpdates), "Help"); + } +} diff --git a/crates/snowbound/src/library.rs b/crates/snowbound/src/library.rs index bdf08a0afa95e61cdf10fa4568d1d1fece4dc009..da8b5bce609d5ca4c31f92efce1726f15fb48a5e 100644 --- a/crates/snowbound/src/library.rs +++ b/crates/snowbound/src/library.rs @@ -3,6 +3,7 @@ use canvas::{editor::NoteTag, gpu::TagArt}; use notebook::discover::{Folder, SectionState}; use notebook::session::{Background, Notebook, Section}; +use notebook::sidecar::themes::Themes; use notebook::smb::{Client, Credentials}; use std::{ error::Error, @@ -312,6 +313,11 @@ pub struct Library { watch: Option>, /// The art the notebook's tags draw with, as its `.snowbound` folder maps them. tag_art: Mutex>, + /// Sections opened ahead of being shown, or left open after, most recent first, which + /// `open` hands out before opening any. + kept: Mutex>, + /// The notebook's style themes, and when they were read. + themes: Mutex)>>, } impl Library { @@ -329,7 +335,7 @@ impl Library { .and_then(|login| Self::on_share(location, mount.clone(), login, cache)) .unwrap_or_else(|reason| Self { location: location.to_owned(), - name: mount.name(), + name: display_name(cache, location).unwrap_or_else(|| mount.name()), notebook: Err(reason), server: None, notice: None, @@ -337,6 +343,8 @@ impl Library { background: None, watch: None, tag_art: Default::default(), + kept: Mutex::new(crate::prefetch::Recent::new(KEPT)), + themes: Default::default(), }); } let mut notice = None; @@ -358,12 +366,14 @@ impl Library { }; Self { location: location.to_owned(), - name: file_name(Path::new(location)), + name: display_name(cache, location).unwrap_or_else(|| file_name(Path::new(location))), background, watch, tag_art: Mutex::new(Arc::new( notebook.as_ref().map(read_tag_art).unwrap_or_default(), )), + kept: Mutex::new(crate::prefetch::Recent::new(KEPT)), + themes: Default::default(), notebook: notebook.map(Some).map_err(|error| error.to_string()), server: None, notice, @@ -397,11 +407,13 @@ impl Library { background.watch(notebook.replicas()); Ok(Self { location: location.to_owned(), - name: match server_address(location) { + name: display_name(cache, location).unwrap_or_else(|| match server_address(location) { Some(_) => server.mount.name(), None => file_name(Path::new(location)), - }, + }), tag_art: Mutex::new(Arc::new(read_tag_art(¬ebook))), + kept: Mutex::new(crate::prefetch::Recent::new(KEPT)), + themes: Default::default(), notebook: Ok(Some(notebook)), server: Some(server), notice: None, @@ -411,6 +423,17 @@ impl Library { }) } + /// Names the notebook `name` on this computer, leaving its folder as it is, as OneNote + /// 2010's Notebook Properties does; the notebook read again with `with` shows it. + pub fn set_display_name(&self, name: &str) -> io::Result<()> { + let file = display_names(&self.cache); + let mut names = read_display_names(&file); + names.insert(self.location.clone(), name.to_owned()); + let partial = file.with_extension("partial"); + std::fs::write(&partial, serde_json::to_vec_pretty(&names)?)?; + std::fs::rename(partial, file) + } + /// The art the notebook's tags draw with. pub fn tag_art(&self) -> Arc { Arc::clone( @@ -455,6 +478,79 @@ impl Library { }); } + /// The notebook's style themes, read again after a while so other machines' changes + /// reach pages opened later; a section opened on its own has none. + pub fn themes(&self) -> Arc { + let mut kept = self + .themes + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some((read, themes)) = &*kept + && read.elapsed() < Duration::from_secs(30) + { + return Arc::clone(themes); + } + let themes = match &self.notebook { + Ok(Some(notebook)) => notebook.themes().unwrap_or_else(|error| { + eprintln!("{}: reading its themes failed: {error}", self.location); + Themes::default() + }), + _ => Themes::default(), + }; + let themes = Arc::new(themes); + *kept = Some((Instant::now(), Arc::clone(&themes))); + themes + } + + /// Takes `change` into the notebook's themes from now on, and keeps it in the notebook's + /// folder on a thread of its own. + pub fn save_themes(self: &Arc, change: Themes) { + if !matches!(self.notebook, Ok(Some(_))) { + return; + } + let mut themes = (*self.themes()).clone(); + notebook::sidecar::themes::merge(&mut themes, change.clone()); + *self + .themes + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = + Some((Instant::now(), Arc::new(themes))); + let library = Arc::clone(self); + std::thread::spawn(move || { + let kept = library + .reopen() + .and_then(|notebook| Ok(notebook.save_themes(change)?)); + if let Err(error) = kept { + eprintln!("{}: keeping its themes failed: {error}", library.location); + } + }); + } + + /// Deletes for good, on a thread of its own, what the notebook's recycle bin has held + /// unchanged past OneNote's 60 days, as OneNote 2010 prunes it a while after opening. + pub fn purge_recycle_bin(self: &Arc) { + if !matches!(self.notebook, Ok(Some(_))) { + return; + } + let library = Arc::clone(self); + std::thread::spawn(move || { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |unix| unix.as_secs()) + // Time32 counts from 1980. + .saturating_sub(315_532_800); + let purged = library.reopen().and_then(|mut notebook| { + Ok(notebook.purge_recycle_bin(u32::try_from(now).unwrap_or(u32::MAX))?) + }); + if let Err(error) = purged { + eprintln!( + "{}: emptying the recycle bin failed: {error}", + library.location + ); + } + }); + } + /// This notebook read again, the way it was opened, for changing its structure. pub fn reopen(&self) -> Result> { Ok(match &self.server { @@ -482,7 +578,7 @@ impl Library { }; Self { location: self.location.clone(), - name: self.name.clone(), + name: display_name(&self.cache, &self.location).unwrap_or_else(|| self.name.clone()), notebook: Ok(Some(notebook)), server: self.server.clone(), notice: self.notice.clone(), @@ -490,6 +586,8 @@ impl Library { background: self.background.clone(), watch, tag_art: Mutex::new(self.tag_art()), + kept: Mutex::new(crate::prefetch::Recent::new(KEPT)), + themes: Default::default(), } } @@ -502,6 +600,8 @@ impl Library { background, watch, tag_art: Default::default(), + kept: Mutex::new(crate::prefetch::Recent::new(KEPT)), + themes: Default::default(), notebook: Ok(Some(notebook)), server: None, notice: None, @@ -521,6 +621,8 @@ impl Library { background: None, watch: None, tag_art: Default::default(), + kept: Mutex::new(crate::prefetch::Recent::new(KEPT)), + themes: Default::default(), } } @@ -533,7 +635,16 @@ impl Library { ) -> Result> { // The background may hold the replica for a step, which takes a network round trip, // or be making it as an offline copy. - let deadline = Instant::now() + TIMEOUT * 3; + self.open_by(path, notify, Instant::now() + TIMEOUT * 3) + } + + /// `open`, waiting until `deadline` for whatever holds the section's replica. + fn open_by( + &self, + path: &str, + notify: impl Fn() + Send + 'static, + deadline: Instant, + ) -> Result> { let notify = Arc::new(Mutex::new(notify)); let notifier = || { let notify = Arc::clone(¬ify); @@ -544,6 +655,14 @@ impl Library { } }; let section = loop { + if let Some(kept) = self + .kept + .lock() + .ok() + .and_then(|mut kept| kept.take(&path.to_owned())) + { + return Ok(kept); + } let opened = match (&self.notebook, &self.server) { (Ok(Some(notebook)), Some(server)) => { let file = match server.mount.root.as_str() { @@ -567,7 +686,7 @@ impl Library { }) } (Ok(Some(notebook)), None) if self.in_icloud() => { - crate::icloud::section(notebook, path, notifier()) + crate::icloud::section(notebook, Path::new(&self.location), path, notifier()) } (Ok(Some(notebook)), None) => notebook.section(path, notifier()), (Ok(None), _) if self.in_icloud() => { @@ -595,6 +714,49 @@ impl Library { Ok(section) } + /// Opens the section at catalog `path`, unless it is kept open already, and keeps it + /// open for `open` to hand out. A section held elsewhere, as the one shown is, is left. + pub fn prefetch( + &self, + path: &str, + notify: impl Fn() + Send + 'static, + ) -> Result, Box> { + let section = self.open_by(path, notify, Instant::now())?; + let replica = Arc::clone(section.replica()); + self.keep(path, section); + Ok(replica) + } + + /// Keeps `section`, at catalog `path`, open for `open` to hand out; the least recent + /// past `KEPT` close. + pub fn keep(&self, path: &str, section: Section) { + let gone = match self.kept.lock() { + Ok(mut kept) => kept.put(path.to_owned(), section), + Err(_) => vec![section], + }; + if !gone.is_empty() { + std::thread::spawn(move || close(gone)); + } + } + + /// Works offline, or online again, in the background and in the sections kept open. + pub fn set_offline(&self, offline: bool) { + if let Some(background) = &self.background { + background.set_offline(offline); + } + if let Ok(mut kept) = self.kept.lock() { + kept.values_mut() + .for_each(|section| section.set_offline(offline)); + } + } + + /// Closes the sections kept open, before the notebook's files change. + pub fn close_kept(&self) { + if let Ok(mut kept) = self.kept.lock() { + close(kept.trim(0, |_| 1)); + } + } + /// Where `file`, a section file of this notebook, sits on this computer: under the /// mount it was opened from when the embedded client holds it share-relative. pub fn local(&self, file: &Path) -> Option { @@ -619,23 +781,60 @@ impl Library { self.folder().is_some_and(crate::icloud::ubiquitous) } - /// How the notebook's files are reached. - pub fn transport(&self) -> String { - match (&self.server, &self.notice) { - (Some(server), _) => format!( - "Snowbound’s SMB client, {}/{}", - server.mount.server, server.mount.share - ), - (None, Some(_)) => "The system’s mount of the share".to_owned(), - (None, None) if self.in_icloud() => "iCloud Drive".to_owned(), - (None, None) => "Folder on this computer".to_owned(), - } + /// Where the notebook lives, as its reader knows the place: the server and share, iCloud + /// Drive, a drive or this computer, then each folder down to the notebook's, left out. + pub fn place(&self) -> Vec { + let mut place: Vec = match self + .server + .as_ref() + .map(|server| server.mount.clone()) + .or_else(|| server_address(&self.location)) + { + Some(mount) => { + let host = bonjour_instance(&mount.server) + .unwrap_or_else(|| mount.host().trim_end_matches(".local").to_owned()); + [host, mount.share.clone()] + .into_iter() + .chain(mount.root.split('/').map(str::to_owned)) + .filter(|part| !part.is_empty()) + .collect() + } + None => { + let path = Path::new(&self.location); + let icloud = self.in_icloud(); + let this = if cfg!(target_os = "macos") { + "This Mac" + } else { + "This Computer" + }; + let tops = [ + ( + icloud.then(crate::icloud::folder).flatten(), + "iCloud Drive/Snowbound", + ), + (icloud.then(crate::icloud::drive).flatten(), "iCloud Drive"), + (std::env::var_os("HOME").map(PathBuf::from), this), + (Some(PathBuf::from("/Volumes")), ""), + ]; + let (top, rest) = tops + .into_iter() + .find_map(|(top, name)| Some((name, path.strip_prefix(top?).ok()?))) + .unwrap_or((this, path)); + top.split('/') + .map(str::to_owned) + .chain(rest.iter().map(|part| part.to_string_lossy().into_owned())) + .filter(|part| !part.is_empty() && part != "/") + .collect() + } + }; + place.pop(); + place } /// Names the section at catalog `path` across every open notebook, for remembering /// its pages. pub fn key(&self, path: &str) -> String { - format!("{}\n{path}", self.location) + key(&self.location, path) } /// The readable sections of the folder at catalog path `folder`, as tabs in its order. @@ -660,6 +859,21 @@ impl Library { } } + /// The file identity of the section at catalog `path`. + pub fn section_identity(&self, path: &str) -> Option<[u8; 16]> { + let Ok(Some(notebook)) = &self.notebook else { + return None; + }; + let mut folders = vec![notebook.catalog()]; + while let Some(folder) = folders.pop() { + if let Some(section) = folder.sections.iter().find(|section| section.path == path) { + return Some(section.file_id); + } + folders.extend(&folder.groups); + } + None + } + /// The first readable section, searching groups after sections, as OneNote opens a /// notebook. pub fn first_section(&self) -> Option { @@ -696,6 +910,31 @@ impl Library { false } + /// The notebook's colour, COLORREF, as its table of contents holds it. + pub fn color(&self) -> Option { + self.catalog()?.toc.as_ref()?.color + } + + /// Whether the notebook lists sections not on this device yet, as iCloud Drive keeps + /// them elsewhere. + pub fn downloading(&self) -> bool { + let Some(catalog) = self.catalog() else { + return false; + }; + let mut folders = vec![catalog]; + while let Some(folder) = folders.pop() { + if folder + .unavailable + .iter() + .any(|entry| entry.reason == notebook::discover::Reason::Evicted) + { + return true; + } + folders.extend(&folder.groups); + } + false + } + /// The notebook's folders of sections, for the sidebar. pub fn catalog(&self) -> Option<&Folder> { match &self.notebook { @@ -715,6 +954,28 @@ pub fn server_address(location: &str) -> Option { } /// Whether the folder at `path` is the notebook's recycle bin, which OneNote keeps out of its lists. +/// `Library::key` for the notebook at `location`, open or not. +pub fn key(location: &str, path: &str) -> String { + format!("{location}\n{path}") +} + +/// Where this computer keeps the display names notebooks were given, by location: OneNote +/// 2010 keeps them in its local cache, never in the notebook. +fn display_names(cache: &Path) -> PathBuf { + cache.join("display-names.json") +} + +fn read_display_names(file: &Path) -> std::collections::BTreeMap { + std::fs::read(file) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + .unwrap_or_default() +} + +fn display_name(cache: &Path, location: &str) -> Option { + read_display_names(&display_names(cache)).remove(location) +} + pub fn recycle_bin(path: &str) -> bool { path.rsplit('/').next() == Some("OneNote_RecycleBin") } @@ -781,6 +1042,17 @@ fn watcher( .map(Arc::new) } +/// How many sections a notebook keeps open besides the one shown. +const KEPT: usize = 3; + +fn close(sections: Vec
) { + for section in sections { + if let Err(error) = section.close() { + eprintln!("Synchronization stopped: {error}"); + } + } +} + pub fn file_name(path: &Path) -> String { path.canonicalize() .ok() @@ -890,6 +1162,37 @@ pub fn locate(path: &Path) -> Located { mod tests { use super::*; + /// A section kept open is what `open` hands out next, without opening it again, and + /// readying a section another holds leaves it to that one at once. + #[test] + fn kept_sections_open_at_once() { + let root = std::env::temp_dir().join(format!("snowbound-kept-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let notebook = root.join("Personal"); + std::fs::create_dir_all(¬ebook).unwrap(); + let sample = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/sample-notebook/Personal"); + for entry in std::fs::read_dir(sample).unwrap() { + let entry = entry.unwrap(); + std::fs::copy(entry.path(), notebook.join(entry.file_name())).unwrap(); + } + let library = Library::notebook(notebook.to_str().unwrap(), &root.join("cache")); + let [first, second] = [0, 1].map(|tab| library.tabs("")[tab].path.clone()); + let readied = library.prefetch(&first, || {}).unwrap(); + let opened = library.open(&first, || {}).unwrap(); + assert!(Arc::ptr_eq(&readied, opened.replica())); + drop(readied); + let start = Instant::now(); + assert!(library.prefetch(&first, || {}).is_err()); + assert!(start.elapsed() < Duration::from_secs(1)); + library.keep(&first, opened); + library.prefetch(&second, || {}).unwrap(); + library.close_kept(); + library.open(&first, || {}).unwrap().close().unwrap(); + drop(library); + std::fs::remove_dir_all(&root).unwrap(); + } + #[cfg(unix)] #[test] fn within_mount_resolves_symlinks() { @@ -1051,6 +1354,8 @@ mod tests { background: None, watch: None, tag_art: Default::default(), + kept: Mutex::new(crate::prefetch::Recent::new(KEPT)), + themes: Default::default(), }; let shown = |root, file| library(root).local(Path::new(file)); let under = Some(PathBuf::from("/Volumes/agent/lab/Group/New Section 1.one")); diff --git a/crates/snowbound/src/linux.rs b/crates/snowbound/src/linux.rs index feff722e9b0d715eae0446d9d593451cb6820cf6..9be8864468553eb285beb3418644a4fa3cded821 100644 --- a/crates/snowbound/src/linux.rs +++ b/crates/snowbound/src/linux.rs @@ -206,11 +206,16 @@ impl Clipboard { .unwrap_or_default() } + pub fn get_html(&mut self) -> Option { + self.through_x11(|clipboard| clipboard.get().html().ok()) + } + pub fn get_picture(&mut self) -> Option> { self.through_x11(|clipboard| crate::paste::bitmap(clipboard.get_image().ok()?)) } - /// Files and pictures come through X11 on Wayland too, whose clipboard Xwayland shares. + /// Files, pages and pictures come through X11 on Wayland too, whose clipboard Xwayland + /// shares. fn through_x11( &mut self, read: impl FnOnce(&mut arboard::Clipboard) -> Option, diff --git a/crates/snowbound/src/macos.rs b/crates/snowbound/src/macos.rs index ff3297dc8920a5a1807f499e985f74690b1f28ab..9903eb806c7a910cf140ff2ba56327a867364287 100644 --- a/crates/snowbound/src/macos.rs +++ b/crates/snowbound/src/macos.rs @@ -193,6 +193,10 @@ impl Clipboard { self.0.get().file_list().unwrap_or_default() } + pub fn get_html(&mut self) -> Option { + self.0.get().html().ok() + } + /// The pasteboard's PNG as copied, keeping its resolution, or else its TIFF as a PNG. pub fn get_picture(&mut self) -> Option> { unsafe { @@ -1300,9 +1304,38 @@ pub fn event_loop(headless: bool) -> Result, EventLo let event_loop = builder.build()?; QUIT.set(event_loop.create_proxy()) .expect("Only one application event loop is created"); + // Winit's delegate gains -application:openFiles:, which every macOS from 10.6 sends for + // the Finder's and `open`'s documents, before the launch finishes on a cold launch. + unsafe { + let app = NSApplication::sharedApplication(MainThreadMarker::new_unchecked()); + let delegate: Retained = msg_send_id![&app, delegate]; + let mut class = ClassBuilder::new("SnowboundApplicationDelegate", delegate.class()) + .expect("Unique application delegate class"); + class.add_method( + sel!(application:openFiles:), + open_files as unsafe extern "C" fn(_, _, _, _), + ); + let class = class.register(); + // The subclass adds no ivars, so the existing allocation remains valid. + AnyObject::set_class(&delegate, class); + } Ok(event_loop) } +unsafe extern "C" fn open_files( + _: &AnyObject, + _: Sel, + app: &NSApplication, + files: &objc2_foundation::NSArray, +) { + if let Some(proxy) = QUIT.get() { + let paths = files.iter().map(|file| file.to_string().into()).collect(); + let _ = proxy.send_event(crate::UserEvent::Open(paths)); + } + // NSApplicationDelegateReplySuccess. + let _: () = unsafe { msg_send![app, replyToOpenOrPrint: 0usize] }; +} + /// Replaces Winit's application menu with the menu bar, whose items the table validates. pub fn install_menu() { let mtm = MainThreadMarker::new().expect("Menus belong to the main thread"); diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 3de2a1c0ccc224f4170877300e062c5a8e27da92..23b57e6d1b76dd0b7d9324217f12efc565b027b4 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -15,6 +15,9 @@ mod history; #[cfg_attr(not(target_os = "macos"), path = "icloud_linux.rs")] #[cfg_attr(target_os = "macos", path = "icloud_macos.rs")] mod icloud; +#[cfg_attr(target_os = "macos", allow(dead_code))] +mod instance; +mod keys; mod library; mod link; mod manage; @@ -35,7 +38,9 @@ mod paste; #[cfg_attr(target_os = "macos", path = "macos.rs")] #[cfg_attr(windows, path = "windows.rs")] mod platform; +mod prefetch; mod print; +mod properties; #[cfg_attr(target_os = "linux", path = "print_linux.rs")] #[cfg_attr(target_os = "macos", path = "print_macos.rs")] #[cfg_attr(windows, path = "print_windows.rs")] @@ -57,6 +62,8 @@ mod surface; mod sync; mod tags; mod templates; +mod themes; +mod undo; mod update; mod video; mod watch; @@ -203,6 +210,12 @@ enum UserEvent { /// The iCloud account signed out, signed in or switched, or iCloud Drive was turned off. #[cfg_attr(not(target_os = "macos"), allow(dead_code))] ICloudAccount, + /// The app's iCloud Drive folder was looked up, or a notebook came or went at its top. + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] + ICloudFolder, + /// The system or a later launch asked to open notebook folders, tables of contents or + /// sections, as the Finder does with a double-clicked file; none brings the window forward. + Open(Vec), } /// Asks the event loop for a frame from any thread. @@ -228,6 +241,11 @@ impl Clipboard { let files = clipboard.get_files(); if !files.is_empty() { Some(paste::Pasted::Files(files)) + } else if let Some(html) = clipboard + .get_html() + .filter(|html| html.to_ascii_lowercase().contains(", /// What the window starts from besides its input, until it opens. launch: Option, + /// Files and folders to open, as File, Open does, once the window opens. + opening: Vec, state: Option, startup_error: Option>, } @@ -330,6 +350,20 @@ enum Input { }, } +impl Input { + /// Lists the notebook folder `root` and shows it first, where the window shows notebooks. + fn show(&mut self, root: &Path) -> std::io::Result<()> { + if let Input::Notebooks { locations, current } = self { + let location = std::path::absolute(root)?.to_string_lossy().into_owned(); + if !locations.contains(&location) { + locations.push(location.clone()); + } + *current = Some(location); + } + Ok(()) + } +} + /// Asks the event loop to poll sections when their synchronization reports. fn notify(proxy: EventLoopProxy) -> impl Fn() + Send + 'static { move || { @@ -337,6 +371,13 @@ fn notify(proxy: EventLoopProxy) -> impl Fn() + Send + 'static { } } +/// Asks the event loop to list the app's iCloud Drive folder again. +fn icloud_listed(proxy: EventLoopProxy) -> impl Fn() + Send + Sync + 'static { + move || { + let _ = proxy.send_event(UserEvent::ICloudFolder); + } +} + /// The open section and the page the editor shows. struct Session { section: notebook::session::Section, @@ -501,6 +542,7 @@ struct Opening { scene: (PageScene, [f32; 2]), editor: CanvasEditor, since: Instant, + requested: Instant, } /// How long an opening page waits for its pictures before showing without them. @@ -519,8 +561,8 @@ enum Command { OpenFromServer(Option), /// Asks where to keep a new notebook and creates it. NewNotebook, - /// Opens the notebooks in the app's iCloud Drive folder, making one there if it has none. - UseICloud, + /// Asks for a name and creates a notebook at the top of the app's iCloud Drive folder. + NewICloudNotebook, /// Opens the Snowbound Guide from the user's documents, copying it there first. OpenGuide, /// Adds Snowbound to the app menu. @@ -600,6 +642,9 @@ struct State { /// The newest read requested; older ones are dropped when they finish. loading: u64, opening: Option, + /// The section tab, if another, that the newest read opens, and when it was asked for. + switching: Option<(Option, Instant)>, + prefetch: prefetch::Prefetch, /// Where each page was left this run, by `Library::key` and page space, as OneNote /// returns to it until the notebook closes. places: HashMap<(String, ExGuid), Place>, @@ -607,6 +652,8 @@ struct State { last_pages: HashMap, /// Pages visited, for Back and Forward. trail: navigation::Trail, + /// What Undo and Redo take back across pages and sections. + undo: undo::Timeline, swipe: navigation::Swipe, /// Asks for a frame when a worker thread finishes something the page shows. redraw: std::task::Waker, @@ -629,13 +676,30 @@ struct State { folded: HashSet, /// The open context menu: what it was opened on, and where. menu: Option<(menus::Target, [f32; 2])>, + /// The notebook the sync status popup shows, opened from its context menu at a point; + /// `None` shows the open section's, below the toolbar's button. + sync_notebook: Option<(Arc, [f32; 2])>, + /// What the palette's actions menu is on while it is open. + actions: Option, + /// An action on a page of another section, done once that section opens on the page. + after_open: Option<(ExGuid, menus::Action)>, /// The Options dialog's choices while it is open. options: Option, + /// The Themes dialog while it is open. + themes: Option, + /// Print Preview and Settings: the choices last made, and the dialog's while open. + printing: print::Printing, updates: update::Updates, /// The Link dialog's fields while it is open. link: Option, + properties: Option, /// Open Notebook from Server while it is open. server: Option, + /// New iCloud Notebook while it is open. + new_icloud: Option, + /// Notebooks in iCloud Drive a thread is reading, or following the download of, by + /// location. + icloud_reading: HashSet, /// The user's tag list, which the toolbar, menus and Ctrl+1 to Ctrl+9 apply. tags: Vec, /// The Customize Tags dialog's list while it is open. @@ -649,8 +713,6 @@ struct State { hide_spelling: bool, /// Options' "Use pen pressure sensitivity": a tablet pen's strokes follow its pressure. pen_pressure: bool, - /// Options' Default font, which new text and titles take. - default_font: settings::DefaultFont, /// Options' "Page tabs appear on the left". page_tabs_left: bool, /// Options' "Navigation bar appears on the left" off: the notebooks on the right. @@ -790,6 +852,7 @@ impl State { platform::install_text_input(&window); // Beneath the surface's layer, which is added over it. let backdrop = visible && platform::install_backdrop(&window); + commands::Keymap::from_saved(&stored.keys).install(); platform::install_menu(); let access_adapter = accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy.clone()); @@ -812,7 +875,7 @@ impl State { icloud::on_account_change(move || { let _ = account.send_event(UserEvent::ICloudAccount); }); - icloud::look_up(notify(proxy.clone())); + icloud::look_up(icloud_listed(proxy.clone())); let mut notebooks = Vec::new(); let mut session = None; let mut sectionless = None; @@ -859,6 +922,10 @@ impl State { if let Err(error) = &library.notebook { eprintln!("Cannot open the notebook at {}: {error}", library.location); } + // Drawn offscreen, the app leaves the notebooks as they are. + if visible { + library.purge_recycle_bin(); + } } let shown = notebooks .iter() @@ -946,6 +1013,7 @@ impl State { let spelling = spell::dictionary() .map(|dictionary| canvas::spelling::Spelling::new(dictionary, redraw.clone())); let updates = update::Updates::start(visible && !stored.manual_updates, proxy.clone()); + let prefetch = prefetch::Prefetch::new(Arc::clone(&layouts), redraw.clone()); let mut state = Self { author: stored.user_name.unwrap_or_else(platform::user_name), window, @@ -972,14 +1040,22 @@ impl State { dismissed: HashSet::new(), title_focus: None, menu: None, + sync_notebook: None, + actions: None, + after_open: None, link: None, + properties: None, server: None, + new_icloud: None, + icloud_reading: HashSet::new(), tags: stored .tags .unwrap_or_else(canvas::editor::NoteTag::defaults), tag_list: None, text_menu: None, options: None, + themes: None, + printing: print::Printing::default(), updates, color_scheme: stored.color_scheme, light_pages: stored.light_pages, @@ -1016,12 +1092,19 @@ impl State { ime_allowed: true, clipboard, loads: mpsc::channel(), + prefetch, layouts, loading: 0, opening: None, + switching: None, places: HashMap::new(), last_pages: HashMap::new(), - trail: navigation::Trail::default(), + trail: { + let mut trail = navigation::Trail::default(); + trail.recent = stored.recent; + trail + }, + undo: undo::Timeline::default(), swipe: navigation::Swipe::default(), access_adapter, accessibility: accessibility::Accessibility::default(), @@ -1029,13 +1112,13 @@ impl State { spelling, hide_spelling: stored.hide_spelling, pen_pressure: !stored.ignore_pen_pressure, - default_font: stored.default_font, page_tabs_left: stored.page_tabs_left, navigation_bar_right: stored.navigation_bar_right, servers: stored.servers, correction: None, }; state.view.snap_to_grid = !stored.ignore_grid; + state.view.editor.default_font = stored.default_font; // A notebook opened from its server that couldn't sign in asks to, as the Finder does. let unsigned = state.notebooks.iter().find(|library| { library.notebook.is_err() && library::server_address(&library.location).is_some() @@ -1044,6 +1127,7 @@ impl State { state.commands.push(Command::OpenFromServer(Some(location))); } state.visited(); + state.prefetch_around(); state.show_spelling(); state.title(); platform::update_tag_menu(&state.tags); @@ -1090,7 +1174,10 @@ impl State { lap("drawn", start); self.sync_index(false, Vec::new()); let commands = std::mem::take(&mut self.commands); - let follow = !commands.is_empty() || self.ui.wants_frame() || self.opening.is_some(); + let follow = !commands.is_empty() + || self.ui.wants_frame() + || self.opening.is_some() + || self.switching.is_some(); for command in commands { self.apply(command)?; } @@ -1118,8 +1205,12 @@ impl State { platform::cover_border_line(&mut self.ui, size[0]); let (section, open_tab, open_page) = self.build()?; self.options_dialog(); + self.themes_dialog(); + self.print_dialog(); self.server_dialog(); + self.new_icloud_dialog(); self.link_dialog()?; + self.properties_dialog(); self.customize_tags(); self.palette(); self.sync_popup()?; @@ -1185,7 +1276,8 @@ impl State { let target = self.ui.theme.section(section_color( self.session .as_ref() - .and_then(|session| session.tabs[session.tab].color), + .zip(self.open_tab()) + .and_then(|(session, tab)| session.tabs[tab].color), )); self.ui.icon_palette = self.ui.theme.icon_palette( target.accent, @@ -1235,7 +1327,12 @@ impl State { ..Spec::default() }, ); - let sidebar = self.sidebar(&theme); + let sidebar = self.sidebar_width(); + if !self.navigation_bar_right { + self.sidebar(&theme, sidebar); + } + // Where the notebook button floats over the tab row while the sidebar is shut. + let beside = 1.0 - sidebar / sidebar::WIDTH; self.ui.open( "main", Spec { @@ -1267,10 +1364,11 @@ impl State { // outlines start where the button's square ends; the room shrinks on the sidebar's // easing, so the tabs ease with it. let room = TAB_ROW - FRAME - self.rounding() - ui::SHADOW[0]; + let room = if self.navigation_bar_right { 0.0 } else { room }; self.ui.leaf( "rail", Spec { - size: [px(room * (1.0 - sidebar / sidebar::WIDTH)), px(1.0)], + size: [px(room * beside), px(1.0)], ..Spec::default() }, ); @@ -1301,6 +1399,7 @@ impl State { .collect(); let lit = self.page_drop(row); let dragged = self.dragged_tab(row); + let shown = self.open_tab().unwrap_or(session.tab); let ui::shell::Tabs { clicked, context, @@ -1313,7 +1412,7 @@ impl State { &mut self.ui, row, &tabs, - session.tab, + shown, lit, dragged, §ion, @@ -1373,6 +1472,12 @@ impl State { } }; self.commands.extend(clicked); + if let Some(count) = self.session.as_ref().map(|session| session.tabs.len()) + && let Some(tab) = + (0..count).find(|tab| self.ui.signal(ui::shell::tab_id(row, *tab)).hovered) + { + self.prefetch_section(tab); + } self.ui.leaf( "space", Spec { @@ -1383,10 +1488,15 @@ impl State { if self.session.is_some() { self.page_tools(&theme); } + let button = if self.navigation_bar_right && !self.temporary { + TAB_ROW * beside + } else { + 0.0 + }; self.ui.leaf( "trail", Spec { - size: [px(self.trailing() - FRAME), px(1.0)], + size: [px(self.trailing() - FRAME + button), px(1.0)], ..Spec::default() }, ); @@ -1402,6 +1512,11 @@ impl State { ..Spec::default() }, ); + // OneNote's "Page tabs appear on the left" lists them before the page. + let mut open_page = None; + if self.page_tabs_left { + open_page = self.page_list(&theme, §ion, row); + } self.ui.open( "column", Spec { @@ -1427,6 +1542,9 @@ impl State { self.ui.close(); self.ui.close(); self.ui.close(); + if self.navigation_bar_right { + self.sidebar(&theme, sidebar); + } self.sidebar_button(&theme, height); self.ui.close(); self.context_menu(); @@ -1445,12 +1563,20 @@ impl State { if let Some(node) = self.ui.access(page()) { node.set_tree_id(PAGE_TREE); } - self.template_strip(&theme); + // A page opening has neither the templates nor the scroll of the page leaving. + let opening = self.loading().is_some(); + if !opening { + self.template_strip(&theme); + } if let Some(rect) = self.ui.rect(page()) { self.transport(&theme, rect)?; } let scroll = self.view.scroll(); - for (index, axis) in [Axis::X, Axis::Y].into_iter().enumerate() { + for (index, axis) in [Axis::X, Axis::Y] + .into_iter() + .filter(|_| !opening) + .enumerate() + { if let Some(offset) = ui::scrollbar( &mut self.ui, index, @@ -1491,9 +1617,14 @@ impl State { } self.page_events(signal.events)?; self.ui.close(); - let open_page = self.page_list(&theme, §ion, row); + if !self.page_tabs_left { + open_page = self.page_list(&theme, §ion, row); + } self.ui.close(); self.ui.close(); + if self.navigation_bar_right { + self.sidebar(&theme, sidebar); + } self.task_pane(&theme); self.sidebar_button(&theme, height); self.ui.close(); @@ -1523,6 +1654,7 @@ impl State { // Where the open page tab meets the page, concentric with its neighbours. let join = PILL_MARGIN + rounding; let [left, top, right, bottom] = page; + let left_tabs = self.page_tabs_left; let tab = open_page .and_then(|id| self.ui.rect(id)) .map(|row| [row[0], row[1], row[2], row[3] - ROW_GAP]) @@ -1532,8 +1664,12 @@ impl State { row[1].max(panel[1]).max(top), row[3].min(panel[3]).min(bottom), ]; - let reach = row[2].min(panel[2]); - (reach - right > 2.0 * join && end - start > 2.0 * rounding).then(|| { + // How far the tab reaches out from the page's side. + let (reach, out) = match left_tabs { + true => (row[0].max(panel[0]), left - row[0].max(panel[0])), + false => (row[2].min(panel[2]), row[2].min(panel[2]) - right), + }; + (out > 2.0 * join && end - start > 2.0 * rounding).then(|| { // A tab too near the page's corner joins it along the edge. let start = if start < top + rounding + join { top @@ -1548,9 +1684,24 @@ impl State { [start, end, reach] }) }); - let mut outline = vec![([left, top], rounding)]; + // Clockwise from the top left, the open tab's bump on the side it stands. + let mut outline = Vec::new(); match tab { + Some([start, end, reach]) if left_tabs => { + if start > top { + outline.push(([left, top], rounding)); + } + outline.extend([([right, top], rounding), ([right, bottom], rounding)]); + if end < bottom { + outline.extend([([left, bottom], rounding), ([left, end], join)]); + } + outline.extend([([reach, end], rounding), ([reach, start], rounding)]); + if start > top { + outline.push(([left, start], join)); + } + } Some([start, end, reach]) => { + outline.push(([left, top], rounding)); if start > top { outline.extend([([right, top], rounding), ([right, start], join)]); } @@ -1558,10 +1709,15 @@ impl State { if end < bottom { outline.extend([([right, end], join), ([right, bottom], rounding)]); } + outline.push(([left, bottom], rounding)); } - None => outline.extend([([right, top], rounding), ([right, bottom], rounding)]), + None => outline.extend([ + ([left, top], rounding), + ([right, top], rounding), + ([right, bottom], rounding), + ([left, bottom], rounding), + ]), } - outline.push(([left, bottom], rounding)); let height = (frame[3] - frame[1]).max(1.0); let paper = self.paper().color; self.ui.round_corners(&outline, paper, |y| { @@ -1573,7 +1729,9 @@ impl State { // the open tab stands on it. let [start, end] = [frame[0], frame[2]]; let outer = platform::corner_radius(&self.window); - let beside = outer * start / sidebar::WIDTH; + let window = self.ui.rect(Id::ROOT).map_or(end, |root| root[2]); + let [beside, beside_right] = + [start, window - end].map(|gap| (outer * gap / sidebar::WIDTH).min(outer)); let strip = self.ui.theme.strip; // The border runs half its width inside the frame, on whole device pixels at any // scale, its corners concentric with the fill's. The fill is cut at the border's @@ -1585,7 +1743,7 @@ impl State { &[ ([left, top], (outer - cut).max(0.0)), ([right, top], (outer - cut).max(0.0)), - ([right, frame[3]], 0.0), + ([right, frame[3]], (beside_right - cut).max(0.0)), ([left, frame[3]], (beside - cut).max(0.0)), ], paper, @@ -1603,7 +1761,7 @@ impl State { .map_or([end; 2], |tab| ui::shell::tab_base(tab, TAB_ROW)) .map(|x| x.clamp(left, right)); // The border runs down both sides to the window's bottom, round the bottom corner - // beside the sidebar. + // beside the sidebar, on whichever side it stands. self.ui.border( &[ ([west + beside, frame[3]], 0.0), @@ -1618,7 +1776,8 @@ impl State { &[ ([toe, north], 0.0), ([east, north], inner(outer)), - ([east, frame[3]], 0.0), + ([east, frame[3]], inner(beside_right)), + ([east - beside_right, frame[3]], 0.0), ], false, section.edge, @@ -1756,6 +1915,7 @@ impl State { let session = self.session.as_ref(); let update = self.updates.status(); let label = format!("{:.0}%", self.view.zoom() * 100.0); + let sheet = self.gallery_sheet(); let drawing_pens = self.pens(); let ui = &mut self.ui; let text = theme.text; @@ -1850,6 +2010,22 @@ impl State { }, ); divider(ui, theme); + // Styles sits left of the font box (Clover, 2026-09-30), and like it never folds. + let styles = toolbar_popup("styles"); + let anchor = if status_of(Cmd::Styles).enabled { + ui::shell::menu_button(ui, "styles", art::STYLES, None, styles) + } else { + ui::shell::unavailable(ui, "styles", art::STYLES, text, true); + ui::Anchor::Below(ui.rect(ui.id("styles")).unwrap_or_default()) + }; + let shown = notebook::sidecar::themes::STYLES + .iter() + .find(|(name, _)| state.style.as_deref() == Some(*name)) + .map_or("Styles".to_owned(), |(_, label)| format!("Styles: {label}")); + ui::popup::tooltip(ui, &shown, "", None); + if let Some(id) = themes::gallery(ui, styles, anchor, &sheet, state.style.as_deref()) { + choice = Some(Choice::Command(id)); + } let combo = ui.id("font"); // It gives up room last of all, once every group has folded. let width = ui::Extent { @@ -2546,8 +2722,9 @@ impl State { self.ui.close(); } - /// The section's pages as tabs down the frame's right side, returning the open page's - /// tab, which is the page's colour and joins it. + /// The section's pages as tabs down the frame's right side, or its left with OneNote's + /// "Page tabs appear on the left", returning the open page's tab, which is the page's + /// colour and joins it. fn page_list(&mut self, theme: &Theme, section: &ui::Section, tabs: Id) -> Option { let session = self.session.as_ref()?; let panel = self.ui.id("panel"); @@ -2567,6 +2744,11 @@ impl State { node.set_label("Pages"); node.set_orientation(accesskit::Orientation::Vertical); } + // Another section's pages are on their way. + if self.loading().is_some() && self.switching.is_some_and(|(tab, _)| tab.is_some()) { + self.ui.close(); + return None; + } let found = self .search .found_in(&session.library.key(&session.tabs[session.tab].path)); @@ -2578,7 +2760,7 @@ impl State { section, session, &found, - rounding, + (rounding, self.page_tabs_left), self.renaming.as_mut(), dragged, ); @@ -2587,7 +2769,12 @@ impl State { .map(|(page, version)| Command::OpenVersion { page, version }), ); if let Some((space, point)) = rows.context { - self.menu = Some((menus::Target::Page(space), point)); + let target = menus::Target::Page { + library: Arc::clone(&session.library), + path: session.tabs[session.tab].path.clone(), + space, + }; + self.menu = Some((target, point)); self.ui.open_popup(menus::id()); } if let Some(space) = rows.renamed { @@ -2600,12 +2787,19 @@ impl State { if !self.dragged() { self.commands.extend(rows.clicked.map(Command::OpenPage)); } + if let Some(space) = rows.hovered { + self.prefetch_page(space); + } self.ui.close(); rows.open } /// Hands the page the events routed to its box, in its device pixels. fn page_events(&mut self, events: Vec) -> Result<(), Box> { + // The page shown is on its way out. + if self.loading().is_some() { + return Ok(()); + } let read_only = self.session.as_ref().is_some_and(Session::read_only); let scale = self.ui.scale(); let corner = self.ui.rect(page()).unwrap_or_default(); @@ -2706,16 +2900,39 @@ impl State { fn apply(&mut self, command: Command) -> Result<(), Box> { match command { Command::OpenSection(library, path) => { + let tab = match &self.session { + // Back to the open section before another one opened. + Some(session) + if self.switching.is_some() + && Arc::ptr_eq(&session.library, &library) + && session.tabs[session.tab].path == path => + { + self.stop_loading(); + return Ok(()); + } + Some(session) if session.library.location == library.location => { + session.tabs.iter().position(|tab| tab.path == path) + } + _ => None, + }; + self.switching = Some((tab, Instant::now())); let notify = notify(self.proxy.clone()); let last = self.last_pages.get(&library.key(&path)).copied(); self.load(move || { + let start = Instant::now(); let section = library.open(&path, notify)?; + lap("switch section open", start); let (session, page) = read_session(section, library, path, last)?; Ok(Loaded::Section(Box::new(session), page)) }); } Command::OpenPage(space) => { let session = self.session.as_ref().ok_or("No section is open")?; + if self.switching.is_some() && session.space == space && session.version.is_none() { + self.stop_loading(); + return Ok(()); + } + self.switching = Some((None, Instant::now())); let read = session.reader(space); self.load(move || Ok(Loaded::Page(space, read()?))); } @@ -2773,12 +2990,17 @@ impl State { } Command::OpenFromServer(location) => self.open_server(location.as_deref()), Command::NewNotebook => self.new_notebook()?, - Command::UseICloud => self.use_icloud()?, + Command::NewICloudNotebook => self.new_icloud_notebook(), Command::OpenGuide => self.open_guide()?, #[cfg(target_os = "linux")] Command::Install => desktop::install(), Command::CloseNotebook(library) => self.close_notebook(&library), - Command::Structure(library, change) => self.restructure(library, change), + Command::Structure(library, change) => { + if let Some(undo) = undo::structure_undo(&library, &change) { + self.undo.record(undo); + } + self.restructure(library, change); + } // The index follows the section's page list. Command::NewPage { under } => { self.new_page(under)?; @@ -2812,8 +3034,12 @@ impl State { self.loading += 1; let (id, sender, redraw) = (self.loading, self.loads.0.clone(), self.redraw.clone()); let layouts = Arc::clone(&self.layouts); + let requested = Instant::now(); + let scenes = Arc::clone(&self.prefetch.scenes); + let open = self.session.as_ref().map(Session::key); std::thread::spawn(move || { let laid = read().and_then(|loaded| { + lap("switch read", requested); let (shown, page) = match loaded { Loaded::Section(session, page) => (Shown::Section(session), page), Loaded::Created(session, page) => (Shown::Created(session), page), @@ -2822,8 +3048,28 @@ impl State { Loaded::Library(library, path) => return Ok(Laid::Library(library, path)), }; let mut engine = layouts.lock().map_err(|_| "Page layout failed")?; - let (scene, editor) = PageScene::from_page(page, &mut engine)?; + let start = Instant::now(); + let key = match &shown { + Shown::Section(session) | Shown::Created(session) => { + Some((session.key(), session.space)) + } + Shown::Page(space) => open.map(|open| (open, *space)), + Shown::Version(..) => None, + }; + // A page shown or prepared lately keeps the pictures it drew. + let kept = + key.and_then(|(key, space)| prefetch::Prefetch::take(&scenes, &key, space)); + let (scene, editor) = match kept { + Some(mut scene) => { + let mut editor = CanvasEditor::from_page(page, &mut engine)?; + scene.refresh(&mut editor, &mut engine)?; + (scene, editor) + } + None => PageScene::from_page(page, &mut engine)?, + }; + lap("switch layout", start); Ok(Laid::Page(Box::new(Opening { + requested, loaded: shown, scene: (scene, [0.0; 2]), editor, @@ -2846,11 +3092,19 @@ impl State { Ok(Laid::Page(opening)) => self.opening = Some(*opening), Ok(Laid::Library(library, path)) => self.adopt(library, path.as_deref())?, Err(error) => { + if id == self.loading { + self.switching = None; + } eprintln!("{error}"); platform::alert("Couldn't open", &error); } } } + let paper = canvas::gpu::Paper { + color: self.ui.theme.paper, + ink: self.ui.theme.paper_ink, + }; + self.prefetch.draw(&self.view, paper, &self.redraw); let Some(mut opening) = self.opening.take() else { return Ok(()); }; @@ -2867,15 +3121,27 @@ impl State { self.opening = Some(opening); return Ok(()); } + lap("switch pictures", opening.since); + let requested = opening.requested; // A recording goes on the page it started on. self.stop_recording(true)?; // The page shown so far keeps what was typed while the next one loaded. self.persist()?; + let mut leaving = self + .session + .as_ref() + .filter(|session| !session.read_only()) + .map(|session| session.space); if let Some(session) = &self.session { - let key = session.library.key(&session.tabs[session.tab].path); + let key = session.key(); self.places .insert((key.clone(), session.space), self.view.place()); - self.last_pages.insert(key, session.space); + self.last_pages.insert(key.clone(), session.space); + if session.version.is_none() + && let Some(scene) = self.view.scene.take() + { + self.prefetch.keep(key, session.space, scene); + } } let created = matches!(opening.loaded, Shown::Created(_)); match opening.loaded { @@ -2894,14 +3160,23 @@ impl State { Some(library) => *library = Arc::clone(&session.library), None => self.notebooks.push(Arc::clone(&session.library)), } - // The notebook's background sync takes the section over once it is closed. + if session.library.in_icloud() && session.library.downloading() { + self.fetch(Arc::clone(&session.library), None); + } + // The section left stays open a while for coming back to; the notebook's + // background sync takes it over once it is closed. if let Some(previous) = self.session.replace(*session) { - let section = previous.section; - std::thread::spawn(move || { - if let Err(error) = section.close() { - eprintln!("Synchronization stopped: {error}"); - } - }); + let library = &previous.library; + if self.notebooks.iter().any(|open| Arc::ptr_eq(open, library)) { + library.keep(&previous.tabs[previous.tab].path, previous.section); + } else { + let section = previous.section; + std::thread::spawn(move || { + if let Err(error) = section.close() { + eprintln!("Synchronization stopped: {error}"); + } + }); + } } self.sectionless = None; if other_notebook { @@ -2918,15 +3193,41 @@ impl State { session.change = None; // Pages created, moved or deleted since the list was read. session.pages = session.section.pages()?; + leaving = leaving.filter(|left| session.pages.iter().any(|(s, ..)| s == left)); } } let place = self.session.as_ref().and_then(|session| { let key = session.library.key(&session.tabs[session.tab].path); self.places.get(&(key, session.space)).copied() }); - self.view.open(opening.editor, Some(opening.scene), place); + let left = self.view.open(opening.editor, Some(opening.scene), place); + // Each page takes up the history it was left with. + if let Some(leaving) = leaving { + self.undo.park(leaving, left); + } + let shown = self + .session + .as_ref() + .filter(|session| !session.read_only()) + .map(|session| session.space); + if let Some(parked) = shown.and_then(|space| self.undo.resume(space)) { + self.view.resume(parked)?; + } + lap("switch shown", requested); + self.switching = None; self.visited(); + if let Some((space, action)) = self.after_open.take() + && let Some(session) = self.session.as_ref().filter(|session| session.space == space) + { + let target = menus::Target::Page { + library: Arc::clone(&session.library), + path: session.tabs[session.tab].path.clone(), + space, + }; + self.act_on(target, action); + } self.opened()?; + self.prefetch_around(); self.refind(true)?; if self.title_focus.take().is_some_and(|space| { self.session @@ -2958,6 +3259,9 @@ impl State { /// the open section, now at catalog `path` in it, stays open, reopened where it moved. /// Without `path` the notebook has no sections left, and one of its shows none. fn adopt(&mut self, library: Arc, path: Option<&str>) -> Result<(), Box> { + if library.in_icloud() && library.downloading() { + self.fetch(Arc::clone(&library), None); + } match self .notebooks .iter_mut() @@ -2978,9 +3282,10 @@ impl State { if let Some(session) = self.session.take() { session.section.close()?; } - self.sectionless = Some(library); + self.sectionless = Some(Arc::clone(&library)); self.title(); } + self.show_arrived(&library); return Ok(()); }; let Some(session) = &mut self.session else { @@ -3014,6 +3319,9 @@ impl State { /// Follows a page shown in place of another. fn opened(&mut self) -> Result<(), Box> { + if let Err(error) = self.wear_theme() { + eprintln!("Restyling the page failed: {error}"); + } // A page a search result shows leaves the keys with the search. if !search::takes_text(self.ui.focused()) { self.ui.set_focus(Some(page())); @@ -3220,6 +3528,9 @@ impl State { session.pages = session.section.pages()?; } self.edited(vec![space]); + let editor = &self.view.editor; + self.undo + .edited(space, editor.history_depth(), editor.typing().is_some()); Ok(()) } @@ -3610,19 +3921,32 @@ impl State { self.view.tag_art = session.library.tag_art(); } let theme = &self.ui.theme; - let page_primitives = self.view.primitives(TextColors { - caret: theme.caret, - selection: if self.page_focused { - theme.selection - } else { - theme.inactive_selection - }, - paper, - })?; + let scale = self.ui.scale(); + let corner = self.ui.rect(page()).unwrap_or_default(); + // A page taking a while to open shows its outline, drawn in points from the corner. + let (page_primitives, viewport) = match self.loading() { + Some(since) => ( + prefetch::skeleton(paper, since), + canvas::gpu::Viewport { + scale, + origin: [0.0; 2], + ..self.view.viewport + }, + ), + None => ( + self.view.primitives(TextColors { + caret: theme.caret, + selection: if self.page_focused { + theme.selection + } else { + theme.inactive_selection + }, + paper, + })?, + self.view.viewport, + ), + }; lap("page primitives", start); - let scale = self.ui.scale(); - let corner = self.ui.rect(page()).unwrap_or_default(); - let viewport = self.view.viewport; let interface = self.ui.layers(); let layers: Vec<_> = interface .iter() @@ -3673,17 +3997,18 @@ impl State { (x0..x1).contains(&x) && (y0..y1).contains(&y) } + /// Window point `point` in the page's device pixels. + fn page_point(&self, point: [f32; 2]) -> [f32; 2] { + let [left, top, ..] = self.ui.rect(page()).unwrap_or_default(); + let scale = self.ui.scale(); + [(point[0] - left) * scale, (point[1] - top) * scale] + } + /// Zooms the page by `factor` about the pointer, when it is over the page. fn pinch(&mut self, factor: f32) -> Result<(), Box> { if self.over_page() { // The pointer here, as the page's own lags by the moves queued for the next frame. - let [left, top, ..] = self.ui.rect(page()).unwrap_or_default(); - let scale = self.ui.scale(); - let anchor = [ - (self.pointer[0] - left) * scale, - (self.pointer[1] - top) * scale, - ]; - let response = self.view.pinch(factor, anchor)?; + let response = self.view.pinch(factor, self.page_point(self.pointer))?; self.respond(response); } Ok(()) @@ -3755,10 +4080,15 @@ impl State { } return; } - // On macOS the menu bar takes the chords of the items it enables first. + if self.record_chord(&event) { + return; + } + // On macOS the menu bar takes the chords of the items it enables first. The open + // palette takes ⌘K, Link's chord, for its actions. if let ui::Event::Key { key, .. } = &event && let Some(id) = commands::find(&ui::edit_key(key), ui::edit_modifiers(self.ui.modifiers())) + && !(id == commands::Id::Link && self.ui.popup_open(palette::id())) { self.choose(commands::Choice::Command(id)); } else { @@ -3977,6 +4307,8 @@ struct Rows { context: Option<(ExGuid, [f32; 2])>, /// The page held down, which a drag moves. held: Option, + /// A page other than the open one under the pointer. + hovered: Option, /// A page version clicked: the page, and the version's context. version: Option<(ExGuid, ExGuid)>, /// The page whose tab was pressed twice in a row, which renames it. @@ -4013,7 +4345,7 @@ fn page_rows( section: &ui::Section, session: &Session, found: &HashSet, - rounding: f32, + shape: (f32, bool), mut renaming: Option<&mut rename::Renaming>, dragged: Option, ) -> Rows { @@ -4058,7 +4390,7 @@ fn page_rows( section, session, found, - rounding, + shape, renaming.as_deref_mut(), (space, title, *level), shift, @@ -4085,7 +4417,7 @@ fn page_rows( section, session, found, - rounding, + shape, renaming, (space, title, *level), top - laid, @@ -4106,7 +4438,7 @@ fn page_row( section: &ui::Section, session: &Session, found: &HashSet, - rounding: f32, + shape: (f32, bool), renaming: Option<&mut rename::Renaming>, (space, title, level): (&ExGuid, &String, u32), shift: f32, @@ -4135,8 +4467,11 @@ fn page_row( if selected && !lifted { rows.open = Some(ui.id(space)); } - let (signal, kept) = page_tab(ui, theme, section, space, tab, selected, rounding); + let (signal, kept) = page_tab(ui, theme, section, space, tab, selected, shape); rows.kept = rows.kept.or(kept); + if signal.hovered && !selected { + rows.hovered = Some(*space); + } if signal.clicked && !selected { rows.clicked = Some(*space); } @@ -4176,7 +4511,7 @@ fn page_row( rows.open = Some(ui.id(version.context)); } height += ROW; - if page_tab(ui, theme, &muted, &version.context, tab, selected, rounding) + if page_tab(ui, theme, &muted, &version.context, tab, selected, shape) .0 .clicked && !selected @@ -4208,7 +4543,7 @@ fn page_row( rows.open = Some(ui.id(version.space)); } height += ROW; - if page_tab(ui, theme, &muted, &version.space, tab, selected, rounding) + if page_tab(ui, theme, &muted, &version.space, tab, selected, shape) .0 .clicked && !selected @@ -4237,8 +4572,9 @@ struct PageTab<'a> { lifted: bool, } -/// A page's tab down the frame's right side: the open one is the page's colour and joins -/// it, the others float free of it as pills rounded like the page. +/// A page's tab down the frame's side, the left where `shape` says so beside its `rounding`: +/// the open one is the page's colour and joins it, the others float free of it as pills +/// rounded like the page. fn page_tab( ui: &mut Ui, theme: &Theme, @@ -4246,7 +4582,7 @@ fn page_tab( id: &ExGuid, tab: PageTab, selected: bool, - rounding: f32, + (rounding, left): (f32, bool), ) -> (ui::Signal, Option) { // A rename field's text stands where the label did. let pad = 10.0 + 16.0 * tab.indent as f32 @@ -4280,13 +4616,20 @@ fn page_tab( fill } }; + // A pill stands off the page by its margin and off the window's edge a little more. + let [page_side, edge_side] = [PILL_MARGIN, 6.0]; + let [start, end] = if left { + [edge_side, page_side] + } else { + [page_side, edge_side] + }; let spec = Spec { fill: Some(tint(section.tab)), hover_fill: Some(tint(section.hover())), radius: rounding, - inset: [PILL_MARGIN, 0.0, 6.0, ROW_GAP], + inset: [start, 0.0, end, ROW_GAP], // The label keeps its place as the tab opens and closes. - pad: [pad - PILL_MARGIN, 0.0], + pad: [if left { pad } else { pad - start }, 0.0], ..spec }; let color = if tab.dim { @@ -4743,6 +5086,7 @@ fn artwork(id: commands::Id) -> Option<&'static [&'static str]> { Cmd::Toggle(Toggle::Subscript) => art::SUBSCRIPT, Cmd::Toggle(Toggle::Superscript) => art::SUPERSCRIPT, Cmd::ClearFormatting => art::CLEAR_FORMATTING, + Cmd::Styles => art::STYLES, Cmd::Highlight => art::HIGHLIGHTER, Cmd::FontColor => art::FONT_COLOR, Cmd::Bullets => art::BULLETS, @@ -4971,7 +5315,7 @@ impl ApplicationHandler for App { let event = match event { UserEvent::Picture(bytes) => { if let Some(state) = &mut self.state { - if let Err(error) = state.insert_picture(bytes) { + if let Err(error) = state.insert_picture(bytes, None) { eprintln!("{error}"); } state.window.request_redraw(); @@ -5005,6 +5349,12 @@ impl ApplicationHandler for App { } return; } + UserEvent::ICloudFolder => { + if let Some(state) = &mut self.state { + state.list_icloud(); + } + return; + } UserEvent::ICloudAccount => { if let Some(state) = &mut self.state { state.icloud_account_changed(); @@ -5012,6 +5362,21 @@ impl ApplicationHandler for App { } return; } + UserEvent::Open(paths) => { + match &mut self.state { + Some(state) => { + for path in paths { + state.open_path(&path); + } + state.window.set_minimized(false); + state.window.focus_window(); + state.window.request_redraw(); + } + // A cold launch's documents arrive before the window opens. + None => self.opening.extend(paths), + } + return; + } UserEvent::Update => { if let Some(state) = &mut self.state { state.updated(); @@ -5115,10 +5480,19 @@ impl ApplicationHandler for App { if self.state.is_some() { return; } + let mut input = self.input.take().unwrap(); + // The window starts at the notebook asked for rather than switching to it. + for path in &self.opening { + if let library::Located::Notebook { root, .. } = library::locate(path) + && let Err(error) = input.show(&root) + { + eprintln!("Cannot open {}: {error}", root.display()); + } + } match pollster::block_on(State::new( event_loop, self.proxy.clone(), - self.input.take().unwrap(), + input, &self.substitutes, self.screenshot.is_none(), self.launch.take().expect("The window opens once"), @@ -5129,7 +5503,12 @@ impl ApplicationHandler for App { self.startup_error = state.screenshot(prefix).err(); event_loop.exit(); } - _ => self.state = Some(state), + _ => { + for path in self.opening.drain(..) { + state.open_path(&path); + } + self.state = Some(state); + } }, Err(error) => { self.startup_error = Some(error); @@ -5193,6 +5572,9 @@ impl ApplicationHandler for App { if !focused { // Someone switching to another device finds their edits there. state.publish_now(std::time::Duration::ZERO); + } else { + // Notebooks another device added or removed meanwhile. + state.list_icloud(); } state.window.request_redraw(); } @@ -5247,7 +5629,7 @@ impl ApplicationHandler for App { WindowEvent::Ime(ime) => state.input(ui::Event::Ime(ime)), WindowEvent::DroppedFile(path) => { let at = platform::drop_point(&state.window); - state.attach(&path, at)?; + state.place_file(&path, at)?; state.window.request_redraw(); } WindowEvent::PinchGesture { delta, .. } => state.pinch(1.0 + delta as f32)?, @@ -5425,6 +5807,7 @@ fn launch() -> Result<(), Box> { let mut cache = None; let mut settings_file = None; let mut screenshot = None; + let mut opening = Vec::new(); while let Some(arg) = args.next() { if arg == "--substitute-font" { substitutes.push(PathBuf::from( @@ -5485,6 +5868,9 @@ fn launch() -> Result<(), Box> { .to_str() .ok_or("The page title must be valid Unicode.")?, )?); + } else if library::locate(Path::new(&arg)) != library::Located::Nothing { + // A file the desktop opens with Snowbound, as a double-clicked section. + opening.push(std::path::absolute(arg)?); } else { positional.push(arg); } @@ -5499,7 +5885,7 @@ fn launch() -> Result<(), Box> { } if positional.len() > 2 { return Err( - "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]..." + "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]..." .into(), ); } @@ -5531,16 +5917,14 @@ fn launch() -> Result<(), Box> { } else if editable { Input::Page(reference.unwrap()) } else if positional.is_empty() && reference.is_none() { - let mut locations = saved.notebooks.clone(); - let mut current = saved.current.clone(); + let mut input = Input::Notebooks { + locations: saved.notebooks.clone(), + current: saved.current.clone(), + }; if let Some(root) = notebook { - let location = std::path::absolute(root)?.to_string_lossy().into_owned(); - if !locations.contains(&location) { - locations.push(location.clone()); - } - current = Some(location); + input.show(&root)?; } - Input::Notebooks { locations, current } + input } else { Input::Notes { document: TextDocument::new( @@ -5552,10 +5936,22 @@ fn launch() -> Result<(), Box> { reference, } }; + #[cfg(not(target_os = "macos"))] + let instance = match (&input, &screenshot) { + (Input::Notebooks { .. }, None) => match instance::claim(&cache, &opening) { + Some(instance) => Some(instance), + None => return Ok(()), + }, + _ => None, + }; if screenshot.is_some() { screenshot::prepare(); } let event_loop = platform::event_loop(screenshot.is_some())?; + #[cfg(not(target_os = "macos"))] + if let Some(instance) = instance { + instance.serve(event_loop.create_proxy()); + } if let Some(script) = std::env::var_os("SNOWBOUND_REPLAY") { replay(std::fs::read_to_string(script)?, event_loop.create_proxy())?; } @@ -5568,6 +5964,7 @@ fn launch() -> Result<(), Box> { saved, cache, }), + opening, substitutes, screenshot, state: None, @@ -5589,6 +5986,26 @@ mod tests { use super::*; use canvas::document::TextPosition; + /// A notebook opened from the desktop is listed once and shown first, whether it was + /// listed already or not. + #[test] + fn an_opened_notebook_is_listed_once_and_shown() { + let root = std::env::temp_dir(); + let [listed, opened] = ["Listed", "Opened"].map(|name| root.join(name)); + let location = |path: &Path| path.to_string_lossy().into_owned(); + let mut input = Input::Notebooks { + locations: vec![location(&listed)], + current: None, + }; + input.show(&opened).unwrap(); + input.show(&listed).unwrap(); + let Input::Notebooks { locations, current } = input else { + unreachable!() + }; + assert_eq!(locations, [location(&listed), location(&opened)]); + assert_eq!(current, Some(location(&listed))); + } + /// The ops an editing session records reach the section through `apply` and read back /// as the editor's page. #[test] @@ -5778,4 +6195,80 @@ mod tests { theirs.close().unwrap(); std::fs::remove_dir_all(&directory).unwrap(); } + + /// Times opening each section of a notebook and each of its pages, phase by phase, as a + /// switch does: `SNOWBOUND_SWITCH_NOTEBOOK=FOLDER`, or a share through `ONESTORE_SMB_LAB` + /// (`HOST:PORT`, share `agent`) at `ONESTORE_SMB_LAB_ROOT` with `ONESTORE_SMB_LAB_USER` + /// and `ONESTORE_SMB_LAB_PASSWORD`. The first pass starts without replicas. + #[test] + #[ignore = "measures a notebook named by the environment"] + fn switch_timings() { + let cache = std::env::temp_dir().join(format!("snowbound-switch-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&cache); + let open = || match std::env::var("SNOWBOUND_SWITCH_NOTEBOOK") { + Ok(folder) => Library::notebook(&folder, &cache), + Err(_) => { + let user = std::env::var("ONESTORE_SMB_LAB_USER").ok(); + let mount = library::Mount { + server: std::env::var("ONESTORE_SMB_LAB").unwrap(), + share: "agent".into(), + user: user.clone(), + domain: String::new(), + root: std::env::var("ONESTORE_SMB_LAB_ROOT").unwrap(), + }; + let login = library::Login { + user: user.unwrap_or_default(), + password: std::env::var("ONESTORE_SMB_LAB_PASSWORD").unwrap_or_default(), + domain: String::new(), + }; + Library::on_share(&mount.url(), mount, login, &cache).unwrap() + } + }; + let ms = |start: Instant| start.elapsed().as_secs_f64() * 1e3; + let paper = canvas::gpu::Paper { + color: [1.0; 4], + ink: [0.0, 0.0, 0.0, 1.0], + }; + let mut engine = TextEngine::default(); + for pass in ["cold", "warm"] { + let start = Instant::now(); + let library = Arc::new(open()); + eprintln!("{pass}\tnotebook\t{:.1}", ms(start)); + for tab in library.tabs("") { + let start = Instant::now(); + let section = library.open(&tab.path, || {}).unwrap(); + let opened = ms(start); + let start = Instant::now(); + let (session, page) = + read_session(section, Arc::clone(&library), tab.path.clone(), None).unwrap(); + let read = ms(start); + let start = Instant::now(); + let (mut scene, editor) = PageScene::from_page(page, &mut engine).unwrap(); + let laid = ms(start); + let start = Instant::now(); + scene.settle(Some(&editor), 2.0, paper); + eprintln!( + "{pass}\tsection {}\topen {opened:.1}\tread {read:.1}\tlayout {laid:.1}\tpictures {:.1}", + tab.name, + ms(start) + ); + for (space, title, _) in session.pages.iter().skip(1) { + let start = Instant::now(); + let page = session.section.page(*space).unwrap(); + let read = ms(start); + let start = Instant::now(); + let (mut scene, editor) = PageScene::from_page(page, &mut engine).unwrap(); + let laid = ms(start); + let start = Instant::now(); + scene.settle(Some(&editor), 2.0, paper); + eprintln!( + "{pass}\tpage {title}\tread {read:.1}\tlayout {laid:.1}\tpictures {:.1}", + ms(start) + ); + } + session.section.close().unwrap(); + } + } + let _ = std::fs::remove_dir_all(&cache); + } } diff --git a/crates/snowbound/src/manage.rs b/crates/snowbound/src/manage.rs index eedc6bb5272acdc0401b01f9bfe1298a8ebe988a..6c3c976e2cc2bf94dfbd0cf1d02b364f7cb83bf1 100644 --- a/crates/snowbound/src/manage.rs +++ b/crates/snowbound/src/manage.rs @@ -1,7 +1,7 @@ //! Changes to notebooks, sections, section groups and pages, as OneNote 2010 makes them: //! structure through `notebook::session::Notebook`, pages as ops on the open section. -use crate::{Command, Library, Loaded, State, notify, platform, read_session}; +use crate::{Command, Library, Loaded, State, notify, platform, read_session, undo::Change}; use canvas::template::Template; use notebook::session::Notebook; use onestore::{ @@ -9,7 +9,7 @@ use onestore::{ op::{Edit, Op, PageOp, SectionOp}, page::{Image, Page, PageObject}, }; -use std::{error::Error, sync::Arc}; +use std::{error::Error, path::Path, sync::Arc}; /// A change to a notebook's sections and groups, by catalog path. pub enum Structure { @@ -37,6 +37,44 @@ pub enum Structure { folder: String, paths: Vec, }, + /// Moves a section or group back into a folder, which then takes the order `paths`, as + /// Undo takes back a move. + Return { + path: String, + folder: String, + paths: Vec, + }, + /// Colours a section's tab (COLORREF); none is OneNote's None. + Color { + path: String, + color: Option, + }, + /// Notebook Properties: the display name on this computer, and a colour picked for the + /// notebook. + Properties { + name: String, + color: Option, + }, +} + +/// New iCloud Notebook while it is open: the name typed, and why the last was refused. +pub struct Naming { + name: String, + problem: Option, +} + +fn naming() -> ui::Id { + ui::Id::ROOT.child("new icloud notebook") +} + +fn naming_field() -> ui::Id { + naming().child("name") +} + +/// Whether the notebook at `location` is a folder at the top of the app's iCloud Drive folder, +/// which the sidebar lists as long as it is there. +pub fn in_icloud_folder(location: &str) -> bool { + crate::icloud::folder().is_some_and(|root| Path::new(location).parent() == Some(&root)) } /// `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 { } impl State { - /// A page OneNote 2010 would create now: titled, with its date and time. - fn dated_page(&self, before: Option) -> Result> { - let creation = PageCreation::new(before, Some(""), &self.author)?; + /// A page OneNote 2010 would create now: titled in the Default font's face, with its date + /// and time. + pub(crate) fn dated_page( + &self, + before: Option, + ) -> Result> { + let creation = PageCreation::new(before, Some(""), &self.author)?.titled_in( + &self.view.editor.default_font.face, + self.view.editor.default_font.color, + )?; let [date, time] = platform::date_text(creation.created()); Ok(creation.dated(&date, &time)?) } @@ -113,6 +158,12 @@ impl State { ops, }, )?; + let shown = session.space; + self.made(Change::Delete { + pages: vec![space], + show: Some(shown), + created: true, + }); self.title_focus = Some(space); self.commands.push(Command::OpenPage(space)); Ok(()) @@ -211,23 +262,292 @@ impl State { self.create_notebook(root) } - /// Opens the notebooks in the app's own iCloud Drive folder, or makes one there. - pub(crate) fn use_icloud(&mut self) -> Result<(), Box> { - let folder = crate::icloud::folder().ok_or("iCloud Drive is off")?; - let mut notebooks: Vec = std::fs::read_dir(&folder)? - .flatten() - .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())) - .filter(|entry| !entry.file_name().to_string_lossy().starts_with('.')) - .map(|entry| entry.path()) + /// New iCloud Notebook: asks for a name, the first free of "iCloud Notebook", "iCloud + /// Notebook 2"…, for a notebook at the top of the app's iCloud Drive folder. + pub(crate) fn new_icloud_notebook(&mut self) { + let taken: Vec = self + .notebooks + .iter() + .map(|library| library.name.clone()) .collect(); - notebooks.sort(); - if notebooks.is_empty() { - return self.create_notebook(folder.join("My Notebook")); + self.new_icloud = Some(Naming { + name: unused(&taken, "iCloud Notebook", false), + problem: None, + }); + self.ui.open_popup(naming()); + self.ui.focus_all(naming_field()); + } + + /// Builds New iCloud Notebook while it is open. + pub(crate) fn new_icloud_dialog(&mut self) { + use ui::{Anchor, Axis, Spec, children, fill, px}; + let Some(dialog) = &mut self.new_icloud else { + return; + }; + if !self.ui.popup_open(naming()) { + self.new_icloud = None; + return; } - for notebook in notebooks { - self.open_notebook(notebook.to_string_lossy().into_owned(), None); + let ui = &mut self.ui; + let theme = ui.theme.clone(); + let row = theme.font_size * 2.0; + let entered = + ui::popup::navigation(ui, &[naming_field()], &[winit::keyboard::NamedKey::Enter]) + .contains(&winit::keyboard::NamedKey::Enter); + ui.open_as( + naming(), + Spec { + axis: Axis::Y, + size: [px(320.0), children()], + fill: Some(theme.popup), + border: Some(theme.chip), + shadow: Some(theme.shadow), + radius: 8.0, + pad: [16.0, 12.0], + gap: 6.0, + anchor: Some(Anchor::Dialog), + role: Some(accesskit::Role::Dialog), + ..Spec::default() + }, + ); + if let Some(node) = ui.access(naming()) { + node.set_label("New iCloud Notebook"); } - Ok(()) + ui.leaf( + "title", + Spec { + size: [fill(), px(row)], + text: Some("New iCloud Notebook"), + bold: true, + role: Some(accesskit::Role::Heading), + ..Spec::default() + }, + ); + ui::text_field( + ui, + naming_field(), + &mut dialog.name, + "", + Spec { + size: [fill(), px(row)], + fill: Some(theme.base), + border: Some(theme.accent), + radius: 4.0, + pad: [6.0, 0.0], + ..Spec::default() + }, + ); + crate::name(ui, naming_field(), "Name"); + if let Some(problem) = &dialog.problem { + ui.leaf( + "problem", + Spec { + size: [fill(), ui::fit()], + text: Some(problem), + overflow: ui::Overflow::Wrap, + pad: [0.0, 4.0], + role: Some(accesskit::Role::Status), + ..Spec::default() + }, + ); + } + ui.open( + "buttons", + Spec { + size: [fill(), children()], + pad: [0.0, 8.0], + gap: 8.0, + ..Spec::default() + }, + ); + ui.leaf( + "space", + Spec { + size: [fill(), px(1.0)], + ..Spec::default() + }, + ); + let cancel = ui::button(ui, "cancel", "Cancel").clicked; + let create = ui::button(ui, "create", "Create").clicked || entered; + ui.close(); + ui.close(); + if cancel { + self.ui.close_popup(naming()); + self.ui.set_focus(Some(crate::page())); + self.new_icloud = None; + } else if create { + let name = dialog.name.trim().to_owned(); + let folder = crate::icloud::folder(); + dialog.problem = match &folder { + None => Some("Turn on iCloud Drive in System Settings.".into()), + Some(_) if name.is_empty() => Some("Enter a name.".into()), + Some(_) if name.starts_with('.') || name.contains(['/', ':']) => { + Some("Use a name without “/”, “:” or a leading “.”.".into()) + } + Some(folder) if folder.join(&name).exists() => Some(format!( + "“{name}” is already in iCloud Drive. Choose another name." + )), + Some(_) => None, + }; + if let (None, Some(folder)) = (&dialog.problem, folder) { + self.ui.close_popup(naming()); + self.new_icloud = None; + if let Err(error) = self.create_notebook(folder.join(name)) { + platform::alert("Couldn't create the notebook", &error.to_string()); + } + } + } + } + + /// Lists the folders at the top of the app's iCloud Drive folder in the sidebar, each a + /// notebook, as they come and go, and brings down what iCloud Drive keeps elsewhere of + /// every notebook in it. + pub(crate) fn list_icloud(&mut self) { + let proxy = self.proxy.clone(); + // Listing a folder iCloud Drive has not brought down yet waits for it. + std::thread::spawn(move || { + let listed = crate::icloud::folder().and_then(|root| { + let mut listed: Vec = std::fs::read_dir(&root) + .ok()? + .flatten() + .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())) + .filter(|entry| !entry.file_name().to_string_lossy().starts_with('.')) + .map(|entry| entry.path().to_string_lossy().into_owned()) + .collect(); + listed.sort(); + Some(listed) + }); + let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| { + if let Some(listed) = listed { + state.listed_icloud(listed); + } + for library in state.notebooks.clone() { + if library.in_icloud() { + state.fetch(library, None); + } + } + Ok(()) + }))); + }); + } + + /// Takes `listed`, the notebook folders at the top of the app's iCloud Drive folder, in + /// place of those the sidebar lists from it. + fn listed_icloud(&mut self, listed: Vec) { + let gone: Vec> = self + .notebooks + .iter() + .filter(|library| { + in_icloud_folder(&library.location) && !listed.contains(&library.location) + }) + .cloned() + .collect(); + for library in gone { + eprintln!("DEBUG gone {} listed {listed:?}", library.location); + self.close_notebook(&library); + } + for location in listed { + if self.notebooks.iter().any(|open| open.location == location) + || !self.icloud_reading.insert(location.clone()) + { + continue; + } + let (cache, proxy) = (self.cache.clone(), self.proxy.clone()); + std::thread::spawn(move || { + let library = Arc::new(Library::notebook(&location, &cache)); + let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| { + state.icloud_reading.remove(&location); + if state.notebooks.iter().any(|open| open.location == location) { + return Ok(()); + } + state.notebooks.push(Arc::clone(&library)); + state.save_settings(); + state.show_arrived(&library); + state.fetch(library, None); + Ok(()) + }))); + }); + } + } + + /// Shows `library`, a notebook that just arrived or whose sections did, where no other + /// notebook is shown: at its first section, or without one while none is here yet. + pub(crate) fn show_arrived(&mut self, library: &Arc) { + if self.session.is_some() + || self + .sectionless + .as_ref() + .is_some_and(|shown| shown.location != library.location) + { + return; + } + let first = library.first_section(); + self.sectionless = Some(Arc::clone(library)); + self.title(); + if let Some(path) = first { + self.commands + .push(Command::OpenSection(Arc::clone(library), path)); + } + } + + /// Brings down the files of `library`, a notebook in iCloud Drive, that iCloud Drive keeps + /// elsewhere, `missing` of them when last asked, and reads the notebook again as they + /// arrive, until all have. + pub(crate) fn fetch(&mut self, library: Arc, missing: Option) { + let Some(root) = library.folder().map(Path::to_owned) else { + return; + }; + if missing.is_none() && !self.icloud_reading.insert(library.location.clone()) { + return; + } + let proxy = self.proxy.clone(); + std::thread::spawn(move || { + if missing.is_some() { + std::thread::sleep(std::time::Duration::from_secs(2)); + } + let now = crate::icloud::download(&root); + let arrived = missing.map_or(now == 0 && library.downloading(), |before| now < before); + let read = arrived + .then(|| { + library + .reopen() + .map(|notebook| Arc::new(library.with(notebook))) + }) + .and_then(|read| { + read.inspect_err(|error| eprintln!("{}: {error}", library.location)) + .ok() + }); + let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| { + let Some(listed) = state + .notebooks + .iter() + .find(|open| open.location == library.location) + .cloned() + else { + state.icloud_reading.remove(&library.location); + return Ok(()); + }; + let current = match read { + Some(read) => { + let path = state + .session + .as_ref() + .filter(|session| session.library.location == read.location) + .map(|session| session.tabs[session.tab].path.clone()) + .filter(|path| read.contains(path)); + state.adopt(Arc::clone(&read), path.as_deref())?; + read + } + None => listed, + }; + if now == 0 { + state.icloud_reading.remove(¤t.location); + } else { + state.fetch(current, Some(now)); + } + Ok(()) + }))); + }); } /// Creates a notebook in the new folder `root` and opens it. @@ -252,6 +572,7 @@ impl State { /// folders go with the account. Replicas holding edits stay, the open section's also /// exported as a recovery archive; signing in again and reopening publishes them. pub(crate) fn icloud_account_changed(&mut self) { + eprintln!("DEBUG account changed"); let closing: Vec> = self .notebooks .iter() @@ -281,13 +602,14 @@ impl State { for library in closing { self.close_notebook(&library); } - crate::icloud::look_up(notify(self.proxy.clone())); + crate::icloud::look_up(crate::icloud_listed(self.proxy.clone())); } /// Closes `library`: its files stay, its offline copies go unless edits wait in them, /// and the sidebar and the next launch leave it out. pub(crate) fn close_notebook(&mut self, library: &Arc) { self.notebooks.retain(|open| !Arc::ptr_eq(open, library)); + self.undo.close(&library.location); if let Some(background) = &library.background { background.discard(); } @@ -336,6 +658,8 @@ impl State { .map(|session| session.tabs[session.tab].path.clone()); let notify = notify(self.proxy.clone()); self.load(move || { + // A section kept open would hold a file the change moves. + library.close_kept(); let mut notebook = library.reopen()?; let names = |notebook: &Notebook, folder: &str| -> Vec { let mut folders = vec![notebook.catalog()]; @@ -356,6 +680,11 @@ impl State { } Vec::new() }; + // A notebook not shown stays so through a change of its colours or name. + let stays = matches!( + change, + Structure::Color { .. } | Structure::Properties { .. } + ); // The open section where the change leaves it, and the section to show. let (followed, created) = match change { Structure::NewSection { folder } => { @@ -391,10 +720,36 @@ impl State { notebook.reorder(&folder, &paths)?; (current, None) } + Structure::Return { + path, + folder, + paths, + } => { + let moved = notebook.move_entry(&path, &folder)?; + let paths: Vec<&str> = paths.iter().map(String::as_str).collect(); + notebook.reorder(&folder, &paths)?; + (current.map(|current| follow(¤t, &path, &moved)), None) + } + Structure::Color { path, color } => { + notebook.set_section_color(&path, color)?; + (current, None) + } + Structure::Properties { name, color } => { + if name != library.name { + library.set_display_name(&name)?; + } + if let Some(color) = color { + notebook.set_color(color)?; + } + (current, None) + } }; let fresh = created.is_some(); let open = created.or(followed.clone()); let library = Arc::new(library.with(notebook)); + if stays && followed.is_none() { + return Ok(Loaded::Library(library, None)); + } let Some(path) = shown(&library, open) else { return Ok(Loaded::Library(library, None)); }; @@ -416,115 +771,38 @@ impl State { /// Deletes pages of the open section as OneNote 2010 does: each goes to the notebook's /// recycle bin, then leaves the section. A section left without pages gains a new one. pub(crate) fn delete_pages(&mut self, spaces: Vec) -> Result<(), Box> { - self.persist()?; - let session = self.session.as_ref().ok_or("No section is open")?; - let pages: Vec = spaces - .iter() - .map(|space| session.section.page(*space)) - .collect::>()?; - let remaining: Vec = session - .pages - .iter() - .map(|(space, ..)| *space) - .filter(|space| !spaces.contains(space)) - .collect(); - // OneNote shows the page after the first deleted one, or the one before it. - let at = session - .pages - .iter() - .position(|(space, ..)| spaces.contains(space)) - .unwrap_or_default(); - let next = session.pages[at..] - .iter() - .chain(session.pages[..at].iter().rev()) - .map(|(space, ..)| *space) - .find(|space| remaining.contains(space)); - let mut ops = vec![Op::Section(SectionOp::Delete(spaces))]; - let next = match next { - Some(next) => next, - None => { - let creation = self.dated_page(None)?; - let space = creation.space(); - ops.push(Op::Section(SectionOp::Create(creation))); - space - } - }; - let notebook = - matches!(session.library.notebook, Ok(Some(_))).then(|| Arc::clone(&session.library)); - let replica = Arc::clone(session.section.replica()); - let author = self.author.clone(); - self.load(move || { - if let Some(library) = notebook { - library.reopen()?.recycle_pages(&pages, &author)?; - } - replica.apply( - &author, - Edit { - at: crate::filetime(), - ops, - }, - )?; - Ok(Loaded::Page(next, replica.page(next)?)) - }); - Ok(()) + self.change_pages(Change::Delete { + pages: spaces, + show: None, + created: false, + }) } /// Moves page `space` of the open section to the end of the section at catalog `path`, /// as OneNote moves a page dropped on a section's tab: the page keeps its identity, /// title, date and content there and leaves this section. pub(crate) fn move_page(&mut self, space: ExGuid, path: String) -> Result<(), Box> { - self.persist()?; let session = self.session.as_ref().ok_or("No section is open")?; - let import = notebook::session::moved(&session.section.page(space)?, &self.author)?; - let at = session - .pages - .iter() - .position(|(listed, ..)| *listed == space) - .unwrap_or_default(); - let next = session.pages[at + 1..] - .iter() - .chain(session.pages[..at].iter().rev()) - .map(|(listed, ..)| *listed) - .next(); - let mut ops = vec![Op::Section(SectionOp::Delete(vec![space]))]; - let next = match next { - Some(next) => next, - None => { - let fresh = self.dated_page(None)?; - let space = fresh.space(); - ops.push(Op::Section(SectionOp::Create(fresh))); - space - } - }; - let library = Arc::clone(&session.library); - let replica = Arc::clone(session.section.replica()); - let author = self.author.clone(); - self.load(move || { - let target = library.open(&path, || {})?; - target.replica().apply( - &author, - Edit { - at: crate::filetime(), - ops: vec![import], - }, - )?; - target.close()?; - replica.apply( - &author, - Edit { - at: crate::filetime(), - ops, - }, - )?; - Ok(Loaded::Page(next, replica.page(next)?)) - }); - Ok(()) + let target = session + .library + .section_identity(&path) + .ok_or("That section is not in the notebook")?; + self.change_pages(Change::Send { + page: space, + target, + }) } /// Moves or indents pages of the open section, in order. pub(crate) fn edit_pages(&mut self, edits: Vec) -> Result<(), Box> { self.persist()?; let session = self.session.as_mut().ok_or("No section is open")?; + let order = session + .pages + .iter() + .map(|(space, _, level)| (*space, *level)) + .collect(); + let moved = edits.iter().map(PageEdit::space).collect(); session.section.apply( &self.author, Edit { @@ -533,6 +811,7 @@ impl State { }, )?; session.pages = session.section.pages()?; + self.made(Change::Arrange { order, moved }); Ok(()) } } diff --git a/crates/snowbound/src/meeting.rs b/crates/snowbound/src/meeting.rs index e2c3eb2d816872860f97450788a2e95cf38d02b2..200f7150482fea19400111740908958a48a3a4e7 100644 --- a/crates/snowbound/src/meeting.rs +++ b/crates/snowbound/src/meeting.rs @@ -102,6 +102,7 @@ pub fn content(page: &Page) -> Result> { Definition { kind: Kind::Style { name: Some("p".into()), + next: None, }, format: Format { language: None, diff --git a/crates/snowbound/src/menubar.rs b/crates/snowbound/src/menubar.rs index bb0175d6178053e1b92b132c05a20f3fae8efd1f..ce1ac69d08e6185b24372108814818ebd362ee43 100644 --- a/crates/snowbound/src/menubar.rs +++ b/crates/snowbound/src/menubar.rs @@ -68,7 +68,6 @@ const MENUS: &[Item] = &[ C(Id::ShowNotebook), S, C(Id::ExportPdf), - C(Id::ExportSectionPdf), S, C(Id::Print), ], @@ -158,6 +157,31 @@ const MENUS: &[Item] = &[ Item::Menu( "Format", &[ + Item::Menu( + "Style", + &[ + C(Id::Style(0)), + C(Id::Style(1)), + C(Id::Style(2)), + C(Id::Style(3)), + C(Id::Style(4)), + C(Id::Style(5)), + C(Id::Style(6)), + C(Id::Style(7)), + C(Id::Style(8)), + C(Id::Style(9)), + C(Id::Style(10)), + ], + ), + Item::Menu( + "Theme", + &[ + C(Id::Theme(crate::themes::Scope::Page)), + C(Id::Theme(crate::themes::Scope::Section)), + C(Id::Theme(crate::themes::Scope::Notebook)), + ], + ), + S, Item::Menu("Font", &[Item::Fonts]), Item::Menu("Size", &[Item::Sizes]), S, @@ -264,11 +288,10 @@ fn menu( for item in items { match item { Item::Command(id) => { - let command = commands::command(*id); choose( &Choice::Command(*id), - command.title, - command.mac.first().copied(), + commands::command(*id).title, + commands::chords(*id).first().copied(), ); } Item::System(title, action, chord) => { @@ -320,8 +343,12 @@ pub fn tags(mtm: MainThreadMarker, target: &AnyObject, menu: &NSMenu, tags: &[No menu.addItem(&NSMenuItem::separatorItem(mtm)); } for id in [Id::CustomizeTags, Id::RemoveTags, Id::FindTags] { - let command = commands::command(id); - choose(menu, id, command.title, command.mac.first().copied()); + choose( + menu, + id, + commands::command(id).title, + commands::chords(id).first().copied(), + ); } } @@ -451,7 +478,7 @@ mod tests { } // The keyboard's first chord for each command is on its menu item. for command in commands::COMMANDS { - let Some(chord) = command.mac.first() else { + let Some(chord) = commands::chords(command.id).first().copied() else { continue; }; let line = tree diff --git a/crates/snowbound/src/menus.rs b/crates/snowbound/src/menus.rs index 3d40fba8400791f67e3cbdf7d06f872cf1260420..318bec8efc14036f32ee84d7c228a9d41c870ec5 100644 --- a/crates/snowbound/src/menus.rs +++ b/crates/snowbound/src/menus.rs @@ -1,7 +1,12 @@ //! Context menus on pages, sections, section groups and notebooks, with OneNote 2010's //! commands in its words, and moving pages by dragging their tabs. -use crate::{Command, Library, State, manage::Structure, platform}; +use crate::{ + Command, Library, State, + commands::{self, Choice}, + manage::Structure, + platform, +}; use onestore::{ExGuid, PageEdit}; use std::sync::Arc; use ui::{Anchor, Id, popup::Item}; @@ -46,12 +51,94 @@ impl Drag { } } -/// What a context menu was opened on. +/// What a context menu, or the palette's actions, are on. +#[derive(Clone)] pub enum Target { - Page(ExGuid), - Section { library: Arc, path: String }, - Group { library: Arc, path: String }, + /// A page of a section, open or not. + Page { + library: Arc, + path: String, + space: ExGuid, + }, + Section { + library: Arc, + path: String, + }, + Group { + library: Arc, + path: String, + }, Notebook(Arc), + /// A notebook shown lately but closed since, by location. + Closed(String), + /// A server saved to reconnect to, by address. + Server(String), + Command(commands::Id), +} + +/// What can be done to a target, as its context menu and the palette's actions list it. +#[derive(Clone, Debug, PartialEq)] +pub enum Action { + Open, + Run, + Rename, + Delete, + /// Opens the submenu of the places `MoveTo` takes it. + Move, + /// Moves it to a section, or into a folder, by catalog path. + MoveTo(String), + CopyLink, + NewPage, + NewSubpage, + MakeSubpage, + PromoteSubpage, + Versions(bool), + NewSection, + NewGroup, + Reveal, + Close, + /// Opens the Themes dialog for it. + Theme, + /// Opens the notebook's sync status. + SyncStatus, + /// Opens the submenu of the colours `Color` gives a section. + Colors, + /// A section's colour, COLORREF; none is OneNote's None. + Color(Option), + Sync, + NewNotebook, + /// Moves a notebook up the notebook list, or down. + Raise(bool), + Properties, +} + +/// OneNote 2010's section and notebook colours, COLORREF, with their names in its Section +/// Color menu's order (`corpus/section-color/native`). +pub(crate) const SECTION_COLORS: [(u32, &str); 16] = [ + (0xe4a88a, "Blue"), + (0x69d8ff, "Yellow"), + (0x97c9b7, "Green"), + (0x9795ee, "Red"), + (0xde9eb4, "Purple"), + (0xaeba91, "Cyan"), + (0x78b0f6, "Orange"), + (0xbba4d5, "Magenta"), + (0xd2bb9b, "Blue Mist"), + (0xb79cab, "Purple Mist"), + (0x99d1e8, "Tan"), + (0x6ff9f5, "Lemon"), + (0x92e7ad, "Apple"), + (0xcabc4d, "Teal"), + (0x7575ba, "Red Chalk"), + (0xaa9595, "Silver"), +]; + +/// A place Move offers. +struct Destination { + name: String, + path: String, + icon: &'static [&'static str], + tint: Option<[f32; 4]>, } pub fn id() -> Id { @@ -134,87 +221,453 @@ impl State { self.menu = None; return; } - let item = |text| Item { - text, - ..Default::default() + let (target, point) = (target.clone(), *point); + let mut actions: Vec<(Action, Item)> = self.actions(&target); + // A notebook's menu heads with its sync status in a line, which opens the whole. + let status = match &target { + Target::Notebook(library) => self.session.as_ref().map(|session| { + let (text, icon) = crate::sync::line(library, session); + (Arc::clone(library), text, icon) + }), + _ => None, }; - let items: Vec = match target { - Target::Page(space) => { - let pages = self - .session - .as_ref() - .map_or(&[][..], |session| &session.pages[..]); + if let Some((_, text, icon)) = &status { + actions[0].1.separated = true; + let item = Item { + text, + icon: Some(*icon), + ..Item::default() + }; + actions.insert(0, (Action::SyncStatus, item)); + } + let action = self.action_menu(id(), Anchor::Point(point), None, &target, &actions); + match (action, status) { + (Some(Action::SyncStatus), Some((library, ..))) => { + self.menu = None; + self.ui.close_popup(id()); + self.show_sync(library, point); + } + (Some(action), _) => { + self.menu = None; + self.act_on(target, action); + } + (None, _) => {} + } + } + + /// What can be done to `target`, in its context menu's order, as each shows there. + pub(crate) fn actions(&self, target: &Target) -> Vec<(Action, Item<'static>)> { + // An action, its label, whether it is disabled, and whether a rule starts its group. + let item = |action: Action, text, disabled, separated| { + let submenu = matches!(action, Action::Move | Action::Colors); + let item = Item { + text, + disabled, + separated, + submenu, + ..Item::default() + }; + (action, item) + }; + let nowhere = || self.destinations(target).is_empty(); + match target { + Target::Page { + library, + path, + space, + } => { + let mut actions = vec![ + item(Action::Rename, "Rename", false, false), + item(Action::Delete, "Delete", false, false), + item(Action::Move, "Move to Section", nowhere(), false), + item(Action::CopyLink, "Copy Link to Page", false, true), + item(Action::NewPage, "New Page", false, true), + // OneNote offers this beside New Page, which is a plain + here. + item(Action::NewSubpage, "New Subpage", false, false), + item(Action::Theme, "Theme…", false, true), + ]; + // Levels and versions are known once the section is open. + let Some(session) = self.session.as_ref().filter(|_| self.open(library, path)) + else { + return actions; + }; + let pages = &session.pages; let at = pages.iter().position(|(listed, ..)| listed == space); let level = at.map_or(1, |at| pages[at].2); let above = at .and_then(|at| at.checked_sub(1)) .map_or(0, |above| pages[above].2); - let (versions, shown) = self.session.as_ref().map_or((false, false), |session| { - ( - !session.page_versions(*space).is_empty(), - session.shown_history == Some(*space), - ) - }); + let shown = session.shown_history == Some(*space); + let versions = if shown { + "Hide Page Versions" + } else { + "Show Page Versions" + }; + actions.extend([ + item( + Action::MakeSubpage, + "Make Subpage", + level > above || level >= 3, + true, + ), + item(Action::PromoteSubpage, "Promote Subpage", level <= 1, false), + item( + Action::Versions(!shown), + versions, + session.page_versions(*space).is_empty(), + true, + ), + ]); + actions + } + Target::Section { library, path } | Target::Group { library, path } => { + let mut actions = vec![ + item(Action::Rename, "Rename", false, false), + item(Action::Delete, "Delete", false, false), + item(Action::Move, "Move", nowhere(), false), + item(Action::NewSection, "New Section", false, true), + item(Action::NewGroup, "New Section Group", false, false), + ]; + if matches!(target, Target::Section { .. }) { + actions.extend([ + item(Action::Colors, "Section Color", false, true), + item( + Action::Reveal, + platform::SHOW_FILE, + section_file(library, path).is_none(), + false, + ), + item(Action::Theme, "Theme…", false, false), + ]); + } + actions + } + Target::Notebook(library) => { + let listed = (self.notebooks.iter()).position(|open| Arc::ptr_eq(open, library)); + let last = self.notebooks.len().saturating_sub(1); vec![ - item("Delete"), - Item { - separated: true, - ..item("Copy Link to Page") - }, - Item { - separated: true, - ..item("New Page") - }, - // OneNote offers this beside New Page, which is a plain + here. - item("New Subpage"), - Item { - separated: true, - disabled: level > above || level >= 3, - ..item("Make Subpage") - }, - Item { - disabled: level <= 1, - ..item("Promote Subpage") - }, - Item { - separated: true, - disabled: !versions, - ..item(if shown { - "Hide Page Versions" - } else { - "Show Page Versions" - }) - }, + item(Action::Rename, "Rename…", false, false), + item( + Action::Sync, + "Sync This Notebook Now", + library.background.is_none(), + false, + ), + // The app's iCloud Drive folder lists every notebook in it. + item( + Action::Close, + "Close This Notebook", + crate::manage::in_icloud_folder(&library.location), + false, + ), + item(Action::CopyLink, "Copy Link to Notebook", false, true), + item(Action::NewSection, "New Section", false, true), + item(Action::NewGroup, "New Section Group", false, false), + item( + Action::NewNotebook, + commands::command(commands::Id::NewNotebook).title, + false, + false, + ), + item( + Action::Raise(true), + "Move Up", + listed.is_none_or(|at| at == 0), + true, + ), + item( + Action::Raise(false), + "Move Down", + listed.is_none_or(|at| at == last), + false, + ), + item( + Action::Reveal, + commands::command(commands::Id::ShowNotebook).title, + library.folder().is_none(), + true, + ), + item(Action::Theme, "Theme…", false, false), + item( + Action::Properties, + "Properties…", + library.catalog().is_none(), + false, + ), ] } - Target::Section { .. } | Target::Group { .. } => vec![ - item("Rename"), - item("Delete"), - Item { - separated: true, - ..item("New Section") - }, - item("New Section Group"), - ], - Target::Notebook(_) => vec![ - item("New Section"), - item("New Section Group"), - Item { - separated: true, - ..item("Close This Notebook") - }, - ], + Target::Closed(_) | Target::Server(_) => { + vec![item(Action::Delete, "Remove from Recent", false, false)] + } + Target::Command(_) => Vec::new(), + } + } + + /// Builds menu `id` of `actions` on `target` at `anchor`, under a filter field showing + /// `filter` when given, with Move's places in its submenu: the action chosen. + pub(crate) fn action_menu( + &mut self, + id: Id, + anchor: Anchor, + filter: Option<&str>, + target: &Target, + actions: &[(Action, Item)], + ) -> Option { + let items: Vec = actions.iter().map(|(_, item)| *item).collect(); + let chosen = ui::popup::menu(&mut self.ui, id, anchor, &items, filter); + let places = id.child("move"); + let colors = id.child("colors"); + ui::popup::submenus(&mut self.ui, id, &items, |index| match actions[index].0 { + Action::Move => Some(places), + Action::Colors => Some(colors), + _ => None, + }); + let moved = if self.ui.popup_open(places) { + let destinations = self.destinations(target); + let items: Vec = (destinations.iter()) + .map(|place| Item { + text: &place.name, + icon: Some(place.icon), + tint: place.tint, + ..Item::default() + }) + .collect(); + ui::popup::menu(&mut self.ui, places, anchor, &items, None) + .map(|index| Action::MoveTo(destinations[index].path.clone())) + } else { + None }; - let Some(chosen) = ui::popup::menu(&mut self.ui, id(), Anchor::Point(*point), &items, None) - else { - return; + let colored = match target { + Target::Section { library, path } if self.ui.popup_open(colors) => { + let current = (library.tabs(&folder(path)).into_iter()) + .find(|tab| tab.path == *path) + .and_then(|tab| tab.color); + let theme = &self.ui.theme; + let mut items: Vec = (SECTION_COLORS.iter()) + .map(|&(color, text)| Item { + text, + icon: Some(crate::art::SECTION), + tint: Some(theme.section(crate::section_color(Some(color))).accent), + checked: Some(current == Some(color)), + ..Item::default() + }) + .collect(); + items.push(Item { + text: "None", + checked: Some(current.is_none()), + separated: true, + ..Item::default() + }); + ui::popup::menu(&mut self.ui, colors, anchor, &items, None) + .map(|index| Action::Color(SECTION_COLORS.get(index).map(|(color, _)| *color))) + } + _ => None, }; - let Some((target, _)) = self.menu.take() else { - return; + chosen + .map(|index| actions[index].0.clone()) + .or(moved) + .or(colored) + } + + /// Where Move takes `target`: the other sections of a page's folder, or the folders a + /// section or group may move into. + fn destinations(&self, target: &Target) -> Vec { + let theme = &self.ui.theme; + match target { + Target::Page { library, path, .. } => (library.tabs(&folder(path)).into_iter()) + .filter(|tab| tab.path != *path) + .map(|tab| Destination { + tint: Some(theme.section(crate::section_color(tab.color)).accent), + name: tab.name, + path: tab.path, + icon: crate::art::SECTION, + }) + .collect(), + Target::Section { library, path } | Target::Group { library, path } => { + let within = format!("{path}/"); + let home = folder(path); + (folders(library).into_iter()) + .filter(|place| { + place.path != home + && place.path != *path + && !place.path.starts_with(&within) + }) + .map(|place| Destination { + name: match place.path.rsplit_once('/') { + _ if place.path.is_empty() => library.name.clone(), + Some((_, name)) => name.to_owned(), + None => place.path.clone(), + }, + path: place.path.clone(), + icon: if place.path.is_empty() { + crate::art::NOTEBOOK + } else { + crate::art::SECTION_GROUP + }, + tint: None, + }) + .collect() + } + _ => Vec::new(), + } + } + + /// Does `action` to `target`. An action on a page of a section not open opens it there + /// first, then acts. + pub(crate) fn act_on(&mut self, target: Target, action: Action) { + let command = match (target, action) { + (Target::Command(id), _) => { + self.choose(Choice::Command(id)); + None + } + ( + Target::Page { + library, + path, + space, + }, + action, + ) if action == Action::Open || !self.open(&library, &path) => { + self.go(library, path, space); + self.after_open = (action != Action::Open).then_some((space, action)); + None + } + (Target::Page { space, .. }, action) => self.page_command(space, action), + (Target::Section { library, path }, Action::Open) => { + Some(Command::OpenSection(library, path)) + } + (Target::Notebook(library), Action::Open) => { + self.open_notebook(library.location.clone(), None); + None + } + ( + Target::Section { library, path } | Target::Group { library, path }, + Action::Rename, + ) => { + self.rename(crate::rename::Target::Entry { + library, + path, + in_tab: false, + }); + None + } + (Target::Section { library, path }, Action::Delete) => platform::confirm( + "Are you sure you want to move this section to this notebook's Recycle Bin?", + &path, + "Cancel", + "Delete", + ) + .then(|| Command::Structure(library, Structure::Delete { path })), + (Target::Group { library, path }, Action::Delete) => platform::confirm( + "Are you sure you want to move the sections in this section group to this notebook's Recycle Bin?", + path.rsplit('/').next().unwrap_or_default(), + "Cancel", + "Delete", + ) + .then(|| Command::Structure(library, Structure::Delete { path })), + ( + Target::Section { library, path } | Target::Group { library, path }, + Action::MoveTo(folder), + ) => Some(Command::Structure(library, Structure::Move { path, folder })), + (Target::Section { library, path }, Action::Theme) => { + if let Some(identity) = library.section_identity(&path) { + let scope = notebook::sidecar::themes::Scope::section(identity); + self.show_themes(crate::themes::Scope::Section, library, scope); + } + None + } + (Target::Notebook(library), Action::Theme) => { + let scope = notebook::sidecar::themes::Scope::Notebook; + self.show_themes(crate::themes::Scope::Notebook, library, scope); + None + } + (Target::Section { library, path }, Action::Color(color)) => Some(Command::Structure( + library, + Structure::Color { path, color }, + )), + (Target::Section { library, path }, Action::Reveal) => { + if let Some(file) = section_file(&library, &path) { + platform::show_file(&file); + } + None + } + (Target::Section { library, path }, action) => { + new(&action, folder(&path)).map(|structure| Command::Structure(library, structure)) + } + (Target::Group { library, path }, action) => { + new(&action, path).map(|structure| Command::Structure(library, structure)) + } + (Target::Notebook(library), Action::Reveal) => { + platform::reveal(&library.location); + None + } + (Target::Notebook(library), Action::Close) => Some(Command::CloseNotebook(library)), + (Target::Notebook(library), Action::Rename | Action::Properties) => { + self.open_properties(library); + None + } + (Target::Notebook(library), Action::Sync) => { + if let Some(session) = &self.session + && Arc::ptr_eq(&session.library, &library) + { + session.section.wake(); + } + library.background.iter().for_each(|background| background.wake()); + None + } + (Target::Notebook(library), Action::CopyLink) => { + // OneNote 2010's Copy Link to Notebook: the folder's path, spaces escaped. + let link = format!("onenote:///{}", library.location.replace(' ', "%20")); + if let Err(error) = self.clipboard.set_text(link) { + eprintln!("Copying the link failed: {error}"); + } + None + } + (Target::Notebook(_), Action::NewNotebook) => { + self.choose(Choice::Command(commands::Id::NewNotebook)); + None + } + (Target::Notebook(library), Action::Raise(up)) => { + let at = (self.notebooks.iter()).position(|open| Arc::ptr_eq(open, &library)); + let to = at.and_then(|at| if up { at.checked_sub(1) } else { Some(at + 1) }); + if let (Some(at), Some(to)) = (at, to.filter(|to| *to < self.notebooks.len())) { + self.notebooks.swap(at, to); + self.save_settings(); + } + None + } + (Target::Notebook(library), action) => { + new(&action, String::new()).map(|structure| Command::Structure(library, structure)) + } + (Target::Closed(location), Action::Delete) => { + self.trail.recent.retain(|place| place.notebook != location); + self.save_settings(); + None + } + (Target::Closed(location), _) => { + self.open_notebook(location, None); + None + } + (Target::Server(address), action) => { + if let Some(index) = self.servers.iter().position(|saved| *saved == address) { + self.saved_server((index, action == Action::Delete)); + } + None + } }; - let command = match (target, items[chosen].text) { - (Target::Page(space), "Delete") => Some(Command::DeletePages(vec![space])), - (Target::Page(space), "Copy Link to Page") => { + self.commands.extend(command); + } + + /// What `action` on page `space` of the open section does. + fn page_command(&mut self, space: ExGuid, action: Action) -> Option { + match action { + Action::Rename => { + self.rename(crate::rename::Target::Page(space)); + None + } + Action::Delete => Some(Command::DeletePages(vec![space])), + Action::MoveTo(path) => Some(Command::MovePage { space, path }), + Action::CopyLink => { let copied = self .page_link(space, None) .and_then(|link| self.clipboard.set_text(link)); @@ -223,63 +676,35 @@ impl State { } None } - (Target::Page(_), "New Page") => Some(Command::NewPage { under: None }), - (Target::Page(space), "New Subpage") => Some(Command::NewPage { under: Some(space) }), - (Target::Page(page), "Show Page Versions") => Some(Command::History { page, show: true }), - (Target::Page(page), "Hide Page Versions") => Some(Command::History { page, show: false }), - (Target::Page(space), text) => self.session.as_ref().and_then(|session| { + Action::NewPage => Some(Command::NewPage { under: None }), + Action::NewSubpage => Some(Command::NewPage { under: Some(space) }), + Action::Theme => { + let session = self.session.as_ref()?; + let identity = session.section.page(space).ok()?.identity?; + let library = Arc::clone(&session.library); + let scope = notebook::sidecar::themes::Scope::page(identity); + self.show_themes(crate::themes::Scope::Page, library, scope); + None + } + Action::Versions(show) => Some(Command::History { page: space, show }), + Action::MakeSubpage | Action::PromoteSubpage => { + let session = self.session.as_ref()?; let level = session .pages .iter() .find(|(listed, ..)| *listed == space)? .2; - let level = if text == "Make Subpage" { + let level = if action == Action::MakeSubpage { level + 1 } else { level - 1 }; - Some(Command::Pages(vec![PageEdit::set_level(space, level).ok()?])) - }), - ( - Target::Section { library, path } | Target::Group { library, path }, - "Rename", - ) => { - self.rename(crate::rename::Target::Entry { - library, - path, - in_tab: false, - }); - None + Some(Command::Pages(vec![ + PageEdit::set_level(space, level).ok()?, + ])) } - (Target::Section { library, path }, "Delete") => platform::confirm( - "Are you sure you want to move this section to this notebook's Recycle Bin?", - &path, - "Cancel", - "Delete", - ) - .then(|| Command::Structure(library, Structure::Delete { path })), - (Target::Group { library, path }, "Delete") => platform::confirm( - "Are you sure you want to move the sections in this section group to this notebook's Recycle Bin?", - path.rsplit('/').next().unwrap_or_default(), - "Cancel", - "Delete", - ) - .then(|| Command::Structure(library, Structure::Delete { path })), - (Target::Section { library, path }, text) => { - let folder = folder(&path); - Some(Command::Structure(library, new(text, folder))) - } - (Target::Group { library, path }, text) => { - Some(Command::Structure(library, new(text, path))) - } - (Target::Notebook(library), "Close This Notebook") => { - Some(Command::CloseNotebook(library)) - } - (Target::Notebook(library), text) => { - Some(Command::Structure(library, new(text, String::new()))) - } - }; - self.commands.extend(command); + _ => None, + } } /// The section tab, of those built as `row`, under the pointer while a page is dragged @@ -472,11 +897,90 @@ impl State { } } -/// The new section or group a menu's `text` asks for in `folder`. -fn new(text: &str, folder: String) -> Structure { - if text == "New Section Group" { - Structure::NewGroup { folder } - } else { - Structure::NewSection { folder } +/// The file of the section at catalog `path` on this computer; none on a server reached +/// without a mount. +fn section_file(library: &Library, path: &str) -> Option { + Some(library.folder()?.join(path)) +} + +/// The new section or group `action` asks for in `folder`. +fn new(action: &Action, folder: String) -> Option { + match action { + Action::NewSection => Some(Structure::NewSection { folder }), + Action::NewGroup => Some(Structure::NewGroup { folder }), + _ => None, + } +} + +/// `library`'s folders of sections, the notebook's first, then its section groups breadth +/// first, but for the recycle bin; none for a section opened on its own. +pub(crate) fn folders(library: &Library) -> Vec<¬ebook::discover::Folder> { + let mut folders: Vec<_> = library.catalog().into_iter().collect(); + let mut at = 0; + while let Some(&folder) = folders.get(at) { + folders.extend( + (folder.groups.iter()).filter(|group| !crate::library::recycle_bin(&group.path)), + ); + at += 1; + } + folders +} + +#[cfg(test)] +mod tests { + use super::SECTION_COLORS; + use notebook::{discover::SectionState, session::Notebook}; + + /// Section Color and Notebook Properties' colour as the app stores them: each of OneNote's + /// colours and None on a section of its own, and Teal on the notebook. + /// `SNOWBOUND_SECTION_COLOR_EXPORT` names a new directory receiving the notebook for a + /// cold reopen in OneNote 2010 (`corpus/section-color`). + #[test] + fn sections_and_the_notebook_take_onenotes_colours() { + let temporary = + std::env::temp_dir().join(format!("snowbound-colors-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&temporary); + let root = temporary.join("Colors"); + let cache = temporary.join("cache"); + std::fs::create_dir_all(&temporary).unwrap(); + let page = || onestore::PageCreation::new(None, Some(""), "Author").unwrap(); + let mut notebook = Notebook::create(&root, &cache, Notebook::NEW_COLOR, &page()).unwrap(); + let named: Vec<(Option, &str)> = (SECTION_COLORS.iter()) + .map(|&(color, name)| (Some(color), name)) + .chain([(None, "None")]) + .collect(); + for (color, name) in &named { + let path = notebook.create_section("", name, &page()).unwrap(); + notebook.set_section_color(&path, *color).unwrap(); + } + notebook.set_color(0xcabc4d).unwrap(); + + let reopened = Notebook::open(&root, &cache).unwrap(); + let catalog = reopened.catalog(); + assert_eq!(catalog.toc.as_ref().unwrap().color, Some(0xcabc4d)); + let colors: Vec<(String, Option)> = (catalog.sections.iter()) + .map(|section| match §ion.state { + SectionState::Readable { color, .. } => (section.path.clone(), *color), + _ => panic!("{}", section.path), + }) + .collect(); + // The section a new notebook starts with keeps the colour it was made in. + let expected: Vec<(String, Option)> = [(Some(0xe4a88a), "New Section 1")] + .iter() + .chain(&named) + .map(|(color, name)| (format!("{name}.one"), *color)) + .collect(); + assert_eq!(colors, expected); + if let Some(directory) = std::env::var_os("SNOWBOUND_SECTION_COLOR_EXPORT") { + let directory = std::path::Path::new(&directory); + std::fs::create_dir_all(directory).unwrap(); + for entry in std::fs::read_dir(&root).unwrap() { + let entry = entry.unwrap(); + if entry.file_type().unwrap().is_file() { + std::fs::copy(entry.path(), directory.join(entry.file_name())).unwrap(); + } + } + } + std::fs::remove_dir_all(&temporary).unwrap(); } } diff --git a/crates/snowbound/src/navigation.rs b/crates/snowbound/src/navigation.rs index a8d2b13e5452454d38d15d55d53ad421f10d24a4..6c378795ee935aeb1a0b317406aa5b310dbd3bfb 100644 --- a/crates/snowbound/src/navigation.rs +++ b/crates/snowbound/src/navigation.rs @@ -1,33 +1,42 @@ //! Back and Forward through the pages visited, across sections and notebooks, as OneNote -//! 2010's Quick Access Toolbar offers them. +//! 2010's Quick Access Toolbar offers them, and the pages shown lately, which the palette +//! lists first. use super::*; -use std::time::Duration; +use canvas::search::Index; +use std::{collections::HashSet, time::Duration}; use winit::event::TouchPhase; /// A page shown: its notebook's location, its section's catalog path and the page. -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Place { pub notebook: String, pub section: String, pub page: ExGuid, } -/// Pages visited before and after the one shown. +/// Pages visited before and after the one shown, and those shown lately. #[derive(Default)] pub struct Trail { back: Vec, here: Option, forward: Vec, + /// Each page shown lately once, latest first, kept between launches. + pub recent: Vec, } /// Places Back keeps, at most. const KEPT: usize = 100; +/// Pages `Trail::recent` keeps, at most. +const RECENT: usize = 8; impl Trail { /// Notes `place` shown. Arriving anywhere but where Back or Forward went drops the pages /// ahead, as a browser does. pub fn visit(&mut self, place: Place) { + self.recent.retain(|recent| *recent != place); + self.recent.insert(0, place.clone()); + self.recent.truncate(RECENT); if self.here.as_ref() == Some(&place) { return; } @@ -59,17 +68,58 @@ impl Trail { pub fn open(&self) -> [bool; 2] { [!self.back.is_empty(), !self.forward.is_empty()] } + + /// Drops the recent pages whose section `listed` refuses, and those whose section `index` + /// has read without them. Returns whether any went. + pub fn prune(&mut self, index: &Index, listed: impl Fn(&Place) -> bool) -> bool { + let read: HashSet<&str> = (index.entries().iter()) + .map(|entry| entry.section.as_str()) + .collect(); + let before = self.recent.len(); + self.recent.retain(|place| { + let key = crate::library::key(&place.notebook, &place.section); + listed(place) + && (!read.contains(key.as_str()) + || (index.entries().iter()) + .any(|entry| entry.space == place.page && entry.section == key)) + }); + self.recent.len() != before + } } impl State { - /// Notes the page just shown for Back and Forward. + /// Notes the page just shown for Back and Forward, and among the recent pages. pub(crate) fn visited(&mut self) { - if let Some(session) = &self.session { - self.trail.visit(Place { - notebook: session.library.location.clone(), - section: session.tabs[session.tab].path.clone(), - page: session.space, - }); + let Some(session) = &self.session else { + return; + }; + let place = Place { + notebook: session.library.location.clone(), + section: session.tabs[session.tab].path.clone(), + page: session.space, + }; + let moved = self.trail.recent.first() != Some(&place); + self.trail.visit(place); + if moved { + self.save_settings(); + } + } + + /// Whether `library`'s section at `path` is the one open. + pub(crate) fn open(&self, library: &Library, path: &str) -> bool { + self.session.as_ref().is_some_and(|session| { + session.library.location == library.location && session.tabs[session.tab].path == path + }) + } + + /// Shows page `space` of `library`'s section at `path`, opening the section on it where + /// it is not the one open. + pub(crate) fn go(&mut self, library: Arc, path: String, space: ExGuid) { + if self.open(&library, &path) { + self.commands.push(Command::OpenPage(space)); + } else { + self.last_pages.insert(library.key(&path), space); + self.commands.push(Command::OpenSection(library, path)); } } @@ -87,13 +137,6 @@ impl State { let Some(place) = self.trail.step(forward, exists) else { return; }; - if let Some(session) = &self.session - && session.library.location == place.notebook - && session.tabs[session.tab].path == place.section - { - self.commands.push(Command::OpenPage(place.page)); - return; - } let Some(library) = self .notebooks .iter() @@ -101,11 +144,7 @@ impl State { else { return; }; - let library = Arc::clone(library); - self.last_pages - .insert(library.key(&place.section), place.page); - self.commands - .push(Command::OpenSection(library, place.section)); + self.go(Arc::clone(library), place.section, place.page); } } @@ -228,6 +267,57 @@ mod tests { assert_eq!(trail.open(), [true, false]); } + #[test] + fn recent_pages_are_kept_once_latest_first_and_bounded() { + let mut trail = Trail::default(); + for page in [1, 2, 1] { + trail.visit(place("A.one", page)); + } + assert_eq!(trail.recent, [place("A.one", 1), place("A.one", 2)]); + for page in 0..20 { + trail.visit(place("B.one", page)); + } + assert_eq!(trail.recent.len(), RECENT); + assert_eq!(trail.recent[0], place("B.one", 19)); + } + + #[test] + fn recent_pages_go_with_their_section_or_once_the_index_lacks_them() { + let page = |title: &str| onestore::page::Page { + title: title.into(), + identity: None, + created: None, + margin_origin: [0.0; 2], + color: None, + rule_lines: None, + objects: Vec::new(), + definitions: Default::default(), + }; + let mut index = Index::default(); + let section = crate::library::key("/notebooks/Personal", "A.one"); + let kept = place("A.one", 1); + index.set(canvas::search::Entry::new( + §ion, + kept.page, + &page("Kept"), + 0, + )); + let mut trail = Trail::default(); + // Visited oldest first: the index has read A without page 2, not yet B, and C is gone. + for gone in [ + place("C.one", 1), + place("B.one", 1), + place("A.one", 2), + kept.clone(), + ] { + trail.visit(gone); + } + let listed = |place: &Place| place.section != "C.one"; + assert!(trail.prune(&index, listed)); + assert_eq!(trail.recent, [kept, place("B.one", 1)]); + assert!(!trail.prune(&index, listed), "nothing more goes"); + } + #[test] fn back_keeps_its_last_hundred_places() { let mut trail = Trail::default(); diff --git a/crates/snowbound/src/options.rs b/crates/snowbound/src/options.rs index 2b53675799cc90a9c48d51a517c5d2abff774825..e32feff63f8f568aa0b2306cf10ffde4efcac8cb 100644 --- a/crates/snowbound/src/options.rs +++ b/crates/snowbound/src/options.rs @@ -1,14 +1,14 @@ -//! OneNote's Options dialog: pages of settings listed on the left as OneNote 2010 lists -//! them, grouped under the same headings, each kept when OK is chosen. +//! OneNote's Options dialog as one scrolling list of sections (`resources/settings.md`): +//! indexed on the left, filtered by the search field above, each choice kept when OK is +//! chosen. use crate::{State, platform, settings::ColorScheme, update}; use accesskit::Role; -use ui::{Anchor, Axis, Flags, Id, Spec, Theme, Ui, children, fill, popup::Item, px}; +use canvas::editor::DefaultFont; +use ui::{Anchor, Axis, Extent, Flags, Id, Size, Spec, Theme, Ui, fill, popup::Item, px}; use winit::keyboard::NamedKey; -const WIDTH: f32 = 640.0; -/// The pages' area, the same for every page so the dialog keeps its size. -const HEIGHT: f32 = 320.0; +const WIDTH: f32 = 680.0; const NAV: f32 = 150.0; /// The column the fields' labels share. const LABEL: f32 = 104.0; @@ -17,38 +17,214 @@ const SCHEMES: [(ColorScheme, &str); 3] = [ (ColorScheme::Light, "Light"), (ColorScheme::Dark, "Dark"), ]; +/// The sizes Default font offers, OneNote 2010's list in points. +const SIZES: [f32; 19] = [ + 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, + 36.0, 48.0, 72.0, +]; -#[derive(Clone, Copy, PartialEq)] -enum Page { - General, - Display, - SaveBackup, - Advanced, +/// An entry of the index and its part of the list. +struct Section { + name: &'static str, + groups: &'static [Group], } -impl Page { - const ALL: [(Page, &str); 4] = [ - (Page::General, "General"), - (Page::Display, "Display"), - (Page::SaveBackup, "Save & Backup"), - (Page::Advanced, "Advanced"), - ]; +/// Rows under a banded heading, as OneNote groups an Options page; an empty heading has no +/// band. +struct Group { + heading: &'static str, + rows: &'static [Row], } -/// The page shown and what the fields hold until OK keeps them. +struct Row { + label: &'static str, + /// Other words search finds the row by. + keywords: &'static str, + control: Control, +} + +enum Control { + /// A check box before the label, bound to a choice. + Check(fn(&mut Options) -> &mut bool), + /// The label in the labels' column, then what the function builds. + Field(fn(&mut State, &mut Options)), + /// What `build` makes across the row, unlabelled, from the search's words; `finds` tells + /// whether they find anything in it. Words found in the row's own texts give it none. + Block { + build: fn(&mut State, &mut Options, &[String]), + finds: fn(&[String]) -> bool, + }, +} + +/// Every section, in the list's order. +const SECTIONS: &[Section] = &[ + Section { + name: "General", + groups: &[ + Group { + heading: "User Interface Options", + rows: &[ + Row { + label: "Appearance:", + keywords: "color colour scheme dark light mode system theme", + control: Control::Field(appearance), + }, + Row { + label: "Pages match UI theme", + keywords: "dark white paper background", + control: Control::Check(|options| &mut options.pages_match), + }, + ], + }, + Group { + heading: "Personalize", + rows: &[Row { + label: "User name:", + keywords: "author name", + control: Control::Field(user_name_field), + }], + }, + ], + }, + Section { + name: "Editing", + groups: &[ + Group { + heading: "Default font", + rows: &[ + Row { + label: "Font:", + keywords: "typeface text new", + control: Control::Field(font_face), + }, + Row { + label: "Size:", + keywords: "font points text new", + control: Control::Field(font_size), + }, + Row { + label: "Font color:", + keywords: "colour text new automatic", + control: Control::Field(font_color), + }, + ], + }, + Group { + heading: "Proofing", + rows: &[Row { + label: "Hide spelling errors", + keywords: "spell check misspelled words", + control: Control::Check(|options| &mut options.hide_spelling), + }], + }, + Group { + heading: "Pen", + rows: &[Row { + label: "Use pen pressure sensitivity", + keywords: "tablet stylus ink stroke width drawing", + control: Control::Check(|options| &mut options.pen_pressure), + }], + }, + ], + }, + Section { + name: "Display", + groups: &[Group { + heading: "", + rows: &[ + Row { + label: "Page tabs appear on the left", + keywords: "pages list side layout", + control: Control::Check(|options| &mut options.page_tabs_left), + }, + Row { + label: "Navigation bar appears on the left", + keywords: "notebooks sidebar side right layout", + control: Control::Check(|options| &mut options.navigation_bar_left), + }, + ], + }], + }, + Section { + name: "Sync & Storage", + groups: &[Group { + heading: "Cache file location", + rows: &[Row { + label: "Path:", + keywords: "folder replica data disk", + control: Control::Field(cache), + }], + }], + }, + Section { + name: "Updates", + groups: &[Group { + heading: "", + rows: &[ + Row { + label: "Version:", + keywords: "about build release", + control: Control::Field(version), + }, + Row { + label: "Check for updates automatically", + keywords: "update download", + control: Control::Check(|options| &mut options.automatic_updates), + }, + #[cfg(target_os = "linux")] + Row { + label: "Installed:", + keywords: "install uninstall app menu remove", + control: Control::Field(installed), + }, + ], + }], + }, + Section { + name: "Keyboard", + groups: &[Group { + heading: "", + rows: &[Row { + label: "Shortcuts", + keywords: "keys chords hotkeys bindings commands menus", + control: Control::Block { + build: |state, options, query| options.keyboard.build(&mut state.ui, query), + finds: crate::keys::searched, + }, + }], + }], + }, +]; + +/// What the fields hold until OK keeps them, and where the list is. pub struct Options { - page: Page, + query: String, + /// The section last picked in the index, marked until the list is scrolled. + picked: Option<&'static str>, user_name: String, color_scheme: ColorScheme, - light_pages: bool, + pages_match: bool, + hide_spelling: bool, automatic_updates: bool, pen_pressure: bool, + default_font: DefaultFont, + page_tabs_left: bool, + navigation_bar_left: bool, + pub(crate) keyboard: crate::keys::Keyboard, } fn id() -> Id { Id::ROOT.child("options") } +fn search() -> Id { + id().child("search") +} + +fn list() -> Id { + id().child("list") +} + fn user_name() -> Id { id().child("user-name") } @@ -57,45 +233,107 @@ fn schemes() -> Id { id().child("color-schemes") } +fn fonts() -> Id { + id().child("fonts") +} + +fn sizes() -> Id { + id().child("sizes") +} + +fn font_colors() -> Id { + id().child("font-colors") +} + +/// Whether `word`, lowercase, starts a word of `text`: "pen" finds "Pen" and "pen-like", +/// not "Open". +fn starts_word(text: &str, word: &str) -> bool { + let text = text.to_lowercase(); + text.match_indices(word).any(|(at, _)| { + text[..at] + .chars() + .next_back() + .is_none_or(|before| !before.is_alphanumeric()) + }) +} + +/// Whether every word of `query`, lowercase, starts a word in one of `texts`. +pub(crate) fn found(query: &[String], texts: &[&str]) -> bool { + query + .iter() + .all(|word| texts.iter().any(|text| starts_word(text, word))) +} + +/// Whether any word of `query` starts a word of `text`, which search then highlights. +fn lit(query: &[String], text: &str) -> bool { + query.iter().any(|word| starts_word(text, word)) +} + +/// Whether every word of the query is in `row`'s section name, group heading, label or +/// keywords. +fn titled(query: &[String], section: &Section, group: &Group, row: &Row) -> bool { + found( + query, + &[section.name, group.heading, row.label, row.keywords], + ) +} + +/// Whether search shows `row`: its own texts, or a block's contents, hold the query. +fn shown(query: &[String], section: &Section, group: &Group, row: &Row) -> bool { + titled(query, section, group, row) + || matches!(row.control, Control::Block { finds, .. } if finds(query)) +} + impl State { pub(crate) fn open_options(&mut self) { self.options = Some(Options { - page: Page::General, + query: String::new(), + picked: None, user_name: self.author.clone(), color_scheme: self.color_scheme, - light_pages: self.light_pages, + pages_match: !self.light_pages, + hide_spelling: self.hide_spelling, automatic_updates: self.updates.automatic(), pen_pressure: self.pen_pressure, + default_font: self.view.editor.default_font.clone(), + page_tabs_left: self.page_tabs_left, + navigation_bar_left: !self.navigation_bar_right, + keyboard: crate::keys::Keyboard::new(), }); self.ui.open_popup(id()); - self.ui.set_focus(Some(user_name())); + self.ui.set_focus(Some(search())); } - /// Builds the Options dialog while it is open. OK, or Enter in a field, keeps its choices - /// with a non-empty user name; Cancel, Escape or a press outside leave them. + /// Builds the Options dialog while it is open. OK, or Enter in the user name, keeps its + /// choices with a non-empty user name; Cancel, Escape or a press outside leave them. pub(crate) fn options_dialog(&mut self) { - let Some(options) = &mut self.options else { + let Some(mut options) = self.options.take() else { return; }; - let ui = &mut self.ui; - if !ui.popup_open(id()) { - self.options = None; + if !self.ui.popup_open(id()) { return; } - let theme = ui.theme.clone(); - let row = theme.font_size * 2.0; + let theme = self.ui.theme.clone(); + let row = row_height(&theme); + let ui = &mut self.ui; let entered = ui::popup::navigation(ui, &[user_name()], &[NamedKey::Enter]) .contains(&NamedKey::Enter); + // As tall as its contents up to the window, which the list then scrolls within. + let loose = Extent { + size: Size::Children, + strictness: 0.0, + }; ui.open_as( id(), Spec { axis: Axis::Y, - size: [px(WIDTH), children()], + size: [px(WIDTH), loose], fill: Some(theme.popup), border: Some(theme.chip), shadow: Some(theme.shadow), radius: 8.0, pad: [6.0, 6.0], + gap: 6.0, anchor: Some(Anchor::Dialog), role: Some(Role::Dialog), ..Spec::default() @@ -104,15 +342,59 @@ impl State { if let Some(node) = ui.access(id()) { node.set_label("Options"); } + let before = options.query.clone(); + ui::text_field( + ui, + search(), + &mut options.query, + "Search options", + Spec { + size: [fill(), px(row)], + fill: Some(theme.base), + border: Some(if ui.focused() == Some(search()) { + theme.accent + } else { + theme.chip + }), + radius: 4.0, + pad: [8.0, 0.0], + role: Some(Role::SearchInput), + ..Spec::default() + }, + ); + if let Some(node) = ui.access(search()) { + node.set_label("Search options"); + } + let query: Vec = options + .query + .split_whitespace() + .map(str::to_lowercase) + .collect(); + if options.query != before { + options.picked = None; + ui.scroll_to(list(), list().child("top")); + } + let sections: Vec<&Section> = SECTIONS + .iter() + .filter(|section| { + section.groups.iter().any(|group| { + group + .rows + .iter() + .any(|row| shown(&query, section, group, row)) + }) + }) + .collect(); ui.open( "body", Spec { - size: [fill(), px(HEIGHT)], + size: [fill(), loose], + gap: 6.0, ..Spec::default() }, ); - let pages = ui.open( - "pages", + let index = ui.open( + "index", Spec { axis: Axis::Y, size: [px(NAV), fill()], @@ -124,177 +406,175 @@ impl State { ..Spec::default() }, ); - if let Some(node) = ui.access(pages) { + if let Some(node) = ui.access(index) { node.set_orientation(accesskit::Orientation::Vertical); } - for (page, name) in Page::ALL { - let shown = page == options.page; - let spec = Spec { - flags: Flags::CLICKABLE, - size: [fill(), px(row)], - text: Some(name), - bold: shown, - fill: shown.then(|| theme.hover()), - hover_fill: Some(theme.hover()), - radius: 4.0, - pad: [8.0, 0.0], - role: Some(Role::Tab), - ..Spec::default() - }; - let tab = ui.open(name, spec); + // The section in the top quarter of the list, or the last once the list is scrolled to + // its end. + let current = options.picked.or_else(|| { + let [_, top, _, bottom] = ui.rect(list())?; + let starts = |id| ui.rect(id).map(|rect: [f32; 4]| rect[1]); + let end = starts(list().child("top")).is_some_and(|start| start < top - 1.0) + && starts(list().child("end")).is_some_and(|end| end <= bottom + 1.0); + sections + .iter() + .rfind(|section| { + end || starts(section_id(section)) + .is_some_and(|start| start <= top + (bottom - top) / 4.0) + }) + .or(sections.first()) + .map(|section| section.name) + }); + for section in §ions { + let marked = current == Some(section.name); + let tab = ui.open( + section.name, + Spec { + flags: Flags::CLICKABLE, + size: [fill(), px(row)], + text: Some(section.name), + bold: marked, + fill: marked.then(|| theme.hover()), + hover_fill: Some(theme.hover()), + radius: 4.0, + pad: [8.0, 0.0], + role: Some(Role::Tab), + ..Spec::default() + }, + ); if let Some(node) = ui.access(tab) { - node.set_selected(shown); + node.set_selected(marked); } ui.close(); if ui.signal(tab).clicked { - options.page = page; + options.picked = Some(section.name); + ui.scroll_to(list(), section_id(section)); } } ui.close(); - ui.open( - "page", + ui.open_as( + list(), Spec { + flags: Flags::SCROLL | Flags::CLIP, axis: Axis::Y, - size: [fill(), fill()], - pad: [16.0, 10.0], - gap: 6.0, + size: [fill(), loose], + pad: [10.0, 0.0], ..Spec::default() }, ); - match options.page { - Page::General => { - heading(ui, &theme, "User Interface Options"); - field(ui, "Appearance:", |ui| { - let combo = ui.id("combo"); - let current = SCHEMES - .iter() - .find(|(scheme, _)| *scheme == options.color_scheme) - .map_or("", |(_, name)| name); - ui::shell::combo(ui, "combo", "Appearance", current, 140.0, schemes(), true); - let items = SCHEMES.map(|(scheme, name)| Item { - text: name, - checked: Some(scheme == options.color_scheme), - current: scheme == options.color_scheme, - ..Item::default() - }); - let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default()); - if let Some(index) = ui::popup::menu(ui, schemes(), anchor, &items, None) { - options.color_scheme = SCHEMES[index].0; - } - }); - heading(ui, &theme, "Personalize"); - field(ui, "User name:", |ui| { - ui::text_field( - ui, - user_name(), - &mut options.user_name, - "", + if !ui.signal(list()).events.is_empty() { + options.picked = None; + } + ui.leaf( + "top", + Spec { + size: [fill(), px(0.0)], + ..Spec::default() + }, + ); + if sections.is_empty() { + ui.leaf( + "none", + Spec { + size: [fill(), px(row * 2.0)], + text: Some("No options match"), + color: Some(theme.text_dim), + center: true, + ..Spec::default() + }, + ); + } + for section in §ions { + self.ui.open_as( + section_id(section), + Spec { + axis: Axis::Y, + size: [fill(), ui::children()], + pad: [0.0, 4.0], + gap: 4.0, + ..Spec::default() + }, + ); + title( + &mut self.ui, + &theme, + section.name, + lit(&query, section.name), + ); + for group in section.groups { + let rows: Vec<&Row> = group + .rows + .iter() + .filter(|row| shown(&query, section, group, row)) + .collect(); + if rows.is_empty() { + continue; + } + if !group.heading.is_empty() { + heading( + &mut self.ui, + &theme, + group.heading, + lit(&query, group.heading), + ); + } + for row in rows { + let highlight = !matches!(row.control, Control::Block { .. }) + && (lit(&query, row.label) || lit(&query, row.keywords)); + self.ui.open( + row.label, Spec { - size: [px(260.0), px(row)], - fill: Some(theme.base), - border: Some(theme.accent), + size: [fill(), ui::children()], + fill: highlight.then(|| ui::mix(theme.popup, theme.accent, 0.18)), radius: 4.0, - pad: [6.0, 0.0], + pad: [8.0, 0.0], + gap: 8.0, ..Spec::default() }, ); - if let Some(node) = ui.access(user_name()) { - node.set_label("User name"); - } - }); - heading(ui, &theme, "Updates"); - ui.leaf( - "version", - Spec { - size: [fill(), px(row)], - text: Some(&update::describe_running()), - pad: [8.0, 0.0], - ..Spec::default() - }, - ); - if ui::check_box( - ui, - "automatic-updates", - "Check for updates automatically", - options.automatic_updates, - ) - .clicked - { - options.automatic_updates = !options.automatic_updates; - } - #[cfg(target_os = "linux")] - if crate::desktop::uninstallable() { - field(ui, "Installed:", |ui| { - let binary = crate::desktop::binary().unwrap_or_default(); - ui.leaf( - "path", - Spec { - flags: Flags::CLIP, - size: [fill(), px(row)], - text: Some(&binary.to_string_lossy()), - color: Some(theme.text_dim), - ..Spec::default() - }, - ); - if ui::button(ui, "uninstall", "Uninstall…").clicked { - crate::desktop::uninstall(); + match row.control { + Control::Check(choice) => { + let checked = choice(&mut options); + if ui::check_box(&mut self.ui, "check", row.label, *checked).clicked { + *checked = !*checked; + } + } + Control::Field(build) => { + self.ui.leaf( + "label", + Spec { + size: [px(LABEL), px(row_height(&theme))], + text: Some(row.label), + ..Spec::default() + }, + ); + build(self, &mut options); + } + Control::Block { build, .. } => { + let titled = titled(&query, section, group, row); + build(self, &mut options, if titled { &[] } else { &query }); } - }); - } - } - Page::Display => { - heading(ui, &theme, "Display"); - let matching = !options.light_pages; - if ui::check_box(ui, "pages-match-theme", "Pages match UI theme", matching).clicked - { - options.light_pages = matching; - } - } - Page::Advanced => { - heading(ui, &theme, "Pen"); - if ui::check_box( - ui, - "pen-pressure", - "Use pen pressure sensitivity", - options.pen_pressure, - ) - .clicked - { - options.pen_pressure = !options.pen_pressure; - } - } - Page::SaveBackup => { - heading(ui, &theme, "Cache file location"); - let cache = &self.cache; - field(ui, "Path:", |ui| { - ui.leaf( - "path", - Spec { - flags: Flags::CLIP, - size: [fill(), px(row)], - text: Some(&cache.to_string_lossy()), - color: Some(theme.text_dim), - ..Spec::default() - }, - ); - let label = if cfg!(target_os = "macos") { - "Show in Finder" - } else { - "Open Folder" - }; - if ui::button(ui, "reveal", label).clicked { - platform::reveal(cache); } - }); + self.ui.close(); + } } + self.ui.close(); } + let ui = &mut self.ui; + ui.leaf( + "end", + Spec { + size: [fill(), px(6.0)], + ..Spec::default() + }, + ); ui.close(); ui.close(); ui.open( "buttons", Spec { - size: [fill(), children()], - pad: [10.0, 8.0], + size: [fill(), ui::children()], + pad: [10.0, 4.0], gap: 8.0, ..Spec::default() }, @@ -314,28 +594,63 @@ impl State { if ok && !name.is_empty() { self.author = name.to_owned(); self.color_scheme = options.color_scheme; - self.light_pages = options.light_pages; + self.light_pages = !options.pages_match; + self.hide_spelling = options.hide_spelling; self.pen_pressure = options.pen_pressure; + self.view.editor.default_font = options.default_font; + self.page_tabs_left = options.page_tabs_left; + self.navigation_bar_right = !options.navigation_bar_left; self.updates.set_automatic(options.automatic_updates); + self.show_spelling(); self.follow_color_scheme(); + self.install_keymap(options.keyboard.keymap); self.save_settings(); } else if !cancel { + self.options = Some(options); return; } self.ui.close_popup(id()); - self.options = None; } } +fn section_id(section: &Section) -> Id { + list().child(section.name) +} + +fn row_height(theme: &Theme) -> f32 { + theme.font_size * 2.0 +} + +/// A section's name over its groups, tinted where search found it. +fn title(ui: &mut Ui, theme: &Theme, text: &str, lit: bool) { + ui.leaf( + "title", + Spec { + size: [fill(), px(theme.font_size * 2.2)], + text: Some(text), + font_size: Some(theme.font_size * 1.25), + bold: true, + color: lit.then_some(theme.accent), + role: Some(Role::Heading), + ..Spec::default() + }, + ); +} + /// A group's heading on a band, as OneNote heads the groups of its Options pages. -fn heading(ui: &mut Ui, theme: &Theme, text: &str) { +fn heading(ui: &mut Ui, theme: &Theme, text: &str, lit: bool) { + let band = ui::mix(theme.panel, theme.chip, 0.4); ui.leaf( text, Spec { size: [fill(), px(theme.font_size * 1.8)], text: Some(text), bold: true, - fill: Some(ui::mix(theme.panel, theme.chip, 0.4)), + fill: Some(if lit { + ui::mix(band, theme.accent, 0.3) + } else { + band + }), radius: 4.0, pad: [8.0, 0.0], role: Some(Role::Heading), @@ -344,26 +659,179 @@ fn heading(ui: &mut Ui, theme: &Theme, text: &str) { ); } -/// A row of `label` in the labels' column and the controls `build` adds after it. -fn field(ui: &mut Ui, label: &str, build: impl FnOnce(&mut Ui)) { - let row = ui.theme.font_size * 2.0; - ui.open( - label, - Spec { - size: [fill(), px(row)], - pad: [8.0, 0.0], - gap: 8.0, - ..Spec::default() - }, - ); - ui.leaf( - "label", - Spec { - size: [px(LABEL), px(row)], - text: Some(label), - ..Spec::default() - }, - ); - build(ui); - ui.close(); +fn appearance(state: &mut State, options: &mut Options) { + let ui = &mut state.ui; + let combo = ui.id("combo"); + let current = SCHEMES + .iter() + .find(|(scheme, _)| *scheme == options.color_scheme) + .map_or("", |(_, name)| name); + ui::shell::combo(ui, "combo", "Appearance", current, 140.0, schemes(), true); + let items = SCHEMES.map(|(scheme, name)| Item { + text: name, + checked: Some(scheme == options.color_scheme), + current: scheme == options.color_scheme, + ..Item::default() + }); + let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default()); + if let Some(index) = ui::popup::menu(ui, schemes(), anchor, &items, None) { + options.color_scheme = SCHEMES[index].0; + } +} + +fn user_name_field(state: &mut State, options: &mut Options) { + let ui = &mut state.ui; + let theme = &ui.theme; + let width = Extent { + size: Size::Pixels(260.0), + strictness: 0.0, + }; + let spec = Spec { + size: [width, px(row_height(theme))], + fill: Some(theme.base), + border: Some(theme.accent), + radius: 4.0, + pad: [6.0, 0.0], + ..Spec::default() + }; + ui::text_field(ui, user_name(), &mut options.user_name, "", spec); + if let Some(node) = ui.access(user_name()) { + node.set_label("User name"); + } +} + +fn font_face(state: &mut State, options: &mut Options) { + let State { ui, fonts, .. } = state; + let font = &mut options.default_font; + let combo = ui.id("combo"); + ui::shell::combo(ui, "combo", "Font", &font.face, 200.0, self::fonts(), true); + let items: Vec<_> = fonts + .iter() + .map(|name| Item { + text: name, + font: Some(name), + current: *name == font.face, + ..Item::default() + }) + .collect(); + let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default()); + if let Some(index) = ui::popup::menu(ui, self::fonts(), anchor, &items, Some("Font")) { + font.face = fonts[index].clone(); + } +} + +fn font_size(state: &mut State, options: &mut Options) { + let ui = &mut state.ui; + let font = &mut options.default_font; + let combo = ui.id("combo"); + let size = format!("{}", font.size); + ui::shell::combo(ui, "combo", "Size", &size, 60.0, sizes(), true); + let labels = SIZES.map(|size| format!("{size}")); + let items: Vec<_> = labels + .iter() + .map(|label| Item { + text: label, + checked: Some(*label == size), + current: *label == size, + ..Item::default() + }) + .collect(); + let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default()); + if let Some(index) = ui::popup::menu(ui, sizes(), anchor, &items, None) { + font.size = SIZES[index]; + } +} + +fn font_color(state: &mut State, options: &mut Options) { + let ui = &mut state.ui; + let font = &mut options.default_font; + let combo = ui.id("combo"); + let shown = crate::FONT_COLORS + .iter() + .find(|(color, _)| Some(*color) == font.color) + .map_or("Automatic", |(_, name)| name); + ui::shell::combo(ui, "combo", "Font color", shown, 200.0, font_colors(), true); + let swatches: Vec<_> = crate::FONT_COLORS + .iter() + .map(|&(color, name)| (canvas::gpu::colorref(color), name)) + .collect(); + let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default()); + if let Some(chosen) = ui::popup::colors(ui, font_colors(), anchor, "Automatic", &swatches, 10) { + font.color = chosen.and_then(|chosen| { + let at = swatches.iter().position(|(swatch, _)| *swatch == chosen)?; + Some(crate::FONT_COLORS[at].0) + }); + } +} + +/// A path shown dimmed and cut to the row. +fn path(ui: &mut Ui, path: &std::path::Path) { + let spec = Spec { + flags: Flags::CLIP, + size: [fill(), px(row_height(&ui.theme))], + text: Some(&path.to_string_lossy()), + color: Some(ui.theme.text_dim), + ..Spec::default() + }; + ui.leaf("path", spec); +} + +fn cache(state: &mut State, _: &mut Options) { + path(&mut state.ui, &state.cache); + let label = if cfg!(target_os = "macos") { + "Show in Finder" + } else { + "Open Folder" + }; + if ui::button(&mut state.ui, "reveal", label).clicked { + platform::reveal(&state.cache); + } +} + +fn version(state: &mut State, _: &mut Options) { + let ui = &mut state.ui; + let spec = Spec { + size: [fill(), px(row_height(&ui.theme))], + text: Some(&update::describe_running()), + ..Spec::default() + }; + ui.leaf("version", spec); +} + +/// Where Install put Snowbound and Uninstall, or Install where it isn't in the app menu. +#[cfg(target_os = "linux")] +fn installed(state: &mut State, _: &mut Options) { + let ui = &mut state.ui; + if crate::desktop::uninstallable() { + path(ui, &crate::desktop::binary().unwrap_or_default()); + if ui::button(ui, "uninstall", "Uninstall…").clicked { + crate::desktop::uninstall(); + } + } else if crate::desktop::installable() { + path(ui, std::path::Path::new("Not in the app menu")); + if ui::button(ui, "install", "Install").clicked { + crate::desktop::install(); + } + } else { + path(ui, std::path::Path::new("By your system's package manager")); + } +} + +#[cfg(test)] +mod tests { + use super::found; + + #[test] + fn search_words_match_the_starts_of_words() { + let query = |text: &str| vec![text.to_owned()]; + assert!(found(&query("pen"), &["Use pen pressure sensitivity"])); + assert!(found(&query("pen"), &["Pen"])); + assert!(!found(&query("pen"), &["Open Notebook"])); + assert!(found(&query("⌘b"), &["⌘B"])); + assert!(found(&query("storage"), &["Sync & Storage"])); + assert!( + !found(&["pen".into(), "zzz".into()], &["Pen"]), + "every word" + ); + } } diff --git a/crates/snowbound/src/palette.rs b/crates/snowbound/src/palette.rs index e1105bc92e65090ad9e08d0e941c743677b9179b..0e4d1e5ef1232261f3a8cf8638cfcfae30c60217 100644 --- a/crates/snowbound/src/palette.rs +++ b/crates/snowbound/src/palette.rs @@ -1,14 +1,18 @@ -//! The palette: the open notebooks, their sections and every page the search index holds to -//! go to, or after a leading `>` the command table's commands, narrowed as typed. +//! The palette: the pages and sections shown lately, then the open notebooks, their sections +//! and every page the search index holds to go to, or after a leading `>` the command +//! table's commands, narrowed as typed. ⌘K or a right-click lists what can be done to a row. use crate::{ - Command, Library, State, art, + State, art, commands::{self, Choice}, library, + menus::{self, Action, Target}, }; -use onestore::ExGuid; use std::sync::Arc; -use ui::{Id, popup::Item}; +use ui::{ + Anchor, Id, + popup::{Item, Pick}, +}; /// What the palette's query starts with to list commands. pub(crate) const COMMANDS: &str = ">"; @@ -17,20 +21,16 @@ pub(crate) fn id() -> Id { Id::ROOT.child("palette") } -enum Target { - Command(commands::Id), - Notebook(String), - Section(Arc, String), - Page(String, ExGuid), -} - /// A row: its text, the dim text after it, its icon and what it runs; a heading runs nothing. +#[derive(Default)] struct Row { text: String, after: String, icon: Option<&'static [&'static str]>, tint: Option<[f32; 4]>, disabled: bool, + /// Repeats a row listed further on, as the recent ones do. + repeated: bool, target: Option, } @@ -38,11 +38,7 @@ impl Row { fn heading(text: &str) -> Self { Self { text: text.to_owned(), - after: String::new(), - icon: None, - tint: None, - disabled: false, - target: None, + ..Self::default() } } @@ -55,13 +51,15 @@ impl Row { disabled: self.disabled, heading: self.target.is_none(), separated: self.target.is_none(), + repeated: self.repeated, ..Item::default() } } } impl State { - /// Builds the palette while it is open, and runs what is chosen from it. + /// Builds the palette while it is open, with the actions on a row when asked for, and + /// does what is chosen from either. pub(crate) fn palette(&mut self) { if !self.ui.popup_open(id()) { return; @@ -74,9 +72,9 @@ impl State { text: command.title.to_owned(), after: commands::shortcut(command.id), icon: crate::artwork(command.id), - tint: None, disabled: !self.status(&Choice::Command(command.id), &format).enabled, target: Some(Target::Command(command.id)), + ..Row::default() }) .collect(); commands.extend(self.tags.iter().enumerate().map(|(place, tag)| { @@ -88,41 +86,30 @@ impl State { tint: Some([1.0; 4]), disabled: !self.status(&Choice::Command(id), &format).enabled, target: Some(Target::Command(id)), + ..Row::default() } })); - let mut places = vec![Row::heading("Notebooks")]; + let mut places = self.recent(); + places.push(Row::heading("Notebooks")); places.extend( (self.notebooks.iter()) .filter(|library| library.catalog().is_some()) .map(|library| Row { text: library.name.clone(), - after: String::new(), icon: Some(art::NOTEBOOK), - tint: None, - disabled: false, - target: Some(Target::Notebook(library.location.clone())), + target: Some(Target::Notebook(Arc::clone(library))), + ..Row::default() }), ); places.push(Row::heading("Sections")); let theme = &self.ui.theme; for library in &self.notebooks { // A section opened on its own has no catalog, and lists itself at "". - let mut folders: Vec<_> = library.catalog().into_iter().collect(); - let mut at = 0; - while let Some(&folder) = folders.get(at) { - folders.extend( - folder - .groups - .iter() - .filter(|group| !library::recycle_bin(&group.path)), - ); - at += 1; - } - let paths = folders.iter().map(|folder| folder.path.as_str()); + let folders = menus::folders(library); let paths: Vec<&str> = if folders.is_empty() { vec![""] } else { - paths.collect() + folders.iter().map(|folder| folder.path.as_str()).collect() }; for path in paths { places.extend(library.tabs(path).into_iter().map(|tab| Row { @@ -130,8 +117,11 @@ impl State { after: library.name.clone(), icon: Some(art::SECTION), tint: Some(theme.section(crate::section_color(tab.color)).accent), - disabled: false, - target: Some(Target::Section(Arc::clone(library), tab.path)), + target: Some(Target::Section { + library: Arc::clone(library), + path: tab.path, + }), + ..Row::default() })); } } @@ -141,57 +131,243 @@ impl State { .index .lock() .unwrap_or_else(|poison| poison.into_inner()); - places.extend(index.entries().iter().map(|entry| { + places.extend(index.entries().iter().filter_map(|entry| { let (location, path) = entry.section.split_once('\n').unwrap_or_default(); + let library = self + .notebooks + .iter() + .find(|library| library.location == location)?; let mut section = library::section_name(path, &None); if section.is_empty() { - section = (self.notebooks.iter()) - .find(|library| library.location == location) - .map_or_else(String::new, |library| library.name.clone()); + section = library.name.clone(); } - Row { - text: if entry.title.is_empty() { - "Untitled page".to_owned() - } else { - entry.title.clone() - }, + Some(Row { + text: title(&entry.title), after: section, icon: Some(art::PAGE), - tint: None, - disabled: false, - target: Some(Target::Page(entry.section.clone(), entry.space)), - } + target: Some(Target::Page { + library: Arc::clone(library), + path: path.to_owned(), + space: entry.space, + }), + ..Row::default() + }) })); drop(index); - let items = [&commands, &places].map(|rows| rows.iter().map(Row::item).collect::>()); - let chosen = ui::popup::palette( + let mut items = + [&commands, &places].map(|rows| rows.iter().map(Row::item).collect::>()); + // The latest page or section starts highlighted, so Enter goes back to it. + if let Some(latest) = items[1].get_mut(1).filter(|item| item.repeated) { + latest.current = true; + } + let picked = ui::popup::palette( &mut self.ui, id(), &[(COMMANDS, &items[0]), ("", &items[1])], "Search pages, sections and notebooks (type > for commands)", ); let mut rows = [commands, places]; - match chosen.and_then(|(mode, index)| rows[mode].swap_remove(index).target) { - Some(Target::Command(command)) => self.choose(Choice::Command(command)), - Some(Target::Notebook(location)) => self.open_notebook(location, None), - Some(Target::Section(library, path)) => { - self.commands.push(Command::OpenSection(library, path)); - } - Some(Target::Page(section, space)) => { - let open = (self.session.as_ref()) - .map(|session| session.library.key(&session.tabs[session.tab].path)); - let (location, path) = section.split_once('\n').unwrap_or_default(); - if open.as_ref() == Some(§ion) { - self.commands.push(Command::OpenPage(space)); - } else if let Some(library) = - (self.notebooks.iter()).find(|library| library.location == location) - { - self.commands - .push(Command::OpenSection(Arc::clone(library), path.to_owned())); - self.last_pages.insert(section, space); + match picked { + Some(Pick::Run(mode, index)) => { + if let Some(target) = rows[mode].swap_remove(index).target { + let action = match target { + Target::Command(_) => Action::Run, + _ => Action::Open, + }; + self.act_on(target, action); } } + Some(Pick::Actions(mode, index)) => { + self.actions = rows[mode].swap_remove(index).target; + } None => {} } + let panel = ui::popup::actions(id()); + let Some(target) = self.actions.clone().filter(|_| self.ui.popup_open(panel)) else { + self.actions = None; + return; + }; + // Run or Open leads, as Enter on the row does. + let (keys, first) = match target { + Target::Command(id) => ( + commands::shortcut(id), + ( + Action::Run, + Item { + text: "Run", + disabled: !self.status(&Choice::Command(id), &format).enabled, + ..Item::default() + }, + ), + ), + _ => ( + String::new(), + ( + Action::Open, + Item { + text: "Open", + ..Item::default() + }, + ), + ), + }; + let first = ( + first.0, + Item { + shortcut: &keys, + ..first.1 + }, + ); + let mut actions = vec![first]; + actions.extend(self.actions(&target).into_iter().enumerate().map( + |(at, (action, item))| { + let separated = item.separated || at == 0; + (action, Item { separated, ..item }) + }, + )); + let anchor = Anchor::Right(self.ui.rect(id()).unwrap_or_default()); + if let Some(action) = + self.action_menu(panel, anchor, Some("Search actions"), &target, &actions) + { + self.actions = None; + self.act_on(target, action); + } + } + + /// Under a heading, the pages shown lately but the one shown, their sections but the one + /// open, the notebooks of those since closed, and the servers saved to reconnect to; + /// latest first, and none before any. Pages and sections since gone are forgotten. + fn recent(&mut self) -> Vec { + let index = self + .search + .index + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + let notebooks = &self.notebooks; + let library = |location: &str| { + (notebooks.iter()) + .find(|library| library.location == location) + .map(Arc::clone) + }; + // A closed notebook keeps its places to open it again, an unreadable one until it reads. + let pruned = self.trail.prune(&index, |place| { + library(&place.notebook) + .is_none_or(|library| library.notebook.is_err() || library.contains(&place.section)) + }); + let shown = self.session.as_ref().map(|session| { + ( + session.library.location.as_str(), + session.tabs[session.tab].path.as_str(), + session.space, + ) + }); + let theme = &self.ui.theme; + let [mut pages, mut sections, mut closed] = [(); 3].map(|()| Vec::::new()); + let listed = |rows: &[Row], target: &Target| { + rows.iter().any(|row| match (&row.target, target) { + ( + Some(Target::Section { library, path }), + Target::Section { + library: other, + path: at, + }, + ) => library.location == other.location && path == at, + (Some(Target::Closed(location)), Target::Closed(other)) => location == other, + _ => false, + }) + }; + for place in &self.trail.recent { + let Some(library) = library(&place.notebook) else { + let target = Target::Closed(place.notebook.clone()); + if !listed(&closed, &target) { + closed.push(Row { + text: (place.notebook.rsplit(['/', '\\'])) + .find(|name| !name.is_empty()) + .unwrap_or(&place.notebook) + .to_owned(), + after: "Closed".to_owned(), + icon: Some(art::NOTEBOOK), + target: Some(target), + ..Row::default() + }); + } + continue; + }; + if library.notebook.is_err() { + continue; + } + let here = (place.notebook.as_str(), place.section.as_str()); + let section = Target::Section { + library: Arc::clone(&library), + path: place.section.clone(), + }; + if shown.is_none_or(|(location, path, _)| (location, path) != here) + && !listed(§ions, §ion) + && let Some(tab) = (library.tabs(&menus::folder(&place.section)).into_iter()) + .find(|tab| tab.path == place.section) + { + sections.push(Row { + text: tab.name, + after: library.name.clone(), + icon: Some(art::SECTION), + tint: Some(theme.section(crate::section_color(tab.color)).accent), + repeated: true, + target: Some(section), + ..Row::default() + }); + } + let key = library.key(&place.section); + // A page the index hasn't read yet waits for its title. + let Some(entry) = (index.entries().iter()) + .find(|entry| entry.space == place.page && entry.section == key) + .filter(|_| shown != Some((here.0, here.1, place.page))) + else { + continue; + }; + pages.push(Row { + text: title(&entry.title), + after: library::section_name(&place.section, &None), + icon: Some(art::PAGE), + repeated: true, + target: Some(Target::Page { + library, + path: place.section.clone(), + space: place.page, + }), + ..Row::default() + }); + } + drop(index); + if pruned { + self.save_settings(); + } + let servers = self.servers.iter().map(|address| Row { + text: address.strip_prefix("smb://").unwrap_or(address).to_owned(), + after: "Server".to_owned(), + icon: Some(art::SERVER), + target: Some(Target::Server(address.clone())), + ..Row::default() + }); + let rows: Vec = pages + .into_iter() + .chain(sections) + .chain(closed) + .chain(servers) + .collect(); + if rows.is_empty() { + return rows; + } + std::iter::once(Row::heading("Recent")) + .chain(rows) + .collect() + } +} + +/// A page's title as the palette lists it. +fn title(title: &str) -> String { + if title.is_empty() { + "Untitled page".to_owned() + } else { + title.to_owned() } } diff --git a/crates/snowbound/src/paste.rs b/crates/snowbound/src/paste.rs index b459308c324e58a39f05f70b08acb0046855e5d2..16fba84d5b21595f22f98d94ea751a1154bb01a9 100644 --- a/crates/snowbound/src/paste.rs +++ b/crates/snowbound/src/paste.rs @@ -1,13 +1,21 @@ //! Pictures and files from the clipboard, and pictures inserted, as OneNote 2010 places them. use crate::{State, platform}; -use std::{error::Error, path::PathBuf}; +use canvas::editor::Piece; +use std::{ + error::Error, + path::{Path, PathBuf}, + time::Duration, +}; /// What Paste takes from the clipboard. Files come first, as Finder also offers their names -/// as text and their icons as pictures; text before a picture, as Office and browsers offer -/// a picture of copied text beside it. +/// as text and their icons as pictures; then a page holding pictures, as its text alone +/// would lose them; text before a picture, as Office and browsers offer a picture of copied +/// text beside it. pub(crate) enum Pasted { Files(Vec), + /// HTML holding a picture. + Page(String), Text(String), Picture(Vec), } @@ -17,13 +25,39 @@ impl State { match self.clipboard.pasted() { Some(Pasted::Files(paths)) => { for path in paths { - // OneNote 2010 pastes a picture file as the picture (lab, 2026-09-30). - match std::fs::read(&path) { - Ok(bytes) if draw::RasterImage::measure(&bytes).is_ok() => { - self.insert_picture(bytes)? - } - _ => self.attach(&path, None)?, + self.place_file(&path, None)?; + } + } + Some(Pasted::Page(html)) => { + // Web pictures come later, so the window never waits on the network. + let mut fetched = Vec::new(); + let pieces = html_pieces(&html, |source, css| { + if source.starts_with("https://") || source.starts_with("http://") { + fetched.push((source.to_owned(), css)); + return Some(Piece::Awaited); } + let bytes = load(source)?; + let size = sized(&bytes, css)?; + Some(Piece::Picture(bytes, size)) + }); + let language = canvas::language::lcid(&platform::input_language()); + let (places, response) = self.view.paste_pieces(pieces, language)?; + self.respond(response); + for (place, (source, css)) in places.into_iter().zip(fetched) { + let proxy = self.proxy.clone(); + std::thread::spawn(move || { + let Some(bytes) = fetch(&source) else { + return; + }; + let Some(size) = sized(&bytes, css) else { + return; + }; + let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| { + let response = state.view.insert_awaited(place, bytes, size)?; + state.respond(response); + Ok(()) + }))); + }); } } Some(Pasted::Text(text)) => { @@ -31,15 +65,34 @@ impl State { let response = self.view.paste(&text, language)?; self.respond(response); } - Some(Pasted::Picture(bytes)) => self.insert_picture(bytes)?, + Some(Pasted::Picture(bytes)) => self.insert_picture(bytes, None)?, None => {} } Ok(()) } - /// Puts an encoded picture at the caret at the size its resolution gives it, as OneNote - /// 2010 inserts and pastes one. - pub(crate) fn insert_picture(&mut self, bytes: Vec) -> Result<(), Box> { + /// A pasted file, or one dropped at `at`, a window point: a picture file as its picture, + /// as OneNote 2010 pastes one (lab, 2026-09-30), and any other file attached. + pub(crate) fn place_file( + &mut self, + path: &Path, + at: Option<[f32; 2]>, + ) -> Result<(), Box> { + match std::fs::read(path) { + Ok(bytes) if draw::RasterImage::measure(&bytes).is_ok() => { + self.insert_picture(bytes, at) + } + _ => self.attach(path, at), + } + } + + /// Puts an encoded picture at the caret, or where it is dropped at `at`, a window point, + /// at the size its resolution gives it, as OneNote 2010 inserts and pastes one. + pub(crate) fn insert_picture( + &mut self, + bytes: Vec, + at: Option<[f32; 2]>, + ) -> Result<(), Box> { let Ok(pixels) = draw::RasterImage::measure(&bytes) else { platform::alert( "Couldn't insert the picture", @@ -48,12 +101,289 @@ impl State { return Ok(()); }; let size = picture_size(&bytes, pixels); - let response = self.view.insert_picture(bytes, size)?; + let response = match at { + Some(point) => self + .view + .drop_picture(self.page_point(point), bytes, size)?, + None => self.view.insert_picture(bytes, size)?, + }; self.respond(response); Ok(()) } } +/// A web page fragment's text, a line to each block, and its pictures in order, as OneNote +/// 2010 pastes them (lab, 2026-09-30). `load` makes a picture's piece from its source and +/// its `width` and `height` in CSS pixels; one it can't is left out. +fn html_pieces( + html: &str, + mut load: impl FnMut(&str, [Option; 2]) -> Option, +) -> Vec { + let html = html + .split_once("") + .map_or(html, |(_, fragment)| fragment); + let html = html + .split_once("") + .map_or(html, |(fragment, _)| fragment); + let mut pieces = Vec::new(); + let mut text = String::new(); + let flush = |text: &mut String, pieces: &mut Vec| { + let trimmed = text.trim_matches(|c: char| c.is_whitespace()); + if !trimmed.is_empty() { + pieces.push(Piece::Text(trimmed.to_owned())); + } + text.clear(); + }; + let line = |text: &mut String| { + text.truncate(text.trim_end_matches(' ').len()); + if !text.is_empty() && !text.ends_with('\n') { + text.push('\n'); + } + }; + // Elements whose content is not text: script, style, head and title. + let mut hidden: Option = None; + let mut rest = html; + while !rest.is_empty() { + if let Some(after) = rest.strip_prefix("").map_or("", |(_, after)| after); + continue; + } + let Some(after) = rest.strip_prefix('<') else { + let end = rest.find('<').unwrap_or(rest.len()); + if hidden.is_none() { + for c in decode_entities(&rest[..end]).chars() { + if c.is_whitespace() && c != '\u{a0}' { + if !text.is_empty() && !text.ends_with([' ', '\n']) { + text.push(' '); + } + } else { + text.push(if c == '\u{a0}' { ' ' } else { c }); + } + } + } + rest = &rest[end..]; + continue; + }; + let end = tag_end(after); + let tag = &after[..end]; + rest = after.get(end + 1..).unwrap_or(""); + let (closing, tag) = match tag.strip_prefix('/') { + Some(tag) => (true, tag), + None => (false, tag), + }; + let name = tag + .split(|c: char| c.is_whitespace() || c == '/') + .next() + .unwrap_or_default() + .to_ascii_lowercase(); + if let Some(until) = &hidden { + if closing && name == *until { + hidden = None; + } + continue; + } + match name.as_str() { + "script" | "style" | "head" | "title" if !closing => hidden = Some(name), + "br" => { + text.truncate(text.trim_end_matches(' ').len()); + text.push('\n'); + } + "p" | "div" | "li" | "tr" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "blockquote" + | "pre" | "table" | "ul" | "ol" | "dt" | "dd" | "section" | "article" | "header" + | "footer" | "figure" | "figcaption" => line(&mut text), + "img" if !closing => { + let css = |name| { + attribute(tag, name) + .and_then(|value| value.trim_end_matches("px").parse::().ok()) + .filter(|pixels| *pixels > 0.0) + }; + let Some(picture) = attribute(tag, "src").and_then(|source| { + load(&decode_entities(source), [css("width"), css("height")]) + }) else { + continue; + }; + flush(&mut text, &mut pieces); + pieces.push(picture); + } + _ => {} + } + } + flush(&mut text, &mut pieces); + pieces +} + +/// Where a tag's text ends, before its `>`, outside quoted attribute values. +fn tag_end(tag: &str) -> usize { + let mut quote = None; + for (at, c) in tag.char_indices() { + match (quote, c) { + (None, '"' | '\'') => quote = Some(c), + (Some(open), _) if c == open => quote = None, + (None, '>') => return at, + _ => {} + } + } + tag.len() +} + +/// The value of attribute `name` in `tag`, entities still encoded. +fn attribute<'a>(tag: &'a str, name: &str) -> Option<&'a str> { + let mut rest = tag; + while let Some(at) = rest.find('=') { + let key = rest[..at].trim_end().rsplit(char::is_whitespace).next()?; + let value = rest[at + 1..].trim_start(); + let (value, after) = match value.chars().next()? { + quote @ ('"' | '\'') => value[1..].split_once(quote)?, + _ => value.split_at(value.find(char::is_whitespace).unwrap_or(value.len())), + }; + if key.eq_ignore_ascii_case(name) { + return Some(value); + } + rest = after; + } + None +} + +fn decode_entities(text: &str) -> String { + let mut decoded = String::with_capacity(text.len()); + let mut rest = text; + while let Some(at) = rest.find('&') { + decoded.push_str(&rest[..at]); + rest = &rest[at..]; + let entity = rest[1..] + .find(';') + .filter(|end| *end <= 10) + .map(|end| &rest[1..end + 1]); + let c = entity.and_then(|entity| match entity { + "amp" => Some('&'), + "lt" => Some('<'), + "gt" => Some('>'), + "quot" => Some('"'), + "apos" => Some('\''), + "nbsp" => Some('\u{a0}'), + _ => { + let number = entity.strip_prefix('#')?; + let code = match number.strip_prefix(['x', 'X']) { + Some(hex) => u32::from_str_radix(hex, 16).ok()?, + None => number.parse().ok()?, + }; + char::from_u32(code) + } + }); + match (c, entity) { + (Some(c), Some(entity)) => { + decoded.push(c); + rest = &rest[entity.len() + 2..]; + } + _ => { + decoded.push('&'); + rest = &rest[1..]; + } + } + } + decoded.push_str(rest); + decoded +} + +/// A picture's size in points from its `width` and `height` in CSS pixels, either giving +/// the other by its proportions, or else from its resolution; none where it doesn't decode. +fn sized(bytes: &[u8], css: [Option; 2]) -> Option<[f32; 2]> { + let natural = picture_size(bytes, draw::RasterImage::measure(bytes).ok()?); + Some(match css { + [Some(width), Some(height)] => [width * 0.75, height * 0.75], + [Some(width), None] => [width * 0.75, natural[1] * width * 0.75 / natural[0]], + [None, Some(height)] => [natural[0] * height * 0.75 / natural[1], height * 0.75], + [None, None] => natural, + }) +} + +/// A picture's bytes from a `data:` address or a local file; none from a relative address, +/// which has no page to resolve against. +fn load(source: &str) -> Option> { + if let Some(data) = source.strip_prefix("data:") { + let (kind, payload) = data.split_once(',')?; + return if kind.ends_with(";base64") { + base64(payload) + } else { + Some(percent_decode(payload)) + }; + } + if let Some(path) = source.strip_prefix("file://") { + let path = String::from_utf8(percent_decode(path)).ok()?; + // file:///C:/… on Windows. + let path = match path.strip_prefix('/') { + Some(drive) if cfg!(windows) => drive.to_owned(), + _ => path, + }; + return std::fs::read(path).ok(); + } + None +} + +/// A web picture's bytes, on a thread of its own. +fn fetch(source: &str) -> Option> { + let agent: ureq::Agent = ureq::Agent::config_builder() + .timeout_global(Some(Duration::from_secs(10))) + .build() + .into(); + agent + .get(source) + .call() + .ok()? + .body_mut() + .with_config() + .limit(64 << 20) + .read_to_vec() + .ok() +} + +fn percent_decode(text: &str) -> Vec { + let bytes = text.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut at = 0; + while at < bytes.len() { + let hex = bytes + .get(at + 1..at + 3) + .and_then(|hex| u8::from_str_radix(std::str::from_utf8(hex).ok()?, 16).ok()); + match (bytes[at], hex) { + (b'%', Some(byte)) => { + decoded.push(byte); + at += 3; + } + (byte, _) => { + decoded.push(byte); + at += 1; + } + } + } + decoded +} + +fn base64(text: &str) -> Option> { + let mut decoded = Vec::with_capacity(text.len() * 3 / 4); + let (mut bits, mut count) = (0_u32, 0); + for c in text + .bytes() + .filter(|c| !c.is_ascii_whitespace() && *c != b'=') + { + let value = match c { + b'A'..=b'Z' => c - b'A', + b'a'..=b'z' => c - b'a' + 26, + b'0'..=b'9' => c - b'0' + 52, + b'+' | b'-' => 62, + b'/' | b'_' => 63, + _ => return None, + }; + bits = bits << 6 | u32::from(value); + count += 6; + if count >= 8 { + count -= 8; + decoded.push((bits >> count) as u8); + } + } + Some(decoded) +} + /// A picture's size in points: its pixels at the resolution it declares, or at 96 dpi /// without one, as OneNote 2010 sizes inserted and pasted pictures and pasted bitmaps /// (lab, 2026-09-30). @@ -62,41 +392,124 @@ fn picture_size(encoded: &[u8], pixels: [u32; 2]) -> [f32; 2] { [0, 1].map(|axis| pixels[axis] as f32 * 72.0 / dpi[axis]) } -/// Dots per inch from a PNG's `pHYs` or a JPEG's JFIF density, where they give one. +/// Dots per inch from a PNG's `pHYs`, or a JPEG's JFIF density or else its Exif +/// resolution, where they give one. fn resolution(encoded: &[u8]) -> Option<[f32; 2]> { - let dpi = if let Some(mut chunks) = encoded.strip_prefix(b"\x89PNG\r\n\x1a\n") { - loop { - let length = u32::from_be_bytes(chunks.get(..4)?.try_into().ok()?) as usize; - let kind = chunks.get(4..8)?; - let data = chunks.get(8..8 + length)?; - if kind == b"IDAT" { - return None; - } - // Unit 1 is the metre. - if kind == b"pHYs" && data.len() == 9 && data[8] == 1 { - let per_metre = - |at: usize| u32::from_be_bytes(data[at..at + 4].try_into().unwrap()); - break [per_metre(0), per_metre(4)].map(|dots| dots as f32 * 0.0254); - } - chunks = chunks.get(12 + length..)?; - } - } else { - // Length, identifier, version, units, then the densities. - let app0 = encoded.strip_prefix(b"\xff\xd8\xff\xe0")?.get(..14)?; - if &app0[2..7] != b"JFIF\0" { - return None; - } - let density = |at: usize| u16::from_be_bytes([app0[at], app0[at + 1]]) as f32; - let dots = [density(10), density(12)]; - match app0[9] { - 1 => dots, - 2 => dots.map(|per_cm| per_cm * 2.54), - _ => return None, - } + let dpi = match encoded.strip_prefix(b"\x89PNG\r\n\x1a\n") { + Some(chunks) => png_resolution(chunks)?, + None => jpeg_resolution(encoded.strip_prefix(b"\xff\xd8")?)?, }; dpi.iter().all(|dpi| *dpi > 0.0).then_some(dpi) } +fn png_resolution(mut chunks: &[u8]) -> Option<[f32; 2]> { + loop { + let length = u32::from_be_bytes(chunks.get(..4)?.try_into().ok()?) as usize; + let kind = chunks.get(4..8)?; + let data = chunks.get(8..8 + length)?; + if kind == b"IDAT" { + return None; + } + // Unit 1 is the metre. + if kind == b"pHYs" && data.len() == 9 && data[8] == 1 { + let per_metre = |at: usize| u32::from_be_bytes(data[at..at + 4].try_into().unwrap()); + return Some([per_metre(0), per_metre(4)].map(|dots| dots as f32 * 0.0254)); + } + chunks = chunks.get(12 + length..)?; + } +} + +/// `segments` follow the start-of-image marker. +fn jpeg_resolution(mut segments: &[u8]) -> Option<[f32; 2]> { + let mut exif = None; + while let [0xff, marker, high, low, rest @ ..] = segments { + let length = usize::from(u16::from_be_bytes([*high, *low])).checked_sub(2)?; + let data = rest.get(..length)?; + match (*marker, data) { + ( + 0xe0, + [ + b'J', + b'F', + b'I', + b'F', + 0, + _, + _, + units @ (1 | 2), + x0, + x1, + y0, + y1, + .., + ], + ) => { + let dots = [[*x0, *x1], [*y0, *y1]].map(|dots| f32::from(u16::from_be_bytes(dots))); + if dots.iter().all(|dots| *dots > 0.0) { + return Some(if *units == 1 { + dots + } else { + dots.map(|per_cm| per_cm * 2.54) + }); + } + } + (0xe1, [b'E', b'x', b'i', b'f', 0, 0, tiff @ ..]) => exif = exif_resolution(tiff), + // The scan follows. + (0xda, _) => break, + _ => {} + } + segments = &rest[length..]; + } + exif +} + +/// XResolution and YResolution from an Exif block's first directory, in their unit. +fn exif_resolution(tiff: &[u8]) -> Option<[f32; 2]> { + let big = match tiff.get(..2)? { + b"MM" => true, + b"II" => false, + _ => return None, + }; + let u16_at = |at: usize| -> Option { + let bytes = tiff.get(at..at + 2)?.try_into().ok()?; + Some(if big { + u16::from_be_bytes(bytes) + } else { + u16::from_le_bytes(bytes) + }) + }; + let u32_at = |at: usize| -> Option { + let bytes = tiff.get(at..at + 4)?.try_into().ok()?; + Some(if big { + u32::from_be_bytes(bytes) + } else { + u32::from_le_bytes(bytes) + }) + }; + let directory = u32_at(4)? as usize; + let (mut x, mut y, mut unit) = (None, None, 2); + for entry in 0..usize::from(u16_at(directory)?) { + let at = directory + 2 + entry * 12; + let rational = || { + let offset = u32_at(at + 8)? as usize; + let [numerator, denominator] = [u32_at(offset)?, u32_at(offset + 4)?]; + (denominator > 0).then(|| numerator as f32 / denominator as f32) + }; + match u16_at(at)? { + 0x011a => x = rational(), + 0x011b => y = rational(), + 0x0128 => unit = u16_at(at + 8)?, + _ => {} + } + } + let dots = [x?, y?]; + match unit { + 2 => Some(dots), + 3 => Some(dots.map(|per_cm| per_cm * 2.54)), + _ => None, + } +} + /// A bitmap from the clipboard as the PNG a page stores, without a resolution, so it takes /// 96 dpi as OneNote 2010 gives a pasted bitmap. #[cfg(not(target_os = "macos"))] @@ -148,6 +561,120 @@ mod tests { bytes } + /// A JPEG header whose JFIF segment gives no density and whose Exif block gives `dpi` + /// in `unit`, little-endian. + fn exif(dpi: u32, unit: u16) -> Vec { + let mut tiff = b"II*\0\x08\0\0\0".to_vec(); + tiff.extend(3u16.to_le_bytes()); + let values = 8 + 2 + 3 * 12 + 4; + for (tag, kind, value) in [ + (0x011a_u16, 5_u16, values), + (0x011b, 5, values + 8), + (0x0128, 3, u32::from(unit)), + ] { + tiff.extend(tag.to_le_bytes()); + tiff.extend(kind.to_le_bytes()); + tiff.extend(1u32.to_le_bytes()); + tiff.extend(value.to_le_bytes()); + } + tiff.extend(0u32.to_le_bytes()); + for _ in 0..2 { + tiff.extend(dpi.to_le_bytes()); + tiff.extend(1u32.to_le_bytes()); + } + let mut bytes = jfif(0, 1); + bytes.extend([0xff, 0xe1]); + bytes.extend(u16::try_from(2 + 6 + tiff.len()).unwrap().to_be_bytes()); + bytes.extend(b"Exif\0\0"); + bytes.extend(tiff); + bytes.extend([0xff, 0xda, 0, 2]); + bytes + } + + fn shown(pieces: &[Piece]) -> Vec { + pieces + .iter() + .map(|piece| match piece { + Piece::Text(text) => text.clone(), + Piece::Picture(bytes, size) => format!("[{} {size:?}]", bytes.len()), + Piece::Awaited => "[awaited]".to_owned(), + }) + .collect() + } + + /// As OneNote 2010 pasted `

before after

` (lab, 2026-09-30): the text on + /// either side of the picture, the picture at the size its attributes give. + #[test] + fn a_pasted_page_gives_its_text_and_pictures_in_order() { + let html = "Version:0.9\r\nT\ +

before \"a b\" \ + src=\"file:///C:/work/pic.png\" width=\"200\" height=\"100px\"> after

\ +

Fish & chips ——

  • one
  • two
    three
\ + \ + "; + let mut asked = Vec::new(); + let pieces = html_pieces(html, |source, css| { + asked.push((source.to_owned(), css)); + match source { + "missing.png" => None, + "https://example.invalid/a.png" => Some(Piece::Awaited), + _ => Some(Piece::Picture(vec![0; 3], [150.0, 75.0])), + } + }); + assert_eq!( + asked, + [ + ( + "file:///C:/work/pic.png".to_owned(), + [Some(200.0), Some(100.0)] + ), + ("missing.png".to_owned(), [None, None]), + ( + "https://example.invalid/a.png".to_owned(), + [Some(40.0), None] + ), + ] + ); + assert_eq!( + shown(&pieces), + [ + "before", + "[3 [150.0, 75.0]]", + "after\nFish & chips \u{2014}\u{2014}\none\ntwo\nthree", + "[awaited]", + ] + ); + } + + /// Attributes size a picture, the missing one by its proportions. + #[test] + fn page_pictures_take_the_size_their_attributes_give() { + let picture = png_at(None); + assert_eq!(sized(&picture, [None, None]), Some([150.0, 75.0])); + assert_eq!(sized(&picture, [Some(100.0), None]), Some([75.0, 37.5])); + assert_eq!(sized(&picture, [None, Some(50.0)]), Some([75.0, 37.5])); + assert_eq!(sized(&picture, [Some(8.0), Some(4.0)]), Some([6.0, 3.0])); + assert_eq!(sized(b"not a picture", [None, None]), None); + } + + #[test] + fn picture_sources_decode() { + assert_eq!( + load("data:image/png;base64,iVBORw0KGgo=").unwrap(), + b"\x89PNG\r\n\x1a\n" + ); + assert_eq!(load("data:text/plain,a%20b").unwrap(), b"a b"); + assert_eq!(load("relative/pic.png"), None); + let directory = + std::env::temp_dir().join(format!("snowbound-paste-{}", std::process::id())); + std::fs::create_dir_all(&directory).unwrap(); + let file = directory.join("a picture.png"); + std::fs::write(&file, b"bytes").unwrap(); + let address = format!("file://{}", file.to_str().unwrap().replace(' ', "%20")); + assert_eq!(load(&address).unwrap(), b"bytes"); + std::fs::remove_dir_all(&directory).unwrap(); + } + fn close(size: [f32; 2], expected: [f32; 2]) -> bool { (0..2).all(|axis| (size[axis] - expected[axis]).abs() < 0.01) } @@ -166,6 +693,9 @@ mod tests { assert!(close(size(&jfif(0, 1)), [150.0, 75.0])); assert!(close(size(&jfif(1, 0)), [150.0, 75.0])); assert!(close(size(b"GIF89a"), [150.0, 75.0])); + assert!(close(size(&exif(72, 2)), [200.0, 100.0])); + assert!(close(size(&exif(20, 3)), [283.4646, 141.7323])); + assert!(close(size(&exif(72, 1)), [150.0, 75.0])); } #[test] diff --git a/crates/snowbound/src/prefetch.rs b/crates/snowbound/src/prefetch.rs new file mode 100644 index 0000000000000000000000000000000000000000..de956b5a5a1d9ed1f03673c73bccfd2beb7f7b76 --- /dev/null +++ b/crates/snowbound/src/prefetch.rs @@ -0,0 +1,376 @@ +//! Sections and pages made ready before they are shown, and pages kept once left, so +//! switching to one shows it at once. A section kept open is its notebook's to hand out +//! (`Library::open`); pages are kept as scenes, whose pictures are what takes long to +//! draw, and every page shown is read and laid out afresh around them. + +use super::*; +use std::collections::VecDeque; + +/// Most recently used first, at most `most` of them. +pub struct Recent { + entries: VecDeque<(K, V)>, + most: usize, +} + +impl Recent { + pub const fn new(most: usize) -> Self { + Self { + entries: VecDeque::new(), + most, + } + } + + /// Keeps `value` as the most recent, returning what `key` held before and what no + /// longer fits. + pub fn put(&mut self, key: K, value: V) -> Vec { + let mut gone: Vec = self.take(&key).into_iter().collect(); + self.entries.push_front((key, value)); + gone.extend(self.trim(u64::MAX, |_| 0)); + gone + } + + pub fn take(&mut self, key: &K) -> Option { + let at = self.entries.iter().position(|(held, _)| held == key)?; + self.entries.remove(at).map(|(_, value)| value) + } + + pub fn contains(&self, key: &K) -> bool { + self.entries.iter().any(|(held, _)| held == key) + } + + pub fn values_mut(&mut self) -> impl Iterator { + self.entries.iter_mut().map(|(_, value)| value) + } + + /// Lets go of the least recent past `most`, and past `budget` as `weigh` counts them. + pub fn trim(&mut self, budget: u64, weigh: impl Fn(&V) -> u64) -> Vec { + let mut kept = 0u64; + let fits = self + .entries + .iter() + .take(self.most) + .take_while(|(_, value)| { + kept = kept.saturating_add(weigh(value)); + kept <= budget + }) + .count(); + self.entries.drain(fits..).map(|(_, value)| value).collect() + } +} + +/// Pages kept: a section's `Library::key` and the page's space. +type Key = (String, ExGuid); + +/// How many pages are kept laid out, and the decoded pictures they may hold between them, +/// besides the page shown. +const SCENES: usize = 32; +const SCENE_BYTES: u64 = 128 << 20; + +/// A page kept for showing again: its scene, at the offset `PageView::open` takes, and, while +/// its pictures are still drawing, the editor that places them. +pub struct Kept { + scene: (PageScene, [f32; 2]), + editor: Option, +} + +pub type Scenes = Arc>>; + +/// Work done ahead on its own thread, one piece at a time. +enum Job { + /// Reads a page of the open section. + Page( + Key, + Box Result> + Send>, + ), + /// Opens a section for its notebook to keep, then reads the page it would show. + Section { + library: Arc, + path: String, + last: Option, + notify: Box, + }, +} + +pub struct Prefetch { + pub scenes: Scenes, + jobs: mpsc::Sender, + /// What was asked for lately, by section key and page (none for a section), so a hover + /// held over many frames asks once. + asked: Recent<(String, Option), ()>, +} + +impl Prefetch { + pub fn new(layouts: Arc>, redraw: std::task::Waker) -> Self { + let scenes = Scenes::new(Mutex::new(Recent::new(SCENES))); + let (jobs, queue) = mpsc::channel(); + let kept = Arc::clone(&scenes); + std::thread::Builder::new() + .name("prefetch".into()) + .spawn(move || { + for job in queue { + // What fails ahead fails again, and says so, once it is asked for. + if prepare(job, &layouts, &kept).is_ok() { + redraw.wake_by_ref(); + } + } + }) + .expect("The prefetch thread starts"); + Self { + scenes, + jobs, + asked: Recent::new(32), + } + } + + /// The scene kept for page `space` of the section `section` names, if any. + pub fn take(scenes: &Scenes, section: &str, space: ExGuid) -> Option { + let kept = scenes.lock().ok()?.take(&(section.to_owned(), space))?; + Some(kept.scene.0) + } + + /// Keeps the scene of a page left, its pictures drawn. + pub fn keep(&mut self, section: String, space: ExGuid, scene: (PageScene, [f32; 2])) { + self.asked.take(&(section.clone(), Some(space))); + if let Ok(mut scenes) = self.scenes.lock() { + scenes.put( + (section, space), + Kept { + scene, + editor: None, + }, + ); + scenes.trim(SCENE_BYTES, weigh); + } + } + + /// Whether `target` is new since it was last asked for, noting it. + fn ask(&mut self, target: (String, Option)) -> bool { + let new = !self.asked.contains(&target); + self.asked.put(target, ()); + new + } + + /// Draws the pictures kept pages show first, as `view` would open them, letting go of + /// each editor once they are drawn. + pub fn draw(&self, view: &PageView, paper: canvas::gpu::Paper, waker: &std::task::Waker) { + let Ok(mut scenes) = self.scenes.lock() else { + return; + }; + for kept in scenes.values_mut() { + if let Some(editor) = &kept.editor + && view.prepare( + &mut kept.scene, + editor, + paper.colored(editor.page_color()), + waker, + ) + { + kept.editor = None; + } + } + scenes.trim(SCENE_BYTES, weigh); + } +} + +fn weigh(kept: &Kept) -> u64 { + kept.scene.0.raster_bytes() +} + +fn prepare(job: Job, layouts: &Mutex, scenes: &Scenes) -> Result<(), Box> { + let (key, page) = match job { + Job::Page(key, read) => { + if scenes.lock().map_err(|_| "Prefetch failed")?.contains(&key) { + return Ok(()); + } + let page = read()?; + (key, page) + } + Job::Section { + library, + path, + last, + notify, + } => { + let replica = library.prefetch(&path, notify)?; + let pages = replica.pages()?; + let space = last + .filter(|space| pages.iter().any(|(listed, ..)| listed == space)) + .or(pages.first().map(|(space, ..)| *space)) + .ok_or("The section has no pages")?; + let key = (library.key(&path), space); + if scenes.lock().map_err(|_| "Prefetch failed")?.contains(&key) { + return Ok(()); + } + (key, replica.page(space)?) + } + }; + let (scene, editor) = { + let mut engine = layouts.lock().map_err(|_| "Page layout failed")?; + PageScene::from_page(page, &mut engine)? + }; + let mut scenes = scenes.lock().map_err(|_| "Prefetch failed")?; + scenes.put( + key, + Kept { + scene: (scene, [0.0; 2]), + editor: Some(editor), + }, + ); + scenes.trim(SCENE_BYTES, weigh); + Ok(()) +} + +impl Session { + /// The section's `Library::key`. + pub fn key(&self) -> String { + self.library.key(&self.tabs[self.tab].path) + } +} + +/// How long a page may take to open before its area shows that it is opening, so a page +/// ready in a frame or two never flashes the placeholder. +const PATIENCE: std::time::Duration = std::time::Duration::from_millis(80); + +/// A page's outline while it opens, in points from the page area's corner: its title, its +/// date and some lines of text, breathing in `paper`'s ink. +pub fn skeleton(paper: canvas::gpu::Paper, since: Instant) -> Vec> { + let breath = 0.5 - 0.5 * (since.elapsed().as_secs_f32() * std::f32::consts::TAU / 1.6).cos(); + let [red, green, blue, _] = paper.ink; + let color = [red, green, blue, 0.06 + 0.06 * breath]; + let bar = |x: f32, y: f32, width: f32, height: f32| draw::Primitive::RoundedRect { + rect: [x, y, x + width, y + height], + radius: [height / 3.0; 2], + stroke: None, + color, + }; + let mut bars = vec![bar(48.0, 40.0, 280.0, 24.0), bar(48.0, 74.0, 150.0, 10.0)]; + let lines = [420.0, 380.0, 440.0, 300.0, 0.0, 400.0, 360.0, 220.0]; + for (line, width) in lines.into_iter().enumerate() { + if width > 0.0 { + bars.push(bar(64.0, 120.0 + 24.0 * line as f32, width, 11.0)); + } + } + bars +} + +impl State { + /// The section tab shown open: one being opened, from the moment it is asked for. + pub fn open_tab(&self) -> Option { + let session = self.session.as_ref()?; + let opening = self.switching.and_then(|(tab, _)| tab); + Some( + opening + .filter(|tab| *tab < session.tabs.len()) + .unwrap_or(session.tab), + ) + } + + /// Since when the page area shows that a page is opening, once it has taken a while. + pub fn loading(&self) -> Option { + let (_, since) = self.switching?; + (since.elapsed() >= PATIENCE).then_some(since) + } + + /// Leaves the page shown as it is, dropping whatever was opening in its place. + pub fn stop_loading(&mut self) { + self.loading += 1; + self.opening = None; + self.switching = None; + } + + /// Readies the pages beside the open one and the sections beside the open section. + pub fn prefetch_around(&mut self) { + let Some(session) = &self.session else { + return; + }; + let at = session + .pages + .iter() + .position(|(space, ..)| *space == session.space); + let pages: Vec = at + .into_iter() + .flat_map(|at| [at + 1, at.wrapping_sub(1)]) + .filter_map(|at| session.pages.get(at).map(|(space, ..)| *space)) + .collect(); + let tabs: Vec = [session.tab + 1, session.tab.wrapping_sub(1)] + .into_iter() + .filter(|tab| *tab < session.tabs.len()) + .collect(); + for space in pages { + self.prefetch_page(space); + } + for tab in tabs { + self.prefetch_section(tab); + } + } + + /// Readies page `space` of the open section. + pub fn prefetch_page(&mut self, space: ExGuid) { + let Some(session) = &self.session else { + return; + }; + let key = session.key(); + if space == session.space || !self.prefetch.ask((key.clone(), Some(space))) { + return; + } + let read = session.reader(space); + let _ = self + .prefetch + .jobs + .send(Job::Page((key, space), Box::new(read))); + } + + /// Readies section tab `tab` of the open section's folder. + pub fn prefetch_section(&mut self, tab: usize) { + let Some(session) = &self.session else { + return; + }; + let path = session.tabs[tab].path.clone(); + let key = session.library.key(&path); + if tab == session.tab || !self.prefetch.ask((key.clone(), None)) { + return; + } + let _ = self.prefetch.jobs.send(Job::Section { + library: Arc::clone(&session.library), + path, + last: self.last_pages.get(&key).copied(), + notify: Box::new(notify(self.proxy.clone())), + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_least_recent_go_first_past_the_count() { + let mut recent = Recent::new(2); + assert!(recent.put("a", 1).is_empty()); + assert!(recent.put("b", 2).is_empty()); + // Putting again makes it the most recent and gives back what it held. + assert_eq!(recent.put("a", 3), vec![1]); + assert_eq!(recent.put("c", 4), vec![2]); + assert!(!recent.contains(&"b")); + assert_eq!(recent.take(&"a"), Some(3)); + assert_eq!(recent.take(&"a"), None); + assert_eq!( + recent.values_mut().map(|value| *value).collect::>(), + [4] + ); + } + + #[test] + fn the_least_recent_go_first_past_the_budget() { + let mut recent = Recent::new(8); + for (key, bytes) in [("a", 40), ("b", 30), ("c", 20), ("d", 10)] { + recent.put(key, bytes); + } + // Most recent first: d, c, b fit in 60; a does not. + assert_eq!(recent.trim(60, |bytes| *bytes), vec![40]); + assert_eq!(recent.trim(30, |bytes| *bytes), vec![30]); + assert!(recent.contains(&"c") && recent.contains(&"d")); + // One entry over the budget on its own goes too. + assert_eq!(recent.trim(5, |bytes| *bytes), vec![10, 20]); + } +} diff --git a/crates/snowbound/src/print.rs b/crates/snowbound/src/print.rs index 870e426823796a9887dbce08aa03198711ed5539..d2ed1c9594ef9459943aa740b08541c218d299ec 100644 --- a/crates/snowbound/src/print.rs +++ b/crates/snowbound/src/print.rs @@ -1,31 +1,319 @@ -//! File, Print… and Export as PDF…: the open page, or the whole section, laid on paper as -//! OneNote 2010 prints it (`canvas::print`), then handed to the system's printing or saved. +//! File, Print… and Export as PDF…: OneNote 2010's Print Preview and Settings, less the +//! preview, then the chosen pages laid on paper as OneNote prints them (`canvas::print`), +//! handed to the system's printing or saved. -use crate::{State, UserEvent, platform, printer}; -use onestore::page::Page; +use crate::{State, UserEvent, library::Library, platform, printer}; +use accesskit::Role; +use canvas::print::Footer; +use notebook::discover::Folder; +use onestore::{ExGuid, page::Page}; use std::{error::Error, path::PathBuf, sync::Arc}; +use ui::{Anchor, Axis, Id, Spec, Ui, children, fill, popup::Item, px}; +use winit::keyboard::NamedKey; #[derive(Clone, Copy, PartialEq, Eq)] pub enum Scope { Page, + /// The page with the subpages of its group. + PageGroup, Section, + /// Every section of the notebook, which only Export as PDF offers, as OneNote's Save As. + Notebook, +} + +const SCOPES: [(Scope, &str); 4] = [ + (Scope::Page, "Current Page"), + (Scope::PageGroup, "Page Group"), + (Scope::Section, "Current Section"), + (Scope::Notebook, "Current Notebook"), +]; + +/// Print Preview's paper sizes, upright, in points. +const PAPERS: [(&str, [f32; 2]); 8] = [ + ("Letter", canvas::print::LETTER), + ("Tabloid", [792.0, 1224.0]), + ("Legal", [612.0, 1008.0]), + ("Executive", [522.0, 756.0]), + ("A3", [841.89, 1190.55]), + ("A4", canvas::print::A4), + ("B4 (JIS)", [728.5, 1031.81]), + ("B5 (JIS)", [515.91, 728.5]), +]; + +const FOOTERS: [Footer; 4] = [ + Footer::SectionAndPage, + Footer::Page, + Footer::Section, + Footer::None, +]; + +/// macOS's print panel chooses the paper, and the sheets are fitted to it. +const PRINT_PAPER: bool = !cfg!(target_os = "macos"); + +/// What to print and how, as Print Preview and Settings offers it. +#[derive(Clone, Copy)] +pub struct Setup { + scope: Scope, + /// Upright width and height. + paper: [f32; 2], + landscape: bool, + fit_width: bool, + footer: Footer, +} + +/// The choices last made, and the dialog's while it is open. +#[derive(Default)] +pub struct Printing { + last: Option, + dialog: Option, +} + +struct Dialog { + export: bool, + setup: Setup, +} + +impl Printing { + pub fn open(&self) -> bool { + self.dialog.is_some() + } +} + +fn id() -> Id { + Id::ROOT.child("print") +} + +/// A combo's value and the menu listing `names`, choosing among them. +fn choose(ui: &mut Ui, name: &str, names: &[&str], current: usize) -> Option { + let menu = id().child(name); + let combo = ui.id(name); + let shown = names.get(current).copied().unwrap_or("Other"); + ui::shell::combo(ui, name, name, shown, 180.0, menu, true); + let items: Vec<_> = names + .iter() + .enumerate() + .map(|(at, text)| Item { + text, + checked: Some(at == current), + current: at == current, + ..Item::default() + }) + .collect(); + let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default()); + ui::popup::menu(ui, menu, anchor, &items, None) +} + +/// A row of `label` and the controls `build` adds after it. +fn field(ui: &mut Ui, label: &str, build: impl FnOnce(&mut Ui)) { + let row = ui.theme.font_size * 2.0; + ui.open( + label, + Spec { + size: [fill(), px(row)], + gap: 8.0, + ..Spec::default() + }, + ); + ui.leaf( + "label", + Spec { + size: [px(110.0), px(row)], + text: Some(label), + ..Spec::default() + }, + ); + build(ui); + ui.close(); } impl State { - /// Prints `scope` through the system's print dialog, or with `export` saves it as a PDF - /// where the user chooses. Pages are laid out on a thread of their own. - pub(crate) fn print(&mut self, scope: Scope, export: bool) -> Result<(), Box> { + /// Opens Print Preview and Settings for Print…, or with `export` for Export as PDF…. + pub(crate) fn open_print(&mut self, export: bool) { + let mut setup = self.printing.last.unwrap_or_else(|| Setup { + scope: Scope::Page, + paper: printer::paper(), + landscape: false, + fit_width: true, + footer: Footer::SectionAndPage, + }); + if !export && setup.scope == Scope::Notebook { + setup.scope = Scope::Page; + } + if !export && !PRINT_PAPER { + setup.paper = printer::paper(); + } + self.printing.dialog = Some(Dialog { export, setup }); + self.ui.open_popup(id()); + } + + /// Builds the dialog while it is open; Print or Export goes on with its choices, which + /// the next opening starts from. + pub(crate) fn print_dialog(&mut self) { + let Some(dialog) = &mut self.printing.dialog else { + return; + }; + let ui = &mut self.ui; + if !ui.popup_open(id()) { + self.printing.dialog = None; + return; + } + let section = self.session.as_ref().map_or(String::new(), |session| { + session.tabs[session.tab].name.clone() + }); + let theme = ui.theme.clone(); + let entered = ui::popup::navigation(ui, &[], &[NamedKey::Enter]).contains(&NamedKey::Enter); + let title = if dialog.export { + "Export as PDF" + } else { + "Print" + }; + ui.open_as( + id(), + Spec { + axis: Axis::Y, + size: [px(340.0), children()], + fill: Some(theme.popup), + border: Some(theme.chip), + shadow: Some(theme.shadow), + radius: 8.0, + pad: [16.0, 12.0], + gap: 6.0, + anchor: Some(Anchor::Dialog), + role: Some(Role::Dialog), + ..Spec::default() + }, + ); + if let Some(node) = ui.access(id()) { + node.set_label(title); + } + ui.leaf( + "title", + Spec { + size: [fill(), px(theme.font_size * 2.0)], + text: Some(title), + bold: true, + role: Some(Role::Heading), + ..Spec::default() + }, + ); + let setup = &mut dialog.setup; + let scopes = if dialog.export { 4 } else { 3 }; + let names: Vec<&str> = SCOPES[..scopes].iter().map(|(_, name)| *name).collect(); + field(ui, "Print range:", |ui| { + let current = SCOPES.iter().position(|(scope, _)| *scope == setup.scope); + if let Some(at) = choose(ui, "Print range", &names, current.unwrap_or(0)) { + setup.scope = SCOPES[at].0; + } + }); + if dialog.export || PRINT_PAPER { + let names = PAPERS.map(|(name, _)| name); + field(ui, "Paper size:", |ui| { + let current = PAPERS.iter().position(|(_, size)| { + size.iter() + .zip(setup.paper) + .all(|(a, b)| (a - b).abs() < 1.0) + }); + if let Some(at) = choose(ui, "Paper size", &names, current.unwrap_or(usize::MAX)) { + setup.paper = PAPERS[at].1; + } + }); + } + field(ui, "Orientation:", |ui| { + let names = ["Portrait", "Landscape"]; + if let Some(at) = choose(ui, "Orientation", &names, usize::from(setup.landscape)) { + setup.landscape = at == 1; + } + }); + if ui::check_box( + ui, + "fit-width", + "Scale content to paper width", + setup.fit_width, + ) + .clicked + { + setup.fit_width = !setup.fit_width; + } + let footers = [ + format!("{section} Page (number)"), + "Page (number)".to_owned(), + section.clone(), + "(none)".to_owned(), + ]; + let names: Vec<&str> = footers.iter().map(String::as_str).collect(); + field(ui, "Footer:", |ui| { + let current = FOOTERS.iter().position(|footer| *footer == setup.footer); + if let Some(at) = choose(ui, "Footer", &names, current.unwrap_or(0)) { + setup.footer = FOOTERS[at]; + } + }); + ui.open( + "buttons", + Spec { + size: [fill(), children()], + pad: [0.0, 8.0], + gap: 8.0, + ..Spec::default() + }, + ); + ui.leaf( + "space", + Spec { + size: [fill(), px(1.0)], + ..Spec::default() + }, + ); + let cancel = ui::button(ui, "cancel", "Cancel").clicked; + let action = if dialog.export { + "Export…" + } else { + "Print…" + }; + let go = ui::button(ui, "go", action).clicked || entered; + ui.close(); + ui.close(); + if !go && !cancel { + return; + } + let Dialog { export, setup } = self.printing.dialog.take().expect("The dialog is open"); + self.ui.close_popup(id()); + if go { + self.printing.last = Some(setup); + if let Err(error) = self.print(setup, export) { + platform::alert("Couldn't print", &error.to_string()); + } + } + } + + /// Prints `setup`'s pages through the system's print dialog, or with `export` saves + /// them as a PDF where the user chooses. Pages are read and laid out on a thread of + /// their own. + fn print(&mut self, setup: Setup, export: bool) -> Result<(), Box> { let session = self.session.as_ref().ok_or("No section is open")?; - let section = session.tabs[session.tab].name.clone(); - // The open page as the editor holds it; the section's others as stored. + let tab = &session.tabs[session.tab]; + // The open page as the editor holds it; the others as stored. let open = (session.space, self.view.editor.page()?); - let spaces: Vec<_> = match scope { - Scope::Page => vec![session.space], - Scope::Section => session.pages.iter().map(|(space, ..)| *space).collect(), + let at = session + .pages + .iter() + .position(|(space, ..)| *space == session.space); + let spaces: Vec = match (setup.scope, at) { + (Scope::PageGroup, Some(at)) => { + let levels: Vec = session.pages.iter().map(|(.., level)| *level).collect(); + session.pages[group(&levels, at)] + .iter() + .map(|(space, ..)| *space) + .collect() + } + (Scope::Section | Scope::Notebook, _) => { + session.pages.iter().map(|(space, ..)| *space).collect() + } + _ => vec![session.space], }; - let title = match scope { - Scope::Page => open.1.title.clone(), - Scope::Section => section.clone(), + let title = match setup.scope { + Scope::Page | Scope::PageGroup => open.1.title.clone(), + Scope::Section => tab.name.clone(), + Scope::Notebook => session.library.name.clone(), }; let destination = if export { let name = format!("{}.pdf", file_name(&title)); @@ -39,22 +327,62 @@ impl State { } else { None }; + // Other sections are opened on the thread, in the notebook's order. + let others = match setup.scope { + Scope::Notebook => notebook_tabs(&session.library), + _ => Vec::new(), + }; + let (open_tab, library) = ( + (tab.path.clone(), tab.name.clone()), + Arc::clone(&session.library), + ); let replica = Arc::clone(session.section.replica()); let layouts = Arc::clone(&self.layouts); let proxy = self.proxy.clone(); - let paper = printer::paper(); + let paper = if setup.landscape { + [setup.paper[1], setup.paper[0]] + } else { + setup.paper + }; + let layout = canvas::print::Setup { + paper, + fit_width: setup.fit_width, + footer: setup.footer, + }; std::thread::spawn(move || { let made = (|| -> Result, Box> { let mut open = Some(open); - let pages = spaces + let this = spaces .into_iter() .map(|space| match open.take_if(|(shown, _)| *shown == space) { Some((_, page)) => Ok(page), None => replica.page(space).map_err(Into::into), }) .collect::, Box>>()?; + let mut this = Some((open_tab.1.clone(), this)); + let sections = if others.is_empty() { + this.into_iter().collect() + } else { + others + .into_iter() + .map(|(path, name)| { + if path == open_tab.0 + && let Some(open) = this.take() + { + return Ok(open); + } + let section = library.open(&path, || {})?; + let pages = section + .pages()? + .into_iter() + .map(|(space, ..)| section.page(space)) + .collect::, _>>()?; + Ok((name, pages)) + }) + .collect::, Box>>()? + }; let mut engine = layouts.lock().map_err(|_| "Page layout failed")?; - Ok(canvas::print::pdf(pages, &mut engine, paper, §ion)?) + Ok(canvas::print::pdf(sections, &mut engine, &layout)?) })(); let done = made.and_then(|pdf| match &destination { Some(path) => std::fs::write(path, pdf).map(|()| None).map_err(Into::into), @@ -79,6 +407,40 @@ impl State { } } +/// The pages of the group the page at `at` is in, by the pages' outline `levels`: the top +/// page before it, or itself, and the subpages after that. +fn group(levels: &[u32], at: usize) -> std::ops::Range { + let top = |level: &u32| *level <= 1; + let start = levels[..=at].iter().rposition(top).unwrap_or(at); + let end = levels[start + 1..] + .iter() + .position(top) + .map_or(levels.len(), |end| start + 1 + end); + start..end +} + +/// Every readable section of `library`, catalog path and name, a folder's sections before +/// its groups', as OneNote orders them. +fn notebook_tabs(library: &Library) -> Vec<(String, String)> { + fn walk(library: &Library, folder: &Folder, tabs: &mut Vec<(String, String)>) { + tabs.extend( + library + .tabs(&folder.path) + .into_iter() + .map(|tab| (tab.path, tab.name)), + ); + for group in &folder.groups { + walk(library, group, tabs); + } + } + let mut tabs = Vec::new(); + match library.catalog() { + Some(catalog) => walk(library, catalog, &mut tabs), + None => tabs.extend(library.tabs("").into_iter().map(|tab| (tab.path, tab.name))), + } + tabs +} + /// `title` as a file name: without the characters Windows and macOS refuse in one. fn file_name(title: &str) -> String { let name: String = title @@ -115,4 +477,13 @@ mod tests { assert_eq!(file_name("Notes: 3/4"), "Notes- 3-4"); assert_eq!(file_name(" "), "Untitled"); } + + #[test] + fn a_page_group_is_its_top_page_and_subpages() { + let levels = [1, 2, 3, 1, 1, 2]; + assert_eq!(group(&levels, 2), 0..3); + assert_eq!(group(&levels, 0), 0..3); + assert_eq!(group(&levels, 3), 3..4); + assert_eq!(group(&levels, 5), 4..6); + } } diff --git a/crates/snowbound/src/properties.rs b/crates/snowbound/src/properties.rs new file mode 100644 index 0000000000000000000000000000000000000000..3a80591cd984ecab479ef97e355826920940c48a --- /dev/null +++ b/crates/snowbound/src/properties.rs @@ -0,0 +1,196 @@ +//! Notebook Properties, as OneNote 2010's: the notebook's display name on this computer, its +//! colour, and where it lives. + +use crate::{Command, Library, State, art, commands, manage::Structure, menus::SECTION_COLORS}; +use std::sync::Arc; +use ui::{Anchor, Axis, Id, Spec, children, fill, popup::Item, px}; +use winit::keyboard::NamedKey; + +const WIDTH: f32 = 380.0; +const TITLE: &str = "Notebook Properties"; + +/// The dialog's fields until OK keeps them. +pub struct Properties { + library: Arc, + name: String, + /// A colour picked, COLORREF. + color: Option, +} + +fn id() -> Id { + Id::ROOT.child("notebook-properties") +} + +fn name_field() -> Id { + id().child("name") +} + +fn colors() -> Id { + id().child("colors") +} + +impl State { + /// Opens Notebook Properties on `library`, its display name selected, as OneNote's Rename… + /// and Properties… both open it. + pub(crate) fn open_properties(&mut self, library: Arc) { + self.properties = Some(Properties { + name: library.name.clone(), + library, + color: None, + }); + self.ui.open_popup(id()); + self.ui.focus_all(name_field()); + } + + /// Builds Notebook Properties while it is open. OK, or Enter in the name, keeps a + /// non-empty name and a colour picked. + pub(crate) fn properties_dialog(&mut self) { + let Some(dialog) = &mut self.properties else { + return; + }; + let ui = &mut self.ui; + if !ui.popup_open(id()) { + self.properties = None; + return; + } + let theme = ui.theme.clone(); + let row = theme.font_size * 2.0; + let entered = ui::popup::navigation(ui, &[name_field()], &[NamedKey::Enter]) + .contains(&NamedKey::Enter); + ui.open_as( + id(), + Spec { + axis: Axis::Y, + size: [px(WIDTH), children()], + fill: Some(theme.popup), + border: Some(theme.chip), + shadow: Some(theme.shadow), + radius: 8.0, + pad: [16.0, 12.0], + gap: 4.0, + anchor: Some(Anchor::Dialog), + role: Some(accesskit::Role::Dialog), + ..Spec::default() + }, + ); + if let Some(node) = ui.access(id()) { + node.set_label(TITLE); + } + ui.leaf( + "title", + Spec { + size: [fill(), px(row)], + text: Some(TITLE), + bold: true, + role: Some(accesskit::Role::Heading), + ..Spec::default() + }, + ); + let label = |ui: &mut ui::Ui, text: &str| { + ui.leaf( + text, + Spec { + size: [fill(), px(row * 0.8)], + text: Some(text), + ..Spec::default() + }, + ); + }; + let dim = |ui: &mut ui::Ui, key: &str, text: &str| { + ui.leaf( + key, + Spec { + flags: ui::Flags::CLIP, + size: [fill(), px(row * 0.8)], + text: Some(text), + color: Some(theme.text_dim), + ..Spec::default() + }, + ); + }; + label(ui, "Display name:"); + ui::text_field( + ui, + name_field(), + &mut dialog.name, + "", + Spec { + size: [fill(), px(row)], + fill: Some(theme.base), + border: Some(theme.accent), + radius: 4.0, + pad: [6.0, 0.0], + ..Spec::default() + }, + ); + if let Some(node) = ui.access(name_field()) { + node.set_label("Display name"); + } + dim(ui, "hint", "Doesn’t change the notebook’s folder name"); + label(ui, "Color:"); + let shown = dialog.color.or(dialog.library.color()); + let named = SECTION_COLORS + .iter() + .find(|(color, _)| Some(*color) == shown) + .map_or("", |(_, name)| name); + let combo = ui.id("combo"); + ui::shell::combo(ui, "combo", "Color", named, 160.0, colors(), true); + let items: Vec = SECTION_COLORS + .iter() + .map(|&(color, text)| Item { + text, + icon: Some(art::SECTION), + tint: Some(theme.section(crate::section_color(Some(color))).accent), + current: Some(color) == shown, + ..Item::default() + }) + .collect(); + let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default()); + if let Some(index) = ui::popup::menu(ui, colors(), anchor, &items, None) { + dialog.color = Some(SECTION_COLORS[index].0); + } + label(ui, "Location:"); + dim(ui, "location", &dialog.library.location); + let folder = dialog.library.folder().map(std::path::Path::to_owned); + let reveal = commands::command(commands::Id::ShowNotebook).title; + if folder.is_some() && ui::button(ui, "reveal", reveal).clicked { + crate::platform::reveal(&dialog.library.location); + } + ui.open( + "buttons", + Spec { + size: [fill(), children()], + pad: [0.0, 8.0], + gap: 8.0, + ..Spec::default() + }, + ); + ui.leaf( + "space", + Spec { + size: [fill(), px(1.0)], + ..Spec::default() + }, + ); + let ok = ui::button(ui, "ok", "OK").clicked || entered; + let cancel = ui::button(ui, "cancel", "Cancel").clicked; + ui.close(); + ui.close(); + let name = dialog.name.trim(); + if ok && !name.is_empty() { + if name != dialog.library.name || dialog.color.is_some() { + self.commands.push(Command::Structure( + Arc::clone(&dialog.library), + Structure::Properties { + name: name.to_owned(), + color: dialog.color, + }, + )); + } + } else if !cancel { + return; + } + self.ui.close_popup(id()); + self.properties = None; + } +} diff --git a/crates/snowbound/src/recording.rs b/crates/snowbound/src/recording.rs index deaf83f2882fda2f261600776adc03ffd12e0a82..49ab42f3d2fbb75a50d9bf7770c473e9f4006768 100644 --- a/crates/snowbound/src/recording.rs +++ b/crates/snowbound/src/recording.rs @@ -204,6 +204,7 @@ impl State { kind: if video { 2 } else { 1 }, duration_ms, }), + tags: Vec::new(), }; let response = self.view.finish_recording(file)?; self.respond(response); @@ -983,6 +984,7 @@ pub(crate) mod tests { kind, duration_ms: Some(duration), }), + tags: Vec::new(), }; editor.finish_recording(&mut engine, file).unwrap(); store(&mut editor); diff --git a/crates/snowbound/src/rename.rs b/crates/snowbound/src/rename.rs index 1273c3df10d09bfb6a5a63dd656b38ce3ce71f4b..075a271bdaa9d9c97d21215916148a0d991f8783 100644 --- a/crates/snowbound/src/rename.rs +++ b/crates/snowbound/src/rename.rs @@ -6,7 +6,7 @@ use crate::{Command, Library, State, manage::Structure}; use onestore::{ ExGuid, op::{Edit, Op, PageOp}, - page::PageObject, + page::{Page, PageObject}, }; use std::{error::Error, sync::Arc}; use ui::{Event, Id, Spec, Theme, Ui, fill, px}; @@ -170,32 +170,48 @@ impl State { fn retitle(&mut self, space: ExGuid, name: String) -> Result<(), Box> { self.persist()?; let session = self.session.as_mut().ok_or("No section is open")?; - let page = session.section.page(space)?; - let title = page - .objects + let op = retitled(&session.section.page(space)?, space, name.clone())?; + let old = session + .pages .iter() - .find_map(|object| match object { - PageObject::Title(title) => title.outlines.first()?.paragraphs.first()?.text(), - _ => None, - }) - .ok_or("The page has no title to rename")?; - let length = title.text.utf16_offset(title.text.text().len())?; + .find(|(page, ..)| *page == space) + .map(|(_, title, _)| title.clone()) + .unwrap_or_default(); session.section.apply( &self.author, Edit { at: crate::filetime(), - ops: vec![Op::Page { - space, - op: PageOp::Text { - text: title.id, - range: 0..length, - with: name, - }, - }], + ops: vec![op], }, )?; session.pages = session.section.pages()?; + self.made(crate::undo::Change::Retitle { + page: space, + from: name, + to: old, + }); self.edited(vec![space]); self.refresh() } } + +/// The op titling `page`, in `space`, `name`. +pub fn retitled(page: &Page, space: ExGuid, name: String) -> Result> { + let title = page + .objects + .iter() + .find_map(|object| match object { + PageObject::Title(title) => title.outlines.first()?.paragraphs.first()?.text(), + _ => None, + }) + .ok_or("The page has no title to rename")?; + let length = title.text.utf16_offset(title.text.text().len())?; + Ok(Op::Page { + space, + op: PageOp::Text { + text: title.id, + range: 0..length, + with: name, + }, + }) +} diff --git a/crates/snowbound/src/server.rs b/crates/snowbound/src/server.rs index 74fb34544d3c8ce9e0607ca881a987094b98112b..3b8deb5eac464120ea22e55909cb097fa6bbf7f5 100644 --- a/crates/snowbound/src/server.rs +++ b/crates/snowbound/src/server.rs @@ -343,6 +343,76 @@ fn id() -> Id { Id::ROOT.child("server") } +/// The address a sign-in to `mount` is saved by: its server and account, without share or +/// folder, and never a password. +fn saved_address(mount: &Mount) -> String { + Mount { + share: String::new(), + root: String::new(), + ..mount.clone() + } + .url() +} + +/// The saved servers under "Recent Servers", each a button that reconnects to it and one that +/// removes it; returns the one picked and whether to remove it. +pub(crate) fn saved_servers(ui: &mut Ui, servers: &[String]) -> Option<(usize, bool)> { + if servers.is_empty() { + return None; + } + let theme = ui.theme.clone(); + let row = theme.font_size * 2.0; + ui.leaf( + "recent", + Spec { + size: [fill(), px(row)], + text: Some("Recent Servers"), + color: Some(theme.text_dim), + role: Some(Role::Heading), + ..Spec::default() + }, + ); + let mut picked = None; + for (index, address) in servers.iter().enumerate() { + ui.open( + ("saved", address), + Spec { + size: [fill(), px(row)], + gap: 4.0, + ..Spec::default() + }, + ); + let label = address.strip_prefix("smb://").unwrap_or(address); + if ui + .leaf( + "connect", + Spec { + flags: Flags::CLICKABLE | Flags::CLIP, + size: [fill(), px(row)], + icon: Some(art::SERVER), + text: Some(label), + hover_fill: Some(theme.hover()), + radius: 4.0, + pad: [8.0, 0.0], + gap: 6.0, + role: Some(Role::Button), + ..Spec::default() + }, + ) + .clicked + { + picked = Some((index, false)); + } + let remove = ui.id("remove"); + if ui::shell::tool_button(ui, "remove", art::CLOSE, theme.text_dim, None).clicked { + picked = Some((index, true)); + } + name(ui, remove, &format!("Remove {label}")); + ui.close(); + } + picked +} + fn address_field() -> Id { id().child("address") } @@ -409,6 +479,33 @@ impl State { self.ui.focus_all(focus); } + /// Opens the dialog at saved server `address`, signing in with any password the keychain + /// keeps, to browse its shares. + pub(crate) fn reconnect(&mut self, address: &str) { + self.open_server(Some(address)); + if let Some(connect) = &mut self.server { + connect.reopen = false; + } + } + + /// Saves `address` first among the recent servers. + fn remember_server(&mut self, address: String) { + self.servers.retain(|saved| *saved != address); + self.servers.insert(0, address); + self.save_settings(); + } + + /// Picks from, or removes from, the recent servers `saved_servers` lists. + pub(crate) fn saved_server(&mut self, (index, remove): (usize, bool)) { + if remove { + self.servers.remove(index); + self.save_settings(); + } else { + let address = self.servers[index].clone(); + self.reconnect(&address); + } + } + /// Runs `request` off the frame, which wakes when it is answered. fn ask(&mut self, request: Request) { let Some(connect) = &self.server else { @@ -452,9 +549,13 @@ impl State { return; } let replies: Vec = connect.replies.1.try_iter().collect(); + let mut signed_in = None; for reply in replies { - if connect.receive(reply) - && connect.remember == Some(true) + if !connect.receive(reply) { + continue; + } + signed_in = connect.mount.as_ref().map(saved_address); + if connect.remember == Some(true) && !connect.guest && let (Some(mount), login) = (&connect.mount, connect.login()) && let Err(error) = platform::save_login(mount, &login) @@ -462,6 +563,12 @@ impl State { eprintln!("Keeping the password for {}: {error}", mount.host()); } } + if let Some(address) = signed_in { + self.remember_server(address); + } + let Some(connect) = &mut self.server else { + return; + }; if connect.reopen && let Some((mount, login)) = connect.opens() { @@ -529,6 +636,7 @@ impl State { }; let mut chosen = None; let mut back = false; + let mut saved = None; match connect.step { Step::Address => { labelled(ui, "Server Address:", |ui| { @@ -541,6 +649,7 @@ impl State { ); name(ui, address_field(), "Server Address"); }); + saved = saved_servers(ui, &self.servers); } Step::SignIn => { if ui::check_box(ui, "guest", "Connect as guest", connect.guest).clicked { @@ -744,6 +853,9 @@ impl State { self.server = None; return; } + if let Some(saved) = saved { + return self.saved_server(saved); + } let connect = self.server.as_mut().expect("the dialog is open"); let request = if let Some(index) = chosen { connect.enter(index) @@ -1032,6 +1144,15 @@ mod tests { assert_eq!(connect.listing.folders, ["Notes", "public"]); } + #[test] + fn a_saved_server_keeps_its_account_but_not_its_share_or_password() { + let mount = + Mount::from_address("smb://WORK;clover:secret@nas:1445/Notes/My Garden").unwrap(); + assert_eq!(saved_address(&mount), "smb://WORK;clover@nas:1445"); + let guest = Mount::from_address("nas/public").unwrap(); + assert_eq!(saved_address(&guest), "smb://nas"); + } + #[test] fn guests_sign_in_without_an_account() { let mut connect = Connect::new("nas/Notes".into(), None); diff --git a/crates/snowbound/src/settings.rs b/crates/snowbound/src/settings.rs index 2e08625bb54e4a5c5e81676f4b0efddb4ef439be..55853a3756e5bbbd33da6c071458b26ae9172ec8 100644 --- a/crates/snowbound/src/settings.rs +++ b/crates/snowbound/src/settings.rs @@ -36,7 +36,7 @@ pub struct Settings { /// Places and moves things where they are dropped, as OneNote's Snap To Grid off. pub ignore_grid: bool, /// Options' Default font: new text's font and size, and new titles' font. - pub default_font: DefaultFont, + pub default_font: canvas::editor::DefaultFont, /// OneNote's "Page tabs appear on the left". pub page_tabs_left: bool, /// OneNote's "Navigation bar appears on the left" turned off: the notebooks on the right. @@ -44,24 +44,10 @@ pub struct Settings { /// Servers Open Notebook from Server signed in to, latest first, as addresses without a /// password. pub servers: Vec, -} - -/// The font and size OneNote's Default font gives new text. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct DefaultFont { - pub face: String, - pub size: f32, -} - -impl Default for DefaultFont { - /// OneNote 2010's: Calibri 11. - fn default() -> Self { - Self { - face: "Calibri".into(), - size: 11.0, - } - } + /// Chords set in Options' Keyboard, by command, each list in place of its defaults. + pub keys: std::collections::BTreeMap>, + /// Pages shown lately, latest first, which the palette lists first. + pub recent: Vec, } /// What the toolbar's buttons apply from their menus' last picks. @@ -174,10 +160,12 @@ impl crate::State { manual_updates: !self.updates.automatic(), ignore_pen_pressure: !self.pen_pressure, ignore_grid: !self.view.snap_to_grid, - default_font: self.default_font.clone(), + default_font: self.view.editor.default_font.clone(), page_tabs_left: self.page_tabs_left, navigation_bar_right: self.navigation_bar_right, servers: self.servers.clone(), + keys: crate::commands::Keymap::current().saved(), + recent: self.trail.recent.clone(), }; if let Err(error) = settings.save(path) { eprintln!("Cannot save the settings in {}: {error}", path.display()); @@ -239,13 +227,23 @@ mod tests { manual_updates: true, ignore_pen_pressure: true, ignore_grid: true, - default_font: DefaultFont { + default_font: canvas::editor::DefaultFont { face: "Georgia".into(), size: 14.0, + color: Some(0xc07000), }, page_tabs_left: true, navigation_bar_right: true, servers: vec!["smb://clover@nas/Notes".into()], + keys: [("Sidebar".into(), vec!["Command+Shift+L".into()])].into(), + recent: vec![crate::navigation::Place { + notebook: "/notebooks/Personal".into(), + section: "To Do.one".into(), + page: onestore::ExGuid { + guid: [7; 16], + n: 1, + }, + }], }; settings.save(&path).unwrap(); assert_eq!(Settings::load(&path), settings); diff --git a/crates/snowbound/src/sidebar.rs b/crates/snowbound/src/sidebar.rs index 55ec410bbed04f9839ce9fe59234368d905822b8..9cbd5f171296411d66da5c138392168608e8b396 100644 --- a/crates/snowbound/src/sidebar.rs +++ b/crates/snowbound/src/sidebar.rs @@ -31,6 +31,7 @@ pub enum Action { /// Folds or unfolds a notebook's or section group's rows, by `fold_key`. Fold(String), NewNotebook, + NewICloudNotebook, OpenNotebook, /// Signs in again to the notebook opened from its server at this location. SignIn(String), @@ -137,7 +138,8 @@ pub fn header() -> Id { } /// The sidebar's header row, `header` tall and dragging the window when `drags`, then -/// with `rows` the tree of `notebooks` with the open section marked. +/// with `rows` the tree of `notebooks` with the open section marked. The notebook button +/// floats over the header's end at the window's edge, the `right` one or the left. fn sidebar( ui: &mut Ui, tree: &mut Tree, @@ -145,6 +147,7 @@ fn sidebar( header: f32, drags: bool, rows: bool, + right: bool, ) { let (theme, folded) = (tree.theme, tree.folded); ui.open_as( @@ -162,13 +165,18 @@ fn sidebar( }, ); // The notebook button's square, which floats over this place. - ui.leaf( - "toggle", - Spec { - size: [px(RAIL - crate::FRAME), px(ui::shell::TOOL)], - ..Spec::default() - }, - ); + let toggle = |ui: &mut Ui| { + ui.leaf( + "toggle", + Spec { + size: [px(RAIL - crate::FRAME), px(ui::shell::TOOL)], + ..Spec::default() + }, + ); + }; + if !right { + toggle(ui); + } ui.leaf( "title", Spec { @@ -178,6 +186,9 @@ fn sidebar( ..Spec::default() }, ); + if right { + toggle(ui); + } ui.close(); if !rows { return; @@ -242,12 +253,21 @@ fn sidebar( }, ); let options = ("options", art::OPTIONS, "Options", Action::Options); - for (part, icon, label, chosen) in [ - ("new", art::PLUS, "New Notebook", Action::NewNotebook), - ("open", art::NOTEBOOK, "Open Existing", Action::OpenNotebook), - ] - .into_iter() - .chain((!cfg!(target_os = "macos")).then_some(options)) + let icloud = crate::icloud::folder().map(|_| { + ( + "new icloud", + art::ICLOUD, + "New iCloud Notebook", + Action::NewICloudNotebook, + ) + }); + for (part, icon, label, chosen) in icloud + .into_iter() + .chain([ + ("new", art::PLUS, "New Notebook", Action::NewNotebook), + ("open", art::NOTEBOOK, "Open Existing", Action::OpenNotebook), + ]) + .chain((!cfg!(target_os = "macos")).then_some(options)) { let (row, _) = tree_row( ui, @@ -395,10 +415,10 @@ fn folder( ui.id(("unavailable", notebook, &entry.path)), Row { label: &name, - icon: if entry.group { - Leading::Icon(art::SECTION_GROUP) - } else { - Leading::Section(section_color(None)) + icon: match entry.reason { + _ if entry.group => Leading::Icon(art::SECTION_GROUP), + Reason::Evicted => Leading::Icon(art::ICLOUD), + _ => Leading::Section(section_color(None)), }, depth, dim: true, @@ -413,6 +433,10 @@ fn folder( format!("Can't read \u{201c}{name}\u{201d}"), format!("{}\n\nIt appears here once you have access.", entry.error), ), + Reason::Evicted => Action::Unavailable( + format!("Downloading \u{201c}{name}\u{201d}"), + "It opens once iCloud Drive brings it to this computer.".into(), + ), _ => Action::Unavailable( format!("Can't open \u{201c}{name}\u{201d}"), format!("{}.", entry.error), @@ -663,19 +687,27 @@ fn tree_row(ui: &mut Ui, tree: &mut Tree, id: Id, row: Row) -> (Signal, bool) { } impl crate::State { - /// The sidebar left of the section tabs, easing open and shut; returns its width. - pub(crate) fn sidebar(&mut self, theme: &Theme) -> f32 { + /// How wide the sidebar stands this frame as it eases open and shut. + pub(crate) fn sidebar_width(&mut self) -> f32 { if self.temporary { return 0.0; } - let width = self.ui.animate( + self.ui.animate( self.ui.id("sidebar"), if self.sidebar && !self.full_page { WIDTH } else { 0.0 }, - ); + ) + } + + /// The sidebar `width` wide beside the section tabs, on the left, or the right as + /// OneNote's "Navigation bar appears on the left" turned off puts it. + pub(crate) fn sidebar(&mut self, theme: &Theme, width: f32) { + if self.temporary { + return; + } self.ui.open( "sidebar", Spec { @@ -721,6 +753,7 @@ impl crate::State { crate::TAB_ROW, drags, width > 0.5, + self.navigation_bar_right, ); let Tree { action, @@ -754,6 +787,9 @@ impl crate::State { } Some(Action::Renamed(keep)) => self.finish_renaming(keep), Some(Action::NewNotebook) => self.commands.push(crate::Command::NewNotebook), + Some(Action::NewICloudNotebook) => { + self.commands.push(crate::Command::NewICloudNotebook) + } Some(Action::OpenNotebook) => self.commands.push(crate::Command::OpenNotebook), Some(Action::SignIn(location)) => self .commands @@ -762,7 +798,6 @@ impl crate::State { Some(Action::Unavailable(title, message)) => crate::platform::alert(&title, &message), Some(Action::Open { .. }) | None => {} } - width } /// The sidebar's button in its square at the window's edge, and the room the section @@ -774,11 +809,20 @@ impl crate::State { if self.temporary { return; } + // On the right it stands at the body's far edge, as laid out last frame. + let x = match self.navigation_bar_right { + true => self + .ui + .rect(self.ui.current()) + .map_or(0.0, |[left, _, right, _]| right - left - RAIL), + false => 0.0, + }; self.ui.open( "toggle", Spec { flags: Flags::FLOAT | Flags::CLIP, size: [px(RAIL), px(height)], + position: [x, 0.0], pad: [ (RAIL - ui::shell::TOOL) / 2.0, (RAIL - ui::shell::TOOL) / 2.0 + DROP, @@ -1058,15 +1102,25 @@ impl crate::State { self.load(move || { let library = match open { Some(library) => library, - None => Arc::new(read(&location, &cache)?), + None => { + let library = Arc::new(read(&location, &cache)?); + library.purge_recycle_bin(); + library + } }; if let Err(error) = &library.notebook { return Err(error.clone().into()); } - let path = section + let Some(path) = section .filter(|path| library.contains(path)) .or_else(|| library.first_section()) - .ok_or("This folder holds no notebook sections.")?; + else { + // Shown once its sections arrive. + if library.downloading() { + return Ok(crate::Loaded::Library(library, None)); + } + return Err("This folder holds no notebook sections.".into()); + }; let section = library.open(&path, notify)?; let (session, page) = crate::read_session(section, library, path, None)?; Ok(crate::Loaded::Section(Box::new(session), page)) @@ -1086,18 +1140,20 @@ impl crate::State { )); #[cfg(not(target_os = "linux"))] let install = None; + let servers = self.servers.clone(); self.notice( theme, id, theme.base, "No notebooks open", + &servers, crate::icloud::folder() .map(|_| { ( "icloud", art::ICLOUD, - "Use iCloud Drive", - crate::Command::UseICloud, + "New iCloud Notebook", + crate::Command::NewICloudNotebook, ) }) .into_iter() @@ -1133,8 +1189,9 @@ impl crate::State { } /// The page's place while notebook `library` has no sections, as OneNote 2010 shows - /// it, with a button to add one. + /// it, with a button to add one; or while none is on this computer yet. pub(crate) fn no_sections(&mut self, theme: &Theme, library: Arc) { + let downloading = library.downloading(); let new = crate::Command::Structure( library, crate::manage::Structure::NewSection { @@ -1142,23 +1199,26 @@ impl crate::State { }, ); let id = self.ui.id("no sections"); - self.notice( - theme, - id, - theme.strip, - "No sections in this notebook", - vec![("new", art::PLUS, "New Section", new)], - ); + let (title, buttons) = if downloading { + ("Downloading from iCloud Drive…", Vec::new()) + } else { + ( + "No sections in this notebook", + vec![("new", art::PLUS, "New Section", new)], + ) + }; + self.notice(theme, id, theme.strip, title, &[], buttons); } /// A box `id` filled with `background`, showing `title` above `buttons` in its middle: each - /// a part, an icon, a label and what it does. + /// a part, an icon, a label and what it does; then the saved `servers` to reconnect to. fn notice( &mut self, theme: &Theme, id: ui::Id, background: [f32; 4], title: &str, + servers: &[String], buttons: Vec<(&str, &'static [&'static str], &str, crate::Command)>, ) { const BUTTON: [f32; 2] = [240.0, 32.0]; @@ -1217,8 +1277,12 @@ impl crate::State { } } self.commands.extend(chosen); + let saved = crate::server::saved_servers(&mut self.ui, servers); self.ui.close(); self.ui.close(); + if let Some(saved) = saved { + self.saved_server(saved); + } } } diff --git a/crates/snowbound/src/spell_windows.rs b/crates/snowbound/src/spell_windows.rs index 16e12dd24f2a1fcc5517ab05a928ef4fa78456a5..46be2c83d0b65cbbbefabbfc6ddc81cbb11aee21 100644 --- a/crates/snowbound/src/spell_windows.rs +++ b/crates/snowbound/src/spell_windows.rs @@ -45,7 +45,8 @@ type Out = *mut *mut *const usize; /// The object a method answered through `out`, where it answered one. fn answered(result: i32, out: *mut *const usize) -> Option { - (result >= 0 && !out.is_null()).then_some(Object(out)) + // Lazily: an `Object` made for a failed call would release a null pointer when dropped. + (result >= 0 && !out.is_null()).then(|| Object(out)) } /// The strings an IEnumString holds, freed as they are read. diff --git a/crates/snowbound/src/surface_windows.rs b/crates/snowbound/src/surface_windows.rs index bbf5d1beb0f89f504f3ffe120db2e7a3db016155..9a5633e59ab095921a4b1ba3c1115b4b6a1b9fee 100644 --- a/crates/snowbound/src/surface_windows.rs +++ b/crates/snowbound/src/surface_windows.rs @@ -73,23 +73,42 @@ impl Surface { unreachable!() }; let device = unsafe { GetDC(handle.hwnd.get() as HWND) }; - let alpha = if backdrop { 8 } else { 0 }; - let format = gl::PIXELFORMATDESCRIPTOR { + let request = |alpha| gl::PIXELFORMATDESCRIPTOR { nSize: size_of::() as u16, nVersion: 1, - // PFD_SUPPORT_COMPOSITION: the desktop composes the window's alpha. dwFlags: gl::PFD_DRAW_TO_WINDOW | gl::PFD_SUPPORT_OPENGL | gl::PFD_DOUBLEBUFFER - | 0x8000, + | gl::PFD_SUPPORT_COMPOSITION, iPixelType: gl::PFD_TYPE_RGBA, cColorBits: 32, cAlphaBits: alpha, iLayerType: gl::PFD_MAIN_PLANE as u8, ..unsafe { std::mem::zeroed() } }; + // A driver with no accelerated format that has alpha answers with Windows' own + // OpenGL 1.1; frames then go without the glass behind them. + let accelerated = |format: &gl::PIXELFORMATDESCRIPTOR| unsafe { + let chosen = gl::ChoosePixelFormat(device, format); + let mut found = std::mem::zeroed(); + let size = size_of::() as u32; + (chosen != 0 + && gl::DescribePixelFormat(device, chosen, size, &mut found) != 0 + && (found.dwFlags & gl::PFD_GENERIC_FORMAT == 0 + || found.dwFlags & gl::PFD_GENERIC_ACCELERATED != 0)) + .then_some(chosen) + }; + let mut format = request(if backdrop { 8 } else { 0 }); + let mut translucent = backdrop; + let mut chosen = accelerated(&format); + if chosen.is_none() && backdrop { + eprintln!("No accelerated OpenGL format with alpha; drawing opaque"); + format = request(0); + translucent = false; + chosen = accelerated(&format); + } unsafe { - let chosen = gl::ChoosePixelFormat(device, &format); + let chosen = chosen.unwrap_or_else(|| gl::ChoosePixelFormat(device, &format)); if chosen == 0 || gl::SetPixelFormat(device, chosen, &format) == 0 { return Err("No OpenGL pixel format".into()); } @@ -105,15 +124,36 @@ impl Surface { ); } } - let renderer = Renderer::opengl()?; - eprintln!("Canvas GPU: OpenGL"); + let string = |name| unsafe { + let text = gl::glGetString(name); + if text.is_null() { + String::new() + } else { + std::ffi::CStr::from_ptr(text.cast()) + .to_string_lossy() + .into_owned() + } + }; + let driver = format!( + "OpenGL {} ({}, {})", + string(gl::GL_VERSION), + string(gl::GL_RENDERER), + string(gl::GL_VENDOR) + ); + eprintln!("Canvas GPU: {driver}"); + let renderer = Renderer::opengl().map_err(|error| { + format!( + "Snowbound needs OpenGL 2.1, and this display driver has {driver}. Install \ + the driver from your graphics card's maker. ({error})" + ) + })?; let size = window.inner_size(); Ok(( Self { size: [size.width, size.height], backend: Backend::Gl { device, - translucent: backdrop, + translucent, target: None, }, }, diff --git a/crates/snowbound/src/sync.rs b/crates/snowbound/src/sync.rs index 7cf725dc5c0763ebe7c20ea747d520b678f24ff0..80b9597377f5e7b315056d745e28cc00b770f89c 100644 --- a/crates/snowbound/src/sync.rs +++ b/crates/snowbound/src/sync.rs @@ -1,14 +1,27 @@ //! The sync status at the top right, and the popup it opens: OneNote's Shared Notebook -//! Synchronization for the open section's notebook and each of its sections. +//! Synchronization for the open section's notebook, led by where its sync stands. -use crate::{Session, State, art, filetime, library, platform, update}; +use crate::{Library, Session, State, art, filetime, library, platform, update}; use notebook::session::{SyncState, SyncStatus}; -use std::error::Error; +use onestore::ExGuid; +use std::{error::Error, path::Path, sync::Arc, time::Duration}; use ui::{ Anchor, Axis, Flags, Id, Overflow, Spec, Theme, Ui, children, fill, fit, px, shell::TOOL, }; -const WIDTH: f32 = 380.0; +const WIDTH: f32 = 340.0; +const PAD: f32 = 16.0; +/// A sync shorter than this never shows, so a quick publish doesn't flash the bar. +const SHOW_AFTER: Duration = Duration::from_millis(400); +/// How long a shown sync's full bar stays before the status reads up to date. +const LINGER: Duration = Duration::from_millis(700); +/// One pass of the sheen along a running bar. +const SHEEN: f32 = 1.4; +const THIS: &str = if cfg!(target_os = "macos") { + "this Mac" +} else { + "this computer" +}; fn id() -> Id { Id::ROOT.child("sync-status") @@ -18,55 +31,52 @@ fn button() -> Id { Id::ROOT.child("sync-button") } -/// The status's label and icon, and what the reader can do about an error; `mounted` where -/// the notebook syncs through the system's mount because Snowbound's client couldn't sign in. +/// The status's headline and icon. `quiet` holds a sync back that hasn't run `SHOW_AFTER` +/// yet: a notebook synced before still reads up to date. fn describe( sync: &SyncStatus, - mounted: bool, -) -> (&'static str, &'static [&'static str], Option<&'static str>) { - if library::offline() { - return ( - "Working offline", - art::SYNC_OFFLINE, - Some("Changes stay on this computer until you sync."), - ); + offline: bool, + quiet: bool, +) -> (&'static str, &'static [&'static str]) { + if offline { + return ("Working offline", art::SYNC_OFFLINE); } match sync.state() { - SyncState::ReadOnly => ( - "Unable to sync", - art::SYNC_ERROR, - Some( - "You can’t change this notebook where it’s stored. Changes stay on this computer.", - ), - ), - SyncState::InUse => ( - "Section in use", - art::SYNC_BUSY, - Some("Someone else is saving this section. Sync continues when they finish."), - ), - SyncState::NotConnected => ( - "Not connected", - art::SYNC_OFFLINE, - Some("Changes stay on this computer and sync when the notebook is back."), - ), - SyncState::Protected => ( - "Password protected", - art::SYNC_ERROR, - Some("Snowbound can’t open password-protected sections yet."), - ), - SyncState::Unreadable => ("Can’t read this section", art::SYNC_ERROR, None), - SyncState::Failed => ("Unable to sync", art::SYNC_ERROR, None), - SyncState::Syncing => ("Syncing…", art::SYNC_BUSY, None), - SyncState::UpToDate if mounted => { - ("Using the system’s connection", art::SYNC_WARNING, None) - } - SyncState::UpToDate => ("Up to date", art::SYNC_DONE, None), + SyncState::ReadOnly => ("Can’t save changes", art::SYNC_ERROR), + SyncState::InUse => ("Waiting for another device", art::SYNC_BUSY), + SyncState::NotConnected => ("Waiting for connection", art::SYNC_OFFLINE), + SyncState::Protected => ("Password-protected section", art::SYNC_ERROR), + SyncState::Unreadable => ("Can’t read a section", art::SYNC_ERROR), + SyncState::Failed => ("Unable to sync", art::SYNC_ERROR), + SyncState::Syncing if !quiet || sync.synced.is_none() => ("Syncing…", art::SYNC_BUSY), + SyncState::Syncing | SyncState::UpToDate => ("Up to date", art::SYNC_DONE), } } -/// Every section of the open section's notebook with its status, in catalog order: the -/// open one's from its session, the others' from the notebook's background sync. -fn sections(session: &Session) -> Vec<(String, SyncStatus)> { +/// A section's status beside its name. +fn brief(sync: &SyncStatus, offline: bool) -> String { + let waiting = match sync.queued { + 0 => String::new(), + queued => format!(" · {} waiting", changes(queued)), + }; + match sync.state() { + SyncState::UpToDate => "Up to date".to_owned(), + SyncState::Syncing if offline => format!("{} waiting", changes(sync.queued)), + SyncState::Syncing if sync.queued > 0 => format!("Sending {}", changes(sync.queued)), + SyncState::Syncing => "Syncing…".to_owned(), + SyncState::InUse => format!("In use elsewhere{waiting}"), + SyncState::NotConnected => format!("Not connected{waiting}"), + SyncState::ReadOnly => format!("Read-only{waiting}"), + SyncState::Protected => "Password protected".to_owned(), + SyncState::Unreadable => "Can’t read".to_owned(), + SyncState::Failed => format!("Unable to sync{waiting}"), + } +} + +/// Every section of `library` with its status, in catalog order: the open one's from its +/// session, the others' from the notebook's background sync. OneNote's sync dialog leaves +/// out the recycle bin, unless something is wrong there. +fn sections(library: &Library, session: &Session) -> Vec<(String, SyncStatus)> { let open = &session.tabs[session.tab].path; let copy = |sync: &SyncStatus| SyncStatus { synced: sync.synced, @@ -76,16 +86,23 @@ fn sections(session: &Session) -> Vec<(String, SyncStatus)> { .map(|error| std::io::Error::new(error.kind(), error.to_string())), queued: sync.queued, }; - let mut sections = session - .library + let mut sections = library .background .as_ref() .map(|background| background.status()) .unwrap_or_default(); - match sections.iter_mut().find(|(path, _)| path == open) { - Some((_, sync)) => *sync = copy(&session.sync), - None => sections.insert(0, (open.clone(), copy(&session.sync))), + if std::ptr::eq(library, &*session.library) { + match sections.iter_mut().find(|(path, _)| path == open) { + Some((_, sync)) => *sync = copy(&session.sync), + None => sections.insert(0, (open.clone(), copy(&session.sync))), + } } + sections.retain(|(path, sync)| { + sync.error.is_some() + || !path + .rsplit_once('/') + .is_some_and(|(folder, _)| library::recycle_bin(folder)) + }); sections } @@ -117,17 +134,41 @@ fn changes(count: u64) -> String { } } -/// A FILETIME as OneNote's "Last sync": the time, and the date too before today. -fn when(time: u64) -> String { +/// A FILETIME as how long ago it was: minutes within the hour, then the time, and the date +/// too before today. +fn ago(time: u64) -> String { + let minutes = filetime().saturating_sub(time) / 600_000_000; + if minutes < 1 { + return "just now".to_owned(); + } + if minutes < 60 { + return format!("{minutes} min ago"); + } let [_, clock] = platform::date_text(time); let day = platform::short_date(time); if day == platform::short_date(filetime()) { - clock + format!("at {clock}") } else { - format!("{day} {clock}") + format!("{day} at {clock}") } } +/// Whether a sync shows: once it has run `SHOW_AFTER`, and for `LINGER` after it ends up +/// to date where it `shown`, so a quick one never flashes. `id` keeps the timings. +fn shows_sync(ui: &mut Ui, id: Id, syncing: bool, shown: bool) -> bool { + let running = ui.lasted(id.child("running"), syncing); + let ended = ui.lasted(id.child("ended"), !syncing); + let due = match syncing { + true => SHOW_AFTER.checked_sub(running), + false if shown => LINGER.checked_sub(ended), + false => None, + }; + if let Some(due) = due.filter(|due| !due.is_zero()) { + ui.wake_after(due); + } + running >= SHOW_AFTER || !syncing && shown && ended < LINGER +} + const TITLE: &str = "Notebook Sync Status"; /// What the popup says of a newer build: downloading, or waiting to be installed. @@ -143,8 +184,16 @@ fn update_note(update: &update::Status) -> Option { /// The status's icon in the toolbar, which opens the popup; its tooltip names the status, /// and a dot on it says a newer build is ready. pub(crate) fn control(ui: &mut Ui, session: &Session, update: &update::Status, theme: &Theme) { - let sync = overall(§ions(session)); - let (label, icon, _) = describe(&sync, session.library.notice.is_some()); + let sync = overall(§ions(&session.library, session)); + let offline = library::offline(); + let syncing = !offline && sync.state() == SyncState::Syncing; + let quiet = !shows_sync(ui, button(), syncing, false); + let (label, icon) = describe(&sync, offline, quiet); + // Through the system's mount, OneNote's locks aren't taken. + let icon = match session.library.notice { + Some(_) if icon == art::SYNC_DONE => art::SYNC_WARNING, + _ => icon, + }; ui.open_as( button(), Spec { @@ -197,8 +246,534 @@ pub(crate) fn control(ui: &mut Ui, session: &Session, update: &update::Status, t } } +/// What the popup shows of the notebook. +struct Facts<'a> { + name: &'a str, + /// The notebook's folder or address in full. + location: &'a str, + place: Vec, + /// Why the notebook syncs through the system's mount of its share. + notice: Option<&'a str>, + sections: Vec<(String, SyncStatus)>, + /// The open section's pages with conflict pages, and their titles. + conflicts: Vec<(ExGuid, &'a str)>, + offline: bool, + update: &'a update::Status, + /// Whether the open section's file can be shown in the file manager. + local: bool, +} + +/// What the reader picked in the popup. +#[derive(Default)] +struct Picked { + offline: bool, + sync_now: bool, + show_file: bool, + sign_in: bool, + conflict: Option, + build_folder: bool, + restart: bool, +} + +/// Lays out the open popup's contents. +fn build(ui: &mut Ui, facts: &Facts) -> Picked { + let theme = ui.theme.clone(); + let mut picked = Picked::default(); + let line = theme.font_size * 1.6; + let text = |ui: &mut Ui, part: &str, text: &str, color: [f32; 4]| { + ui.leaf( + part, + Spec { + size: [fill(), fit()], + text: Some(text), + color: Some(color), + overflow: Overflow::Wrap, + pad: [0.0, 2.0], + ..Spec::default() + }, + ); + }; + let rule = |ui: &mut Ui, part: &str| { + ui.leaf( + part, + Spec { + size: [fill(), px(1.0)], + fill: Some(theme.chip), + ..Spec::default() + }, + ); + }; + let sync = overall(&facts.sections); + let total = facts.sections.len(); + let done = facts + .sections + .iter() + .filter(|(_, sync)| sync.state() == SyncState::UpToDate) + .count(); + let state = sync.state(); + let syncing = !facts.offline && state == SyncState::Syncing; + let bar = id().child("status").child("bar"); + let shown = ui.rect(bar).is_some() && state == SyncState::UpToDate; + let showing = !facts.offline && shows_sync(ui, button(), syncing, shown); + let (mut headline, mut icon) = describe(&sync, facts.offline, !showing); + let host = facts.place.first().map_or("the notebook", String::as_str); + let waiting = changes(sync.queued); + let conflict; + let advice = if facts.offline { + Some(match sync.queued { + 0 => "Turn off Work offline to sync.".to_owned(), + _ => format!("{waiting} will sync when you go back online."), + }) + } else { + match state { + SyncState::NotConnected => Some(match sync.queued { + 0 => format!("Can’t reach {host}. Sync continues when it’s back."), + _ => format!("Can’t reach {host}. {waiting} will sync when it’s back."), + }), + SyncState::InUse => { + Some("Someone else is saving a section. Sync continues when they finish.".into()) + } + SyncState::ReadOnly => Some(format!( + "This location is read-only. Your changes stay on {THIS}." + )), + SyncState::Protected => { + Some("Snowbound can’t open password-protected sections yet.".into()) + } + SyncState::Unreadable | SyncState::Failed => { + Some("Snowbound keeps trying. Sync Now tries again at once.".into()) + } + _ => None, + } + }; + if !showing && headline == "Up to date" && !facts.conflicts.is_empty() { + conflict = match facts.conflicts.as_slice() { + [(_, title)] => format!("Conflict on “{title}”"), + pages => format!("Conflicts on {} pages", pages.len()), + }; + headline = &conflict; + icon = art::SYNC_WARNING; + } + + // The status: headline, when it last synced or what to do, and a running sync's bar. + ui.open( + "status", + Spec { + axis: Axis::Y, + size: [fill(), children()], + gap: 2.0, + ..Spec::default() + }, + ); + if let Some(node) = ui.access(ui.current()) { + node.set_live(accesskit::Live::Polite); + } + ui.leaf( + "headline", + Spec { + size: [fill(), px(theme.font_size * 2.0)], + text: Some(headline), + icon: Some(icon), + font_size: Some(theme.font_size * 1.2), + bold: true, + overflow: Overflow::Ellipsis, + ..Spec::default() + }, + ); + if showing { + let fraction = match total { + 0 => 1.0, + _ if state == SyncState::UpToDate => 1.0, + _ => done as f32 / total as f32, + }; + let fraction = ui.animate(bar.child("fraction"), fraction); + let running = ui.lasted(bar.child("running"), true).as_secs_f32(); + ui.open_as( + bar, + Spec { + size: [fill(), px(6.0)], + fill: Some(theme.chip), + radius: 3.0, + role: Some(accesskit::Role::ProgressIndicator), + ..Spec::default() + }, + ); + let width = ui.rect(bar).map_or(0.0, |rect| rect[2] - rect[0]); + let filled = width * fraction; + ui.mark([0.0, 0.0, filled, 6.0], theme.accent, 3.0); + // A sheen sweeps the filled part while the sync runs, so a long section still moves. + if fraction < 1.0 && filled > 0.0 { + let band = 36.0_f32.min(filled); + let at = (running % SHEEN) / SHEEN * (filled + band) - band; + let [r, g, b, _] = theme.popup; + ui.mark( + [at.max(0.0), 0.0, (at + band).min(filled), 6.0], + [r, g, b, 0.28], + 3.0, + ); + ui.wake_after(Duration::from_millis(16)); + } + if let Some(node) = ui.access(bar) { + node.set_label("Sync progress"); + node.set_min_numeric_value(0.0); + node.set_max_numeric_value(100.0); + node.set_numeric_value(f64::from((fraction * 100.0).round())); + } + ui.close(); + let mut caption = match total { + 0 | 1 => String::new(), + _ => format!("{done} of {total} sections synced"), + }; + if sync.queued > 0 { + if !caption.is_empty() { + caption += " · "; + } + caption += &format!("{waiting} to send"); + } + if caption.is_empty() { + caption = "Checking for changes…".to_owned(); + } + text(ui, "caption", &caption, theme.text_dim); + } else { + if let Some(advice) = &advice { + text(ui, "advice", advice, theme.text); + } else if !facts.conflicts.is_empty() && headline != "Syncing…" { + text( + ui, + "advice", + "Both versions are kept. Open the page to compare them.", + theme.text, + ); + } + if let Some(synced) = sync.synced { + let prefix = match advice { + Some(_) => "Last synced", + None => "Synced", + }; + text( + ui, + "synced", + &format!("{prefix} {}", ago(synced)), + theme.text_dim, + ); + ui.wake_after(Duration::from_secs(30)); + } + } + ui.close(); + + rule(ui, "identity-rule"); + // Which notebook, and where it lives; the full location in the tooltip. + ui.open( + "identity", + Spec { + axis: Axis::Y, + size: [fill(), children()], + ..Spec::default() + }, + ); + ui.leaf( + "name", + Spec { + size: [fill(), px(line)], + text: Some(facts.name), + icon: Some(art::NOTEBOOK), + bold: true, + overflow: Overflow::Ellipsis, + ..Spec::default() + }, + ); + if !facts.place.is_empty() { + ui.leaf( + "place", + Spec { + size: [fill(), px(line)], + text: Some(&facts.place.join(" › ")), + color: Some(theme.text_dim), + overflow: Overflow::Ellipsis, + pad: [22.0, 0.0], + ..Spec::default() + }, + ); + ui::popup::tooltip(ui, facts.location, "", None); + } + if let Some(notice) = facts.notice { + ui.open( + "notice", + Spec { + size: [fill(), children()], + gap: 8.0, + pad: [22.0, 4.0], + ..Spec::default() + }, + ); + text( + ui, + "text", + &format!("Using the system’s connection. Snowbound couldn’t sign in: {notice}"), + theme.text_dim, + ); + picked.sign_in = ui::button(ui, "sign-in", "Sign In…").clicked; + ui.close(); + } + ui.close(); + + rule(ui, "sections-rule"); + // Only what isn't up to date, and how many others are. + ui.open( + "sections", + Spec { + axis: Axis::Y, + size: [fill(), children()], + ..Spec::default() + }, + ); + // Where the whole notebook can't be reached, the headline says so for each section + // without changes waiting. + let unreached = |sync: &SyncStatus| { + !facts.offline && sync.state() == SyncState::NotConnected && sync.queued == 0 + }; + let behind: Vec<_> = facts + .sections + .iter() + .filter(|(_, sync)| sync.state() != SyncState::UpToDate) + .filter(|(_, sync)| !(state == SyncState::NotConnected && unreached(sync))) + .collect(); + let unreached = match state { + SyncState::NotConnected => facts + .sections + .iter() + .filter(|(_, sync)| unreached(sync)) + .count(), + _ => 0, + }; + for (index, (path, sync)) in behind.iter().enumerate() { + ui.open( + ("section", index), + Spec { + size: [fill(), children()], + gap: 8.0, + ..Spec::default() + }, + ); + let name = path + .strip_suffix(".one") + .unwrap_or(path) + .replace('/', " › "); + ui.leaf( + "name", + Spec { + size: [fill(), px(line)], + text: Some(&name), + overflow: Overflow::Ellipsis, + ..Spec::default() + }, + ); + let color = match sync.state() { + SyncState::Syncing => theme.text_dim, + _ => theme.text, + }; + ui.leaf( + "status", + Spec { + size: [fit(), px(line)], + text: Some(&brief(sync, facts.offline)), + color: Some(color), + ..Spec::default() + }, + ); + ui.close(); + if let Some(error) = sync + .error + .as_ref() + .filter(|_| matches!(sync.state(), SyncState::Failed | SyncState::Unreadable)) + { + text( + ui, + &format!("section-{index}-error"), + &error.to_string(), + theme.text_dim, + ); + } + } + for (space, title) in &facts.conflicts { + ui.open( + ("conflict", space), + Spec { + size: [fill(), children()], + gap: 8.0, + ..Spec::default() + }, + ); + ui.leaf( + "name", + Spec { + size: [fill(), px(line)], + text: Some(title), + icon: Some(art::SYNC_WARNING), + overflow: Overflow::Ellipsis, + ..Spec::default() + }, + ); + if ui::button(ui, "open", "Open Page").clicked { + picked.conflict = Some(*space); + } + ui.close(); + } + let summaries = [ + match (behind.len() + unreached, done) { + (_, 0) | (0, 1) => None, + (0, all) => Some(format!("All {all} sections up to date")), + (_, 1) => Some("1 other section up to date".to_owned()), + (_, others) => Some(format!("{others} other sections up to date")), + }, + match (unreached, behind.is_empty() && done == 0) { + (0, _) | (1, true) => None, + (count, true) => Some(format!("All {count} sections waiting for connection")), + (1, false) => Some("1 other section waiting for connection".to_owned()), + (count, false) => Some(format!("{count} other sections waiting for connection")), + }, + ]; + for (index, summary) in summaries.iter().flatten().enumerate() { + ui.leaf( + ("summary", index), + Spec { + size: [fill(), px(line)], + text: Some(summary), + color: Some(theme.text_dim), + ..Spec::default() + }, + ); + } + ui.close(); + + if let Some(note) = update_note(facts.update) { + rule(ui, "update-rule"); + ui.open( + "update", + Spec { + size: [fill(), children()], + gap: 8.0, + ..Spec::default() + }, + ); + ui.leaf( + "note", + Spec { + size: [fill(), px(theme.font_size * 2.0)], + text: Some(&format!("Snowbound {note}")), + overflow: Overflow::Ellipsis, + ..Spec::default() + }, + ); + picked.build_folder = ui::button(ui, "build-folder", "Build Folder").clicked; + if matches!(facts.update, update::Status::Ready(..)) { + picked.restart = ui::button(ui, "restart", "Restart to Update").clicked; + } + ui.close(); + } + + ui.open( + "controls", + Spec { + size: [fill(), children()], + pad: [0.0, 4.0], + gap: 8.0, + ..Spec::default() + }, + ); + picked.offline = ui::check_box(ui, "offline", "Work offline", facts.offline).clicked; + ui.leaf( + "space", + Spec { + size: [fill(), px(1.0)], + ..Spec::default() + }, + ); + picked.show_file = match facts.local { + true => ui::button(ui, "show-file", platform::SHOW_FILE).clicked, + false => { + ui.leaf( + "show-file", + Spec { + size: [fit(), px(theme.font_size * 2.0)], + text: Some(platform::SHOW_FILE), + color: Some(theme.text_dim), + fill: Some(theme.chip), + radius: 4.0, + pad: [theme.font_size * 0.75, 0.0], + center: true, + role: Some(accesskit::Role::Button), + ..Spec::default() + }, + ); + if let Some(node) = ui.access(ui.id("show-file")) { + node.set_disabled(); + } + false + } + }; + picked.sync_now = ui::button(ui, "sync-now", "Sync Now").clicked; + ui.close(); + picked +} + +/// Opens the popup's box at `anchor`. +fn open(ui: &mut Ui, anchor: Anchor) { + let theme = ui.theme.clone(); + ui.open_as( + id(), + Spec { + axis: Axis::Y, + size: [px(WIDTH), children()], + fill: Some(theme.popup), + border: Some(theme.chip), + shadow: Some(theme.shadow), + radius: 8.0, + pad: [PAD, 12.0], + gap: 10.0, + anchor: Some(anchor), + role: Some(accesskit::Role::Dialog), + ..Spec::default() + }, + ); + if let Some(node) = ui.access(id()) { + node.set_label(TITLE); + } +} + +/// `library`'s status in a line, as its context menu heads with it. +pub(crate) fn line(library: &Library, session: &Session) -> (String, &'static [&'static str]) { + let sections = sections(library, session); + let sync = overall(§ions); + let offline = library::offline(); + let (headline, icon) = describe(&sync, offline, false); + let done = sections + .iter() + .filter(|(_, sync)| sync.state() == SyncState::UpToDate) + .count(); + let text = match sync.state() { + _ if offline && sync.queued > 0 => format!("Offline · {} waiting", changes(sync.queued)), + SyncState::Syncing if !offline && sections.len() > 1 => { + format!("Syncing {done} of {} sections…", sections.len()) + } + SyncState::UpToDate if !offline => match sync.synced { + Some(synced) => format!("Up to date · {}", ago(synced)), + None => headline.to_owned(), + }, + _ if sync.queued > 0 => format!("{headline} · {} waiting", changes(sync.queued)), + _ => headline.to_owned(), + }; + (text, icon) +} + impl State { - /// Builds the sync status popup while it is open. + /// Opens the sync status popup on `library`, at `at` in the window. + pub(crate) fn show_sync(&mut self, library: Arc, at: [f32; 2]) { + self.sync_notebook = Some((library, at)); + self.ui.open_popup(id()); + } + + /// Builds the sync status popup while it is open: on the notebook its context menu + /// opened it for, or the open section's. pub(crate) fn sync_popup(&mut self) -> Result<(), Box> { let (ui, notebooks) = (&mut self.ui, &self.notebooks); let update = self.updates.status(); @@ -207,245 +782,450 @@ impl State { return Ok(()); }; if !ui.popup_open(id()) { + self.sync_notebook = None; return Ok(()); } // Read afresh while shown, so the last sync time follows each poll. session.sync = session.section.sync_status()?; - let theme = ui.theme.clone(); + let (library, anchor) = match &self.sync_notebook { + Some((library, at)) => (Arc::clone(library), Anchor::Point(*at)), + None => ( + Arc::clone(&session.library), + Anchor::Below(ui.rect(button()).unwrap_or_default()), + ), + }; + let own = Arc::ptr_eq(&library, &session.library); let offline = library::offline(); - let sections = sections(session); - let sync = overall(§ions); - let (progress, _, advice) = describe(&sync, session.library.notice.is_some()); - ui.open_as( - id(), - Spec { - axis: Axis::Y, - size: [px(WIDTH), children()], - fill: Some(theme.popup), - border: Some(theme.chip), - shadow: Some(theme.shadow), - radius: 8.0, - pad: [14.0, 10.0], - gap: 2.0, - anchor: Some(Anchor::Below(ui.rect(button()).unwrap_or_default())), - role: Some(accesskit::Role::Dialog), - ..Spec::default() - }, - ); - if let Some(node) = ui.access(id()) { - node.set_label(TITLE); - } - let text = |ui: &mut Ui, part: &str, text: &str, color: [f32; 4], bold: bool| { - ui.leaf( - part, - Spec { - size: [fill(), fit()], - text: Some(text), - color: Some(color), - bold, - overflow: Overflow::Wrap, - pad: [0.0, 3.0], - ..Spec::default() - }, - ); + let file = match own { + true => library.local(session.section.file()), + false => library.folder().map(Path::to_owned), }; - let row = |ui: &mut Ui, key: &str, value: &str| { - ui.open( - key, - Spec { - size: [fill(), children()], - gap: 8.0, - ..Spec::default() - }, - ); - ui.leaf( - "key", - Spec { - size: [px(92.0), px(theme.font_size * 1.6)], - text: Some(key), - color: Some(theme.text_dim), - ..Spec::default() - }, - ); - ui.leaf( - "value", - Spec { - size: [fill(), px(theme.font_size * 1.6)], - text: Some(value), - overflow: Overflow::Ellipsis, - ..Spec::default() - }, - ); - ui.close(); + let facts = Facts { + name: &library.name, + location: &library.location, + place: library.place(), + notice: library.notice.as_deref(), + sections: sections(&library, session), + conflicts: session + .conflicts + .iter() + .filter(|(_, pages)| own && !pages.is_empty()) + .filter_map(|(space, _)| { + let (_, title, _) = session.pages.iter().find(|page| page.0 == *space)?; + Some((*space, title.as_str())) + }) + .collect(), + offline, + update: &update, + local: file.is_some(), }; - text(ui, "title", TITLE, theme.text, true); - row(ui, "Notebook", &session.library.name); - row(ui, "Location", &session.library.location); - row(ui, "Connection", &session.library.transport()); - row(ui, "Progress", progress); - if let Some(synced) = sync.synced { - row(ui, "Last sync", &when(synced)); - } - if sync.queued > 0 { - row(ui, "Not yet synced", &changes(sync.queued)); - } - if let Some(advice) = advice { - text(ui, "advice", advice, theme.text, false); - } - let mut sign_in = None; - if let Some(notice) = &session.library.notice { - let notice = format!("Snowbound’s SMB client couldn’t sign in: {notice}"); - text(ui, "notice", ¬ice, theme.text_dim, false); - if ui::button(ui, "sign-in", "Sign In\u{2026}").clicked { - sign_in = Some(session.library.location.clone()); - } - } - text(ui, "sections", "Sections", theme.text, true); - // OneNote's sync dialog leaves out the recycle bin, unless something is wrong there. - let listed = sections.iter().filter(|(path, sync)| { - sync.error.is_some() - || !path - .rsplit_once('/') - .is_some_and(|(folder, _)| library::recycle_bin(folder)) - }); - for (index, (path, sync)) in listed.enumerate() { - ui.open( - format!("section-{index}"), - Spec { - size: [fill(), children()], - gap: 8.0, - ..Spec::default() - }, - ); - let name = path.strip_suffix(".one").unwrap_or(path); - ui.leaf( - "name", - Spec { - size: [fill(), px(theme.font_size * 1.6)], - text: Some(name), - overflow: Overflow::Ellipsis, - ..Spec::default() - }, - ); - let status = match sync.queued { - 0 => describe(sync, false).0.to_owned(), - queued => format!("{}, {}", describe(sync, false).0, changes(queued)), - }; - ui.leaf( - "status", - Spec { - size: [fit(), px(theme.font_size * 1.6)], - text: Some(&status), - color: Some(theme.text_dim), - ..Spec::default() - }, - ); - ui.close(); - if let Some(error) = &sync.error { - let part = format!("section-{index}-error"); - text(ui, &part, &error.to_string(), theme.text_dim, false); - } - } - let (mut folder, mut restart) = (false, false); - if let Some(note) = update_note(&update) { - text(ui, "update-title", "Snowbound Update", theme.text, true); - row(ui, "Update", ¬e); - ui.open( - "update", - Spec { - size: [fill(), children()], - pad: [0.0, 4.0], - gap: 8.0, - ..Spec::default() - }, - ); - folder = ui::button(ui, "build-folder", "Build Folder").clicked; - ui.leaf( - "space", - Spec { - size: [fill(), px(1.0)], - ..Spec::default() - }, - ); - if matches!(update, update::Status::Ready(..)) { - restart = ui::button(ui, "restart", "Restart to Update").clicked; - } - ui.close(); - } - ui.open( - "controls", - Spec { - size: [fill(), children()], - pad: [0.0, 6.0], - gap: 8.0, - ..Spec::default() - }, - ); - let toggled = ui::check_box(ui, "offline", "Work offline", offline).clicked; - ui.leaf( - "space", - Spec { - size: [fill(), px(1.0)], - ..Spec::default() - }, - ); - let file = session.library.local(session.section.file()); - let show = match file { - Some(_) => ui::button(ui, "show-file", platform::SHOW_FILE).clicked, - None => { - ui.leaf( - "show-file", - Spec { - size: [fit(), px(theme.font_size * 2.0)], - text: Some(platform::SHOW_FILE), - color: Some(theme.text_dim), - fill: Some(theme.chip), - radius: 4.0, - pad: [theme.font_size * 0.75, 0.0], - center: true, - role: Some(accesskit::Role::Button), - ..Spec::default() - }, - ); - if let Some(node) = ui.access(ui.id("show-file")) { - node.set_disabled(); - } - false - } - }; - let now = ui::button(ui, "sync-now", "Sync Now").clicked; - ui.close(); + open(ui, anchor); + let picked = build(ui, &facts); ui.close(); let backgrounds = notebooks .iter() .filter_map(|library| library.background.as_ref()); - if toggled { + if picked.offline { library::set_offline(!offline); session.section.set_offline(!offline); - backgrounds.for_each(|background| background.set_offline(!offline)); - } else if now { - session.section.wake(); + notebooks + .iter() + .for_each(|library| library.set_offline(!offline)); + } else if picked.sync_now { + if own { + session.section.wake(); + } backgrounds.for_each(|background| background.wake()); } - if let Some(file) = file.filter(|_| show) { + if let Some(file) = file.filter(|_| picked.show_file) { platform::show_file(&file); } - if let Some(location) = sign_in { + if picked.sign_in { + let location = library.location.clone(); self.ui.close_popup(id()); self.commands .push(crate::Command::OpenFromServer(Some(location))); } + if let Some(space) = picked.conflict { + self.ui.close_popup(id()); + self.commands.push(crate::Command::OpenPage(space)); + } match update { update::Status::Downloading(version) | update::Status::Ready(version, _) | update::Status::Available(version) - if folder => + if picked.build_folder => { update::show_build(&version) } - _ if restart => self.restart_to_update(), + _ if picked.restart => self.restart_to_update(), _ => {} } Ok(()) } } + +/// The popup in each state, laid out with made-up statuses on a made-up clock. A quick sync +/// never shows, a longer one shows its bar and fills it before reading up to date. Where +/// wgpu paints, `SNOWBOUND_SYNC_RENDER` names a directory for PNGs of each state in both +/// appearances, and `frames-light`/`frames-dark` of a sync running, for a GIF. +#[cfg(test)] +mod tests { + use super::*; + use std::{io, time::Instant}; + use winit::window::Theme as Appearance; + + const SIZE: [f32; 2] = [380.0, 640.0]; + const SCALE: f32 = 2.0; + const NAMES: [&str; 5] = [ + "Recipes", + "Travel", + "Work/Meetings", + "Work/Projects", + "Journal", + ]; + + fn status(synced: bool, queued: u64, error: Option<(io::ErrorKind, &str)>) -> SyncStatus { + SyncStatus { + synced: synced.then(|| filetime() - 3 * 600_000_000), + error: error.map(|(kind, text)| io::Error::new(kind, text)), + queued, + } + } + + fn sections(each: impl Fn(usize) -> SyncStatus) -> Vec<(String, SyncStatus)> { + NAMES + .iter() + .enumerate() + .map(|(index, name)| (format!("{name}.one"), each(index))) + .collect() + } + + static IDLE: update::Status = update::Status::Idle; + + fn facts(sections: Vec<(String, SyncStatus)>) -> Facts<'static> { + Facts { + name: "OneNote", + location: "/Volumes/clover/Documents/OneNote", + place: ["zenith", "clover", "Documents"] + .map(str::to_owned) + .to_vec(), + notice: None, + sections, + conflicts: Vec::new(), + offline: false, + update: &IDLE, + local: true, + } + } + + /// One frame at `now`: the toolbar's button at the top right and the popup open below it. + fn frame(ui: &mut Ui, now: Instant, facts: &Facts) { + ui.begin(SIZE, SCALE, now); + ui.leaf( + "toolbar", + Spec { + size: [fill(), px(TOOL + 12.0)], + fill: Some(ui.theme.strip), + ..Spec::default() + }, + ); + ui.open_as( + button(), + Spec { + flags: Flags::FLOAT, + size: [px(TOOL), px(TOOL)], + position: [SIZE[0] - TOOL - 10.0, 6.0], + icon: Some(art::SYNC_DONE), + ..Spec::default() + }, + ); + ui.close(); + if !ui.popup_open(id()) { + ui.open_popup(id()); + } + open(ui, Anchor::Below(ui.rect(button()).unwrap_or_default())); + build(ui, facts); + ui.close(); + ui.end(); + } + + fn ui(appearance: Appearance) -> Ui { + let theme = crate::theme(appearance, false, false); + let mut ui = Ui::new(theme, Duration::from_millis(500)); + ui.icon_palette = ui.theme.icon_palette( + ui.theme.section(crate::section_color(None)).accent, + [1.0; 4], + ); + ui + } + + #[cfg(all(feature = "wgpu", not(windows)))] + struct Gpu { + renderer: draw::Renderer, + device: wgpu::Device, + queue: wgpu::Queue, + } + + #[cfg(all(feature = "wgpu", not(windows)))] + impl Gpu { + fn new() -> Self { + let instance = + wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = + pollster::block_on(instance.request_adapter(&Default::default())).unwrap(); + let (device, queue) = + pollster::block_on(adapter.request_device(&Default::default())).unwrap(); + let renderer = draw::Renderer::new( + device.clone(), + queue.clone(), + wgpu::TextureFormat::Rgba8UnormSrgb, + ); + Self { + renderer, + device, + queue, + } + } + + /// Writes the frame `ui` laid out to `path`, cropped to the popup and its shadow, or + /// to `bottom` where given so a GIF's frames are one size. + fn save(&mut self, ui: &Ui, path: &Path, bottom: Option) { + let size = SIZE.map(|side| (side * SCALE) as u32); + let interface = ui.layers(); + let layers: Vec<_> = interface + .iter() + .filter_map(|layer| match layer { + ui::Layer::Primitives(primitives) => Some(primitives.layer(SCALE)), + ui::Layer::Custom { .. } => None, + }) + .collect(); + let texture = self.device.create_texture(&wgpu::TextureDescriptor { + label: Some("Sync popup"), + size: wgpu::Extent3d { + width: size[0], + height: size[1], + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8UnormSrgb, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + self.renderer + .draw( + &texture.create_view(&Default::default()), + size, + ui.theme.base, + &layers, + ) + .unwrap(); + let row = (size[0] * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT); + let buffer = self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Sync popup readback"), + size: u64::from(row) * u64::from(size[1]), + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut encoder = self.device.create_command_encoder(&Default::default()); + encoder.copy_texture_to_buffer( + texture.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(row), + rows_per_image: Some(size[1]), + }, + }, + texture.size(), + ); + self.queue.submit([encoder.finish()]); + buffer.map_async(wgpu::MapMode::Read, .., |result| result.unwrap()); + self.device + .poll(wgpu::PollType::Wait { + submission_index: None, + timeout: Some(Duration::from_secs(5)), + }) + .unwrap(); + let mapped = buffer.get_mapped_range(..).unwrap(); + let bottom = bottom + .unwrap_or(ui.rect(id()).unwrap()[3] + 24.0) + .min(SIZE[1]); + let height = (bottom * SCALE) as u32; + let pixels: Vec = mapped + .chunks(row as usize) + .take(height as usize) + .flat_map(|line| &line[..size[0] as usize * 4]) + .copied() + .collect(); + let file = std::fs::File::create(path).unwrap(); + let mut encoder = png::Encoder::new(file, size[0], height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder + .write_header() + .unwrap() + .write_image_data(&pixels) + .unwrap(); + } + } + + /// Lays out `facts` until the popup has opened and settled, from `now`. + fn settle(ui: &mut Ui, now: &mut Instant, facts: &Facts) { + for _ in 0..90 { + *now += Duration::from_millis(16); + frame(ui, *now, facts); + } + } + + fn bar(ui: &Ui) -> bool { + ui.rect(id().child("status").child("bar")).is_some() + } + + #[test] + fn a_quick_sync_never_shows_and_a_long_one_fills_before_up_to_date() { + let mut ui = ui(Appearance::Light); + let mut now = Instant::now(); + let done = facts(sections(|_| status(true, 0, None))); + settle(&mut ui, &mut now, &done); + let busy = facts(sections(|index| status(true, u64::from(index == 1), None))); + for _ in 0..12 { + now += Duration::from_millis(16); + frame(&mut ui, now, &busy); + assert!(!bar(&ui), "a sync under {SHOW_AFTER:?} shows"); + } + settle(&mut ui, &mut now, &done); + assert!(!bar(&ui)); + settle(&mut ui, &mut now, &busy); + assert!(bar(&ui), "a running sync shows its bar"); + now += Duration::from_millis(16); + frame(&mut ui, now, &done); + assert!(bar(&ui), "a finished sync's bar stays a moment, full"); + now += LINGER + Duration::from_millis(50); + frame(&mut ui, now, &done); + frame(&mut ui, now, &done); + assert!(!bar(&ui)); + } + + #[cfg(all(feature = "wgpu", not(windows)))] + #[test] + fn popup_renders_each_state() { + let output = std::env::var_os("SNOWBOUND_SYNC_RENDER").map(std::path::PathBuf::from); + let mut gpu = output.as_ref().map(|output| { + std::fs::create_dir_all(output).unwrap(); + Gpu::new() + }); + type Made = fn() -> Facts<'static>; + let states: Vec<(&str, Made)> = vec![ + ("up-to-date", || facts(sections(|_| status(true, 0, None)))), + ("syncing", || { + facts(sections(|index| match index { + 0 | 1 => status(true, 0, None), + 2 => status(true, 3, None), + _ => status(false, 0, None), + })) + }), + ("offline", || Facts { + offline: true, + ..facts(sections(|index| status(true, [0, 4, 0, 1, 0][index], None))) + }), + ("not-connected", || { + facts(sections(|index| { + status( + true, + [0, 2, 0, 0, 0][index], + Some((io::ErrorKind::TimedOut, "The server didn’t answer")), + ) + })) + }), + ("conflict", || Facts { + conflicts: vec![(ExGuid::default(), "Packing list")], + ..facts(sections(|_| status(true, 0, None))) + }), + ("error", || { + facts(sections(|index| match index { + 3 => status( + true, + 1, + Some((io::ErrorKind::Other, "The section file ended early.")), + ), + _ => status(true, 0, None), + })) + }), + ("in-use", || { + facts(sections(|index| match index { + 2 => status(true, 2, Some((io::ErrorKind::ResourceBusy, "In use"))), + _ => status(true, 0, None), + })) + }), + ("read-only", || { + facts(sections(|index| { + let denied = (io::ErrorKind::PermissionDenied, "Read-only"); + status(true, u64::from(index == 0), (index == 0).then_some(denied)) + })) + }), + ("system-connection", || Facts { + notice: Some("the Keychain has no password for clover on zenith"), + place: vec!["clover".into(), "Documents".into()], + ..facts(sections(|_| status(true, 0, None))) + }), + ("icloud", || Facts { + name: "Personal", + location: "/Users/clover/Library/Mobile Documents/com~apple~CloudDocs/Notes/Personal", + place: vec!["iCloud Drive".into(), "Notes".into()], + ..facts(sections(|_| status(true, 0, None))) + }), + ]; + for (appearance, theme) in [(Appearance::Light, "light"), (Appearance::Dark, "dark")] { + for (name, facts) in &states { + let mut ui = ui(appearance); + let mut now = Instant::now(); + let facts = facts(); + settle(&mut ui, &mut now, &facts); + assert!(ui.rect(id()).is_some(), "{name}"); + if let (Some(output), Some(gpu)) = (&output, &mut gpu) { + gpu.save(&ui, &output.join(format!("{name}-{theme}.png")), None); + } + } + // Changes made offline sending once the share is back, a section at a time, at + // 30 frames a second; a quick one first never shows. + let Some((output, gpu)) = output.as_ref().zip(gpu.as_mut()) else { + continue; + }; + let frames = output.join(format!("frames-{theme}")); + std::fs::create_dir_all(&frames).unwrap(); + let mut ui = ui(appearance); + let mut now = Instant::now(); + settle( + &mut ui, + &mut now, + &facts(sections(|_| status(true, 0, None))), + ); + let mut shot = 0; + for tick in 0..300 { + let seconds = tick as f32 / 60.0; + let quick = (0.2..0.45).contains(&seconds); + // Each section's waiting changes, and when they're sent. + let sending = [(0, 0.0), (3, 1.4), (1, 2.6), (0, 0.0), (2, 3.3)]; + let facts = facts(sections(|index| { + let (queued, sent) = sending[index]; + let queued = match seconds { + _ if index == 0 && quick => 1, + seconds if (0.8..sent).contains(&seconds) => queued, + _ => 0, + }; + status(true, queued, None) + })); + now += Duration::from_micros(16_667); + frame(&mut ui, now, &facts); + if tick % 2 == 0 { + gpu.save(&ui, &frames.join(format!("{shot:03}.png")), Some(360.0)); + shot += 1; + } + } + } + } +} diff --git a/crates/snowbound/src/themes.rs b/crates/snowbound/src/themes.rs new file mode 100644 index 0000000000000000000000000000000000000000..88a2a1cb6879b40958f080b019101a82bb6bda18 --- /dev/null +++ b/crates/snowbound/src/themes.rs @@ -0,0 +1,936 @@ +//! Style themes in the app (`resources/styles.md`): the page wears its own theme, else its +//! section's, else its notebook's, as its paragraph style objects; the Styles gallery +//! applies a style in the page's theme; the Themes dialog picks a theme for the page, the +//! section or the notebook, and edits a notebook's own themes with a live preview. + +use crate::{Library, State, filetime}; +use accesskit::Role; +use notebook::sidecar::themes::{ + self as stored, Assignment, STYLES, Theme, ThemeStyle, Themes, built_in, +}; +use onestore::page::Definition; +use std::{error::Error, sync::Arc}; +use ui::{Anchor, Axis, Flags, Id, Spec, Ui, children, fill, popup::Item, px}; + +/// What a theme is chosen for, from where the page is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Scope { + Page, + Section, + Notebook, +} + +impl Scope { + fn name(self) -> &'static str { + match self { + Self::Page => "This Page", + Self::Section => "This Section", + Self::Notebook => "This Notebook", + } + } +} + +/// The Themes dialog while it is open. +pub struct Dialog { + scope: Scope, + library: Arc, + target: stored::Scope, + /// The built-in themes, then the notebook's own as the dialog edits them. + themes: Vec, + /// The notebook's own themes changed here, by id. + changed: Vec, + /// The notebook's own themes deleted here. + deleted: Vec, + selected: usize, + /// The style the controls edit, by its place in the gallery. + style: usize, +} + +fn id() -> Id { + Id::ROOT.child("themes") +} + +fn popup(name: &str) -> Id { + id().child(name) +} + +/// Spacing the dialog offers above and below a style, in points. +const SPACING: [f32; 9] = [0.0, 2.0, 3.0, 4.0, 6.0, 8.0, 10.0, 12.0, 18.0]; + +/// A style's colour as `#rrggbb` from a COLORREF, and back. +fn hex(colorref: u32) -> String { + let [red, green, blue, _] = colorref.to_le_bytes(); + format!("#{red:02X}{green:02X}{blue:02X}") +} + +fn colorref(hex: &str) -> Option { + let rgb = u32::from_str_radix(hex.strip_prefix('#')?, 16).ok()?; + Some((rgb >> 16) | (rgb & 0xff00) | ((rgb & 0xff) << 16)) +} + +/// A line of `text` in `style` as a page shows it, scaled down to fit a menu row up to +/// `largest` pixels, its colour moved onto the popup's paper so it reads in either scheme. +pub(crate) fn preview(ui: &mut Ui, part: &str, text: &str, style: &ThemeStyle, largest: f32) { + let theme = ui.theme.clone(); + let paper = canvas::gpu::Paper { + color: theme.popup, + ink: theme.text, + }; + let color = style + .color + .as_deref() + .and_then(colorref) + .map_or(theme.text, |color| paper.tint(canvas::gpu::colorref(color))); + ui.leaf( + part, + Spec { + flags: Flags::CLIP, + size: [fill(), fill()], + text: Some(text), + font: Some(&style.font), + font_size: Some((style.size * 1.2).clamp(10.0, largest)), + bold: style.bold, + italic: style.italic, + color: Some(color), + ..Spec::default() + }, + ); +} + +impl State { + /// Where a theme for `scope` goes from the open page: its notebook and the scope's key. + pub(crate) fn theme_target(&self, scope: Scope) -> Option<(Arc, stored::Scope)> { + let session = self.session.as_ref()?; + let library = &session.library; + library.catalog()?; + let target = match scope { + Scope::Page => stored::Scope::page(self.view.editor.identity()?), + Scope::Section => { + stored::Scope::section(library.section_identity(&session.tabs[session.tab].path)?) + } + Scope::Notebook => stored::Scope::Notebook, + }; + Some((Arc::clone(library), target)) + } + + /// The theme the open page wears, if any scope names one. + pub(crate) fn page_theme(&self) -> Option { + let session = self.session.as_ref()?; + let section = session + .library + .section_identity(&session.tabs[session.tab].path); + session + .library + .themes() + .effective(section, self.view.editor.identity()) + } + + /// The styles the gallery offers: the page's theme's, else OneNote 2010's. + pub(crate) fn gallery_sheet(&self) -> Theme { + self.page_theme() + .unwrap_or_else(|| built_in().swap_remove(0)) + } + + /// Stored style `name` as the gallery applies it on the open page. + pub(crate) fn gallery_style(&self, name: &str) -> Definition { + let theme = self.gallery_sheet(); + stored::definition(name, &theme.styles[name]) + } + + /// Dresses the open page in its theme: new text and Enter take the theme's styles, and + /// style objects the theme gives otherwise are restyled, as one edit. A page with no theme + /// keeps what it holds. + pub(crate) fn wear_theme(&mut self) -> Result<(), Box> { + let theme = self.page_theme(); + let sheet = theme.as_ref().map(Theme::sheet).unwrap_or_default(); + self.view.editor.styles = sheet.clone(); + let Some(session) = &self.session else { + return Ok(()); + }; + if theme.is_none() || session.read_only() { + return Ok(()); + } + let page = session.section.page(session.space)?; + let ops = onestore::op::restyle(&page, &sheet)?; + if ops.is_empty() { + return Ok(()); + } + self.persist()?; + let Some(session) = &self.session else { + return Ok(()); + }; + let space = session.space; + session.section.apply( + &self.author, + onestore::op::Edit { + at: filetime(), + ops: ops + .into_iter() + .map(|op| onestore::op::Op::Page { space, op }) + .collect(), + }, + )?; + self.edited(vec![space]); + self.refresh() + } + + /// Opens the Themes dialog choosing a theme for `scope` of the open page. + pub(crate) fn open_themes(&mut self, scope: Scope) { + let Some((library, target)) = self.theme_target(scope) else { + return; + }; + self.show_themes(scope, library, target); + } + + /// Opens the Themes dialog choosing a theme for `target`, which `scope` names. + pub(crate) fn show_themes( + &mut self, + scope: Scope, + library: Arc, + target: stored::Scope, + ) { + let themes = library.themes(); + let assigned = themes.assigned(&target).map(|theme| theme.id); + let all = themes.all(); + let selected = assigned + .and_then(|id| all.iter().position(|theme| theme.id == id)) + .unwrap_or(0); + self.themes = Some(Dialog { + scope, + library, + target, + themes: all, + changed: Vec::new(), + deleted: Vec::new(), + selected, + style: 0, + }); + self.ui.open_popup(id()); + } + + /// Builds the Themes dialog while it is open. Apply keeps the edits and gives the theme + /// to the dialog's scope; No Theme takes the scope's own away; Cancel, Escape or a press + /// outside leave everything as it was. + pub(crate) fn themes_dialog(&mut self) { + let Some(dialog) = &mut self.themes else { + return; + }; + let ui = &mut self.ui; + if !ui.popup_open(id()) { + self.themes = None; + return; + } + let theme = ui.theme.clone(); + let row = theme.font_size * 2.0; + ui.open_as( + id(), + Spec { + axis: Axis::Y, + size: [px(760.0), children()], + fill: Some(theme.popup), + border: Some(theme.chip), + shadow: Some(theme.shadow), + radius: 8.0, + pad: [10.0, 10.0], + gap: 8.0, + anchor: Some(Anchor::Dialog), + role: Some(Role::Dialog), + ..Spec::default() + }, + ); + if let Some(node) = ui.access(id()) { + node.set_label("Themes"); + } + let title = format!("Theme for {}", dialog.scope.name()); + ui.leaf( + "title", + Spec { + size: [fill(), px(row)], + text: Some(&title), + bold: true, + role: Some(Role::Heading), + ..Spec::default() + }, + ); + ui.open( + "body", + Spec { + size: [fill(), px(420.0)], + gap: 10.0, + ..Spec::default() + }, + ); + // The themes, built-in first. + ui.open( + "list", + Spec { + axis: Axis::Y, + size: [px(170.0), fill()], + fill: Some(theme.panel), + radius: 5.0, + pad: [6.0, 6.0], + gap: 2.0, + role: Some(Role::ListBox), + ..Spec::default() + }, + ); + for (index, listed) in dialog.themes.iter().enumerate() { + let shown = index == dialog.selected; + let item = ui.open( + ("theme", index), + Spec { + flags: Flags::CLICKABLE, + size: [fill(), px(row)], + fill: shown.then(|| theme.hover()), + hover_fill: Some(theme.hover()), + radius: 4.0, + pad: [8.0, 0.0], + role: Some(Role::ListBoxOption), + ..Spec::default() + }, + ); + preview(ui, "name", &listed.name, &listed.styles["h2"], 15.0); + if let Some(node) = ui.access(item) { + node.set_label(listed.name.as_str()); + node.set_selected(shown); + } + ui.close(); + if ui.signal(item).clicked { + dialog.selected = index; + } + } + ui.leaf( + "space", + Spec { + size: [fill(), fill()], + ..Spec::default() + }, + ); + ui.open( + "list buttons", + Spec { + size: [fill(), children()], + gap: 6.0, + ..Spec::default() + }, + ); + let duplicate = ui::button(ui, "duplicate", "Duplicate").clicked; + let delete = + dialog.selected >= built_in().len() && ui::button(ui, "delete", "Delete").clicked; + ui.close(); + ui.close(); + // The selected theme's styles. + ui.open( + "editor", + Spec { + axis: Axis::Y, + size: [px(270.0), fill()], + gap: 6.0, + ..Spec::default() + }, + ); + let built = dialog.selected < built_in().len(); + if built { + ui.leaf( + "locked", + Spec { + size: [fill(), px(row * 1.5)], + text: Some("Changing a built-in theme edits a copy."), + color: Some(theme.text_dim), + ..Spec::default() + }, + ); + } else { + let edited = &mut dialog.themes[dialog.selected]; + let before = edited.name.clone(); + ui::text_field( + ui, + popup("name"), + &mut edited.name, + "Theme name", + Spec { + size: [fill(), px(row)], + fill: Some(theme.base), + border: Some(theme.chip), + radius: 4.0, + pad: [6.0, 0.0], + ..Spec::default() + }, + ); + if edited.name != before && !dialog.changed.contains(&edited.id) { + dialog.changed.push(edited.id.clone()); + } + } + // Each control edits the style shown; a built-in theme is copied first. + let mut change: Option = None; + let shown = dialog.themes[dialog.selected].clone(); + let (name, label) = STYLES[dialog.style]; + let style = &shown.styles[name]; + let field = |ui: &mut Ui, label: &str, build: &mut dyn FnMut(&mut Ui)| { + ui.open( + label, + Spec { + size: [fill(), px(row)], + gap: 8.0, + ..Spec::default() + }, + ); + ui.leaf( + "label", + Spec { + size: [px(64.0), px(row)], + text: Some(label), + ..Spec::default() + }, + ); + build(ui); + ui.close(); + }; + field(ui, "Style:", &mut |ui| { + let combo = ui.id("combo"); + ui::shell::combo(ui, "combo", "Style", label, 180.0, popup("styles"), true); + let items: Vec = STYLES + .iter() + .enumerate() + .map(|(at, (_, label))| Item { + text: label, + checked: Some(at == dialog.style), + current: at == dialog.style, + ..Item::default() + }) + .collect(); + let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default()); + if let Some(index) = ui::popup::menu(ui, popup("styles"), anchor, &items, None) { + dialog.style = index; + } + }); + let fonts = &self.fonts; + field(ui, "Font:", &mut |ui| { + let combo = ui.id("combo"); + ui::shell::combo( + ui, + "combo", + "Font", + &style.font, + 180.0, + popup("fonts"), + true, + ); + let mut names: Vec<&str> = crate::FONTS.to_vec(); + names.push("Georgia"); + names.extend(fonts.iter().map(String::as_str)); + let items: Vec = names + .iter() + .map(|font| Item { + text: font, + font: Some(font), + current: *font == style.font, + ..Item::default() + }) + .collect(); + let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default()); + if let Some(index) = ui::popup::menu(ui, popup("fonts"), anchor, &items, Some("Font")) { + let font = names[index].to_owned(); + change = Some(Change::Font(font)); + } + }); + field(ui, "Size:", &mut |ui| { + let combo = ui.id("combo"); + let size = format!("{}", style.size); + ui::shell::combo(ui, "combo", "Size", &size, 60.0, popup("sizes"), true); + let labels: Vec = crate::SIZES.iter().map(|size| format!("{size}")).collect(); + let items: Vec = labels + .iter() + .map(|label| Item { + text: label, + checked: Some(*label == size), + current: *label == size, + ..Item::default() + }) + .collect(); + let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default()); + if let Some(index) = ui::popup::menu(ui, popup("sizes"), anchor, &items, None) { + let size = crate::SIZES[index]; + change = Some(Change::Size(size)); + } + }); + field(ui, "", &mut |ui| { + if ui::check_box(ui, "bold", "Bold", style.bold).clicked { + change = Some(Change::Bold); + } + if ui::check_box(ui, "italic", "Italic", style.italic).clicked { + change = Some(Change::Italic); + } + }); + field(ui, "Color:", &mut |ui| { + let button = ui.id("color"); + let shown = style.color.clone().unwrap_or_else(|| "Automatic".into()); + ui::shell::combo(ui, "color", "Color", &shown, 120.0, popup("colors"), true); + let swatches: Vec<_> = crate::FONT_COLORS + .iter() + .map(|&(color, name)| (canvas::gpu::colorref(color), name)) + .collect(); + let anchor = Anchor::Below(ui.rect(button).unwrap_or_default()); + if let Some(chosen) = + ui::popup::colors(ui, popup("colors"), anchor, "Automatic", &swatches, 10) + { + let color = chosen.and_then(|chosen| { + crate::FONT_COLORS + .iter() + .zip(&swatches) + .find(|(_, (swatch, _))| *swatch == chosen) + .map(|((color, _), _)| hex(*color)) + }); + change = Some(Change::Color(color)); + } + }); + for (part, label, value) in [ + ("before", "Above:", style.before), + ("after", "Below:", style.after), + ] { + field(ui, label, &mut |ui| { + let combo = ui.id("combo"); + let shown = format!("{value} pt"); + ui::shell::combo(ui, "combo", label, &shown, 80.0, popup(part), true); + let labels: Vec = SPACING + .iter() + .map(|points| format!("{points} pt")) + .collect(); + let items: Vec = labels + .iter() + .map(|text| Item { + text, + checked: Some(*text == shown), + current: *text == shown, + ..Item::default() + }) + .collect(); + let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default()); + if let Some(index) = ui::popup::menu(ui, popup(part), anchor, &items, None) { + let points = SPACING[index]; + change = Some(Change::Spacing(part == "before", points)); + } + }); + } + ui.close(); + // The preview: every style as a page in the theme shows it. + ui.open( + "preview", + Spec { + axis: Axis::Y, + size: [fill(), fill()], + fill: Some(theme.popup), + border: Some(theme.chip), + radius: 5.0, + pad: [14.0, 10.0], + role: Some(Role::Group), + ..Spec::default() + }, + ); + for (at, (name, label)) in STYLES.iter().enumerate() { + let style = &shown.styles[*name]; + let height = (style.size * 1.2).clamp(10.0, 26.0) * 1.35 + + (style.before + style.after).min(12.0); + let line = ui.open( + ("line", at), + Spec { + flags: Flags::CLICKABLE, + size: [fill(), px(height)], + fill: (at == dialog.style).then(|| theme.hover()), + radius: 3.0, + pad: [4.0, (style.before.min(12.0)) / 2.0], + ..Spec::default() + }, + ); + preview(ui, "text", label, style, 26.0); + ui.close(); + if ui.signal(line).clicked { + dialog.style = at; + } + } + ui.close(); + ui.close(); + ui.open( + "buttons", + Spec { + size: [fill(), children()], + gap: 8.0, + ..Spec::default() + }, + ); + let none = ui::button(ui, "none", "No Theme").clicked; + ui.leaf( + "space", + Spec { + size: [fill(), px(1.0)], + ..Spec::default() + }, + ); + let cancel = ui::button(ui, "cancel", "Cancel").clicked; + let apply = ui::button(ui, "apply", "Apply").clicked; + ui.close(); + ui.close(); + + let now = filetime(); + let copy = |dialog: &mut Dialog, name: String| { + let mut copy = dialog.themes[dialog.selected].clone(); + copy.id = fresh_id(now, dialog.themes.len()); + copy.name = name; + copy.modified = now; + dialog.changed.push(copy.id.clone()); + dialog.themes.push(copy); + dialog.selected = dialog.themes.len() - 1; + }; + if duplicate { + let name = format!("{} Copy", dialog.themes[dialog.selected].name); + copy(dialog, name); + } + if let Some(change) = change { + if dialog.selected < built_in().len() { + let name = format!("My {}", dialog.themes[dialog.selected].name); + copy(dialog, name); + } + let edited = &mut dialog.themes[dialog.selected]; + change.apply( + edited + .styles + .get_mut(name) + .expect("a theme has every style"), + ); + if !dialog.changed.contains(&edited.id) { + dialog.changed.push(edited.id.clone()); + } + } + if delete && dialog.selected >= built_in().len() { + let mut gone = dialog.themes.remove(dialog.selected); + gone.deleted = true; + dialog.deleted.push(gone); + dialog.selected = 0; + } + if !(apply || none || cancel) { + return; + } + if !cancel { + let themes = dialog + .themes + .iter() + .filter(|listed| dialog.changed.contains(&listed.id)) + .chain(&dialog.deleted) + .map(|listed| Theme { + modified: now, + ..listed.clone() + }) + .collect(); + let chosen = &dialog.themes[dialog.selected]; + let assignment = Assignment { + scope: dialog.target.clone(), + theme: apply.then(|| chosen.id.clone()), + assigned: now, + }; + dialog.library.save_themes(Themes { + themes, + assignments: vec![assignment], + }); + } + self.ui.close_popup(id()); + self.themes = None; + if !cancel && let Err(error) = self.wear_theme() { + eprintln!("Restyling the page failed: {error}"); + } + } +} + +/// An edit the dialog's controls make to the style they show. +enum Change { + Font(String), + Size(f32), + Bold, + Italic, + Color(Option), + /// Space above (true) or below, in points. + Spacing(bool, f32), +} + +impl Change { + fn apply(self, style: &mut ThemeStyle) { + match self { + Self::Font(font) => style.font = font, + Self::Size(size) => style.size = size, + Self::Bold => style.bold = !style.bold, + Self::Italic => style.italic = !style.italic, + Self::Color(color) => style.color = color, + Self::Spacing(true, points) => style.before = points, + Self::Spacing(false, points) => style.after = points, + } + } +} + +/// A new theme's id: unique to this machine's clock and the themes it knows. +fn fresh_id(now: u64, count: usize) -> String { + format!("{now:x}-{count}") +} + +/// The Styles gallery under the toolbar's Styles button: the eleven styles drawn in +/// `sheet`, the one at the caret outlined, then the theme commands. Returns the command chosen. +pub(crate) fn gallery( + ui: &mut Ui, + menu: Id, + anchor: Anchor, + sheet: &Theme, + current: Option<&str>, +) -> Option { + use crate::commands::Id as Cmd; + let scopes = [Scope::Page, Scope::Section, Scope::Notebook]; + let groups = [ + ui::popup::Group { + heading: &sheet.name, + cells: STYLES.len(), + columns: 1, + size: [240.0, 34.0], + }, + ui::popup::Group { + heading: "Theme", + cells: scopes.len(), + columns: 1, + size: [240.0, 24.0], + }, + ]; + let shown: Vec = STYLES + .iter() + .position(|(name, _)| Some(*name) == current) + .into_iter() + .collect(); + let chosen = ui::popup::gallery(ui, menu, anchor, &groups, &shown, |ui, index| match STYLES + .get(index) + { + Some((name, label)) => { + preview(ui, "preview", label, &sheet.styles[*name], 24.0); + if let Some(node) = ui.access(ui.id("preview")) { + node.set_label(*label); + } + } + None => { + let label = format!("{}…", scopes[index - STYLES.len()].name()); + ui.leaf( + "scope", + Spec { + size: [fill(), fill()], + text: Some(&label), + ..Spec::default() + }, + ); + } + })?; + Some(match scopes.get(chosen.wrapping_sub(STYLES.len())) { + Some(scope) => Cmd::Theme(*scope), + None => Cmd::Style(chosen), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use canvas::{ + document::TextPosition, + editor::{CanvasEditor, Formatting}, + layout::TextEngine, + }; + use onestore::{ + RevisionIndex, Store, + document::{Document, Kind}, + op, + page::Page, + }; + + fn page(bytes: &[u8]) -> (onestore::ExGuid, Page) { + let store = Store::parse(bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let (space, _) = document.pages().unwrap()[0]; + (space, Page::from_space(&document, space).unwrap()) + } + + /// The name of each paragraph's style on `page`, with its definition, in page order. + fn styled(page: &Page) -> Vec<(String, Definition)> { + let mut out = Vec::new(); + let mut pending: Vec<&onestore::page::PageParagraph> = Vec::new(); + for object in &page.objects { + match object { + onestore::page::PageObject::Outline(outline) => { + pending.extend(outline.paragraphs.iter()); + } + onestore::page::PageObject::Title(title) => { + pending.extend(title.outlines.iter().flat_map(|o| &o.paragraphs)); + } + _ => {} + } + } + for paragraph in pending { + let Some(style) = paragraph.style else { + continue; + }; + let definition = page.definitions[&style].clone(); + if let Kind::Style { + name: Some(name), .. + } = &definition.kind + { + out.push((name.clone(), definition)); + } + } + out + } + + /// A section per built-in theme, each a page with a paragraph in every gallery style, + /// applied through the editor as the gallery does and the title restyled as opening the + /// page does: every style object holds the theme's definition under OneNote's name, the + /// next paragraph after a heading is Normal, and a second theme restyles the page whole. + /// `SNOWBOUND_STYLES_EXPORT` names a new directory receiving the notebook, its themes + /// assigned by section, for a cold open in OneNote (`corpus/styles`). + #[test] + fn themed_pages_hold_their_theme_under_onenote_s_names() { + let temporary = + std::env::temp_dir().join(format!("snowbound-styles-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&temporary); + let root = temporary.join("Styles"); + std::fs::create_dir_all(&root).unwrap(); + let mut engine = TextEngine::default(); + let mut sections = Vec::new(); + for theme in built_in() { + let file = format!("{}.one", theme.name); + let source = onestore::create_section(&file, "", "Author").unwrap(); + let (space, before) = page(&source); + let mut editor = CanvasEditor::from_page(before, &mut engine).unwrap(); + let sheet = theme.sheet(); + editor.styles = sheet.clone(); + let body = editor + .outlines() + .iter() + .find(|outline| !outline.title) + .unwrap() + .id; + editor.focus_outline(body).unwrap(); + for (paragraph, (name, label)) in STYLES.iter().enumerate() { + if paragraph > 0 { + editor.enter(&mut engine, false).unwrap(); + } + editor + .insert(&mut engine, &format!("{label} in {}", theme.name)) + .unwrap(); + editor + .select( + [TextPosition { + paragraph, + offset: 0, + }; 2] + .into(), + ) + .unwrap(); + editor + .format(&mut engine, Formatting::Style(sheet[*name].clone())) + .unwrap(); + let offset = editor.outlines()[editor + .outlines() + .iter() + .position(|outline| outline.id == body) + .unwrap()] + .document() + .paragraphs() + .nth(paragraph) + .unwrap() + .text() + .encode_utf16() + .count() as u32; + editor + .select([TextPosition { paragraph, offset }; 2].into()) + .unwrap(); + } + let arena = onestore::Arena::default(); + let mut section = onestore::Section::open(&arena, source.to_vec()).unwrap(); + let edit = |ops: Vec, at| op::Edit { + at, + ops: ops + .into_iter() + .map(|op| op::Op::Page { space, op }) + .collect(), + }; + section + .apply( + "Author", + &edit(editor.take_ops().unwrap(), 134_000_000_000_000_000), + ) + .unwrap(); + let typed = section.page(space).unwrap(); + let restyle = op::restyle(&typed, &sheet).unwrap(); + section + .apply("Author", &edit(restyle, 134_000_000_000_000_001)) + .unwrap(); + section.seal().unwrap(); + let written = section.image(); + let (_, stored) = page(&written); + let styles: Vec<_> = styled(&stored) + .into_iter() + .filter(|(name, _)| sheet.contains_key(name)) + .collect(); + let names: std::collections::BTreeSet<&str> = + styles.iter().map(|(name, _)| name.as_str()).collect(); + assert_eq!(names.len(), STYLES.len(), "{}", theme.name); + for (name, definition) in &styles { + assert_eq!(definition, &sheet[name], "{} {name}", theme.name); + } + assert!(op::restyle(&stored, &sheet).unwrap().is_empty()); + // Another theme restyles the page whole, keeping the names. + let other = built_in().swap_remove(3).sheet(); + let mut copy = onestore::Section::open(&arena, written.clone()).unwrap(); + copy.apply( + "Author", + &edit( + op::restyle(&stored, &other).unwrap(), + 134_000_000_000_000_002, + ), + ) + .unwrap(); + for (name, definition) in styled(©.page(space).unwrap()) { + if !other.contains_key(&name) { + continue; + } + assert_eq!(definition, other[&name], "{} to modern {name}", theme.name); + } + std::fs::write(root.join(&file), &written).unwrap(); + let file_id = Store::parse(&written).unwrap().header.file_id; + sections.push((file, file_id, theme.id)); + } + let listed: Vec<(&str, [u8; 16])> = sections + .iter() + .map(|(file, id, _)| (file.as_str(), *id)) + .collect(); + let toc = onestore::create_table_of_contents("Open Notebook.onetoc2", &listed).unwrap(); + std::fs::write(root.join("Open Notebook.onetoc2"), toc).unwrap(); + let notebook = notebook::session::Notebook::open(&root, temporary.join("cache")).unwrap(); + let themes = notebook + .save_themes(Themes { + assignments: sections + .iter() + .map(|(_, id, theme)| Assignment { + scope: stored::Scope::section(*id), + theme: Some(theme.clone()), + assigned: 134_000_000_000_000_000, + }) + .collect(), + ..Themes::default() + }) + .unwrap(); + for (_, id, theme) in §ions { + assert_eq!(&themes.effective(Some(*id), None).unwrap().id, theme); + } + if let Some(directory) = std::env::var_os("SNOWBOUND_STYLES_EXPORT") { + let status = std::process::Command::new("cp") + .arg("-R") + .arg(&root) + .arg(directory) + .status() + .unwrap(); + assert!(status.success()); + } + std::fs::remove_dir_all(&temporary).unwrap(); + } +} diff --git a/crates/snowbound/src/undo.rs b/crates/snowbound/src/undo.rs new file mode 100644 index 0000000000000000000000000000000000000000..912c47b8da9f57d01a4754948cf29d2f79a6c2c4 --- /dev/null +++ b/crates/snowbound/src/undo.rs @@ -0,0 +1,1630 @@ +//! Undo and Redo across pages, sections and notebooks (`arc/canvas.md`). Each page keeps +//! its own history in its editor, kept while the window is open as OneNote 2010 keeps it; the +//! window keeps what was done to pages and sections between those edits, so Undo takes back +//! whichever came last where the user is. Every step taken back is an edit of its own. + +use crate::{Command, Library, Loaded, State, UserEvent}; +use canvas::editor::CanvasEditor; +use notebook::{Replica, discover::Folder}; +use onestore::{ + ExGuid, PageCreation, PageEdit, + op::{Edit, Op, SectionOp}, + page::{Page, PageObject}, +}; +use std::{error::Error, sync::Arc}; + +/// Pages whose editors are kept with their history once left, at most. +const KEPT: usize = 50; + +#[derive(Default)] +pub struct Timeline { + undo: Vec, + redo: Vec, + /// Editors of pages left holding history, the longest left first. + parked: Vec<(ExGuid, CanvasEditor)>, + /// Actions on their way, while which Undo and Redo wait. + busy: usize, +} + +enum Step { + /// One step of a page's own history, which its editor holds. + Edit(ExGuid), + Action(Action), +} + +impl Step { + fn edits(&self, page: ExGuid) -> bool { + matches!(self, Step::Edit(edited) if *edited == page) + } +} + +/// A change to pages or sections and where it was made: a notebook, by location, and +/// for a change to pages, the section by file identity. Undo reaches it there. +pub struct Action { + notebook: String, + section: Option<[u8; 16]>, + change: Change, +} + +/// What an action does when taken; doing one gives the action that takes it back. +pub enum Change { + /// Takes pages out of the section, to the recycle bin where the notebook has one. Pages + /// `created` go only while they hold nothing, and never to the bin. Shows `show` after, + /// else the page after the first one taken, or before it. + Delete { + pages: Vec, + show: Option, + created: bool, + }, + /// Puts pages back where they were, taking them out of the recycle bin unless `created`, + /// and takes out `added`, the page a section left without pages gained, while it holds + /// nothing. + Restore { + pages: Vec, + added: Option, + created: bool, + }, + /// Puts the pages `moved` back as `order` lists the section's pages, with their levels. + Arrange { + order: Vec<(ExGuid, u32)>, + moved: Vec, + }, + /// Titles a page `to` while its title is `from`. + Retitle { + page: ExGuid, + from: String, + to: String, + }, + /// Moves a page to the end of the section `target` names, as a drop on its tab does. + Send { page: ExGuid, target: [u8; 16] }, + /// Brings a page sent to `target` back, before `before` at `level`, taking out `added` + /// while it holds nothing. + Return { + page: ExGuid, + target: [u8; 16], + before: Option, + level: u32, + added: Option, + }, + /// Names a section or group `to` while it is named `from`. + Rename { + entry: [u8; 16], + from: String, + to: String, + }, + /// Puts a section or group, `entry`, in the folder `folder` names (the notebook's own + /// without), which then takes `order`. + Place { + entry: Option<[u8; 16]>, + folder: Option<[u8; 16]>, + order: Vec<[u8; 16]>, + }, +} + +/// A page taken out of its section, and where it was: before `before` at `level`. +pub struct Kept { + page: Page, + before: Option, + level: u32, +} + +/// Where the user is: the open section's notebook, by location, and the section. +pub struct Here { + notebook: String, + section: [u8; 16], +} + +impl Action { + fn applies(&self, here: &Here) -> bool { + self.notebook == here.notebook && self.section.is_none_or(|section| section == here.section) + } +} + +/// What Undo or Redo takes next. +pub enum Next { + /// The open page's last edit, or the last undone. + Edit, + Action(Action), + /// The page a created page Undo would take out still holds edits made since, so + /// Undo shows it, where those go back first. + Show(ExGuid), +} + +impl Timeline { + /// Notes `action`, just done; what Redo held of actions goes. + pub fn record(&mut self, action: Action) { + self.undo.push(Step::Action(action)); + self.redo.retain(|step| matches!(step, Step::Edit(_))); + } + + /// Notes `action`, which takes back one the user just did (`taken` none), or Undo, or + /// `taken` Redo, just took. + fn note(&mut self, taken: Option, action: Action) { + match taken { + None => self.record(action), + Some(true) => self.undo.push(Step::Action(action)), + Some(false) => self.redo.push(Step::Action(action)), + } + } + + /// Follows page `page`'s history, `depth` steps of Undo and Redo, after an edit or + /// before one is taken: steps it gained are the newest; `run`, a run of typing that + /// went on, makes its last step the newest. + pub fn edited(&mut self, page: ExGuid, [undo, redo]: [usize; 2], run: bool) { + let count = |steps: &[Step]| steps.iter().filter(|step| step.edits(page)).count(); + let (had, had_redo) = (count(&self.undo), count(&self.redo)); + if undo < had { + drop_newest(&mut self.undo, page, had - undo); + } else if undo > had || run { + // A run of typing that went on makes its step the newest. + if undo == had + && let Some(at) = self.undo.iter().rposition(|step| step.edits(page)) + { + let step = self.undo.remove(at); + self.undo.push(step); + } + self.undo.extend((had..undo).map(|_| Step::Edit(page))); + // A new edit ends what actions Redo held. + self.redo.retain(|step| matches!(step, Step::Edit(_))); + } + if redo < had_redo { + drop_newest(&mut self.redo, page, had_redo - redo); + } else { + self.redo.extend((had_redo..redo).map(|_| Step::Edit(page))); + } + } + + /// What Undo, or `redo` Redo, takes next on page `page`, its history `depth` steps deep, + /// with `here` open: the newer of the page's own step and the last action made there. + /// An action is taken off the timeline; an edit stays until `stepped`. + pub fn next( + &mut self, + redo: bool, + page: ExGuid, + depth: [usize; 2], + here: &Here, + ) -> Option { + if self.busy > 0 { + return None; + } + self.edited(page, depth, false); + let steps = if redo { &mut self.redo } else { &mut self.undo }; + let at = steps.iter().rposition(|step| match step { + Step::Edit(edited) => *edited == page, + Step::Action(action) => action.applies(here), + })?; + match &steps[at] { + Step::Edit(_) => return Some(Next::Edit), + Step::Action(Action { + change: + Change::Delete { + pages, + created: true, + .. + }, + .. + }) if !redo => { + let edited = pages + .iter() + .find(|page| steps.iter().any(|step| step.edits(**page))); + if let Some(edited) = edited { + return Some(Next::Show(*edited)); + } + } + Step::Action(_) => {} + } + match steps.remove(at) { + Step::Action(action) => Some(Next::Action(action)), + Step::Edit(_) => unreachable!("an edit is not taken off"), + } + } + + /// Notes that Undo, or `redo` Redo, took page `page`'s step. + pub fn stepped(&mut self, page: ExGuid, redo: bool) { + let (from, to) = if redo { + (&mut self.redo, &mut self.undo) + } else { + (&mut self.undo, &mut self.redo) + }; + if let Some(at) = from.iter().rposition(|step| step.edits(page)) { + to.push(from.remove(at)); + } + } + + /// Whether Undo, or `redo` Redo, has an action to take `here`. + pub fn reaches(&self, redo: bool, here: &Here) -> bool { + let steps = if redo { &self.redo } else { &self.undo }; + self.busy == 0 + && steps + .iter() + .any(|step| matches!(step, Step::Action(action) if action.applies(here))) + } + + /// Keeps the editor of page `page`, just left, for its history. + pub fn park(&mut self, page: ExGuid, editor: CanvasEditor) { + self.parked.retain(|(parked, _)| *parked != page); + if editor.history_depth() == [0, 0] || editor.marked_range().is_some() { + return self.forget(&[page]); + } + self.parked.push((page, editor)); + if self.parked.len() > KEPT { + let (oldest, _) = self.parked.remove(0); + self.forget(&[oldest]); + } + } + + /// The editor page `page` was left with, which takes up its history again. + pub fn resume(&mut self, page: ExGuid) -> Option { + let at = self.parked.iter().position(|(parked, _)| *parked == page)?; + Some(self.parked.remove(at).1) + } + + /// Drops the histories of pages gone. + pub fn forget(&mut self, pages: &[ExGuid]) { + self.parked.retain(|(parked, _)| !pages.contains(parked)); + for steps in [&mut self.undo, &mut self.redo] { + steps.retain(|step| !matches!(step, Step::Edit(page) if pages.contains(page))); + } + } + + /// Drops the actions of the notebook at `location`, closed. + pub fn close(&mut self, location: &str) { + for steps in [&mut self.undo, &mut self.redo] { + steps.retain( + |step| !matches!(step, Step::Action(action) if action.notebook == location), + ); + } + } +} + +/// Removes the `count` newest steps of page `page`. +fn drop_newest(steps: &mut Vec, page: ExGuid, count: usize) { + for _ in 0..count { + if let Some(at) = steps.iter().rposition(|step| step.edits(page)) { + steps.remove(at); + } + } +} + +/// Whether `page` holds nothing but its title, untitled. +fn blank(page: &Page) -> bool { + page.title.trim().is_empty() + && page + .objects + .iter() + .all(|object| matches!(object, PageObject::Title(_))) +} + +/// The page shown once `gone` leave the section listing `listed`: `prefer` if it stays, +/// else the page after the first one gone, or before it, as OneNote 2010 shows. +fn neighbor( + listed: &[(ExGuid, String, u32)], + gone: &[ExGuid], + prefer: Option, +) -> Option { + let stays = |space: &ExGuid| !gone.contains(space) && listed.iter().any(|(s, ..)| s == space); + if let Some(prefer) = prefer.filter(stays) { + return Some(prefer); + } + let at = listed + .iter() + .position(|(space, ..)| gone.contains(space)) + .unwrap_or_default(); + listed[at..] + .iter() + .chain(listed[..at].iter().rev()) + .map(|(space, ..)| *space) + .find(stays) +} + +/// The space of the page an import op creates. +fn imported(op: &Op) -> ExGuid { + match op { + Op::Section(SectionOp::Import { creation, .. }) => creation.space(), + _ => unreachable!("notebook::session::moved imports"), + } +} + +/// What a change to pages works with: the open section, its notebook, who changes it, and +/// a page to give it should it be left without pages. +struct Site { + replica: Arc, + library: Arc, + author: String, + fresh: PageCreation, +} + +/// A change to pages done: the change taking it back, the page to show, and pages gone. +struct Applied { + undo: Change, + show: ExGuid, + gone: Vec, +} + +impl Site { + fn apply(&self, ops: Vec) -> Result<(), Box> { + self.replica.apply( + &self.author, + Edit { + at: crate::filetime(), + ops, + }, + )?; + Ok(()) + } + + /// The catalog path of the section `identity` names. + fn section_path(&self, identity: [u8; 16]) -> Option { + entry_of(self.library.catalog()?, identity).map(|(_, path)| path) + } + + /// Does `change` to the section's pages, `shown` the page open; `None` where what it + /// changes is gone or holds what it did not make, which it leaves alone. + fn change(&self, change: Change, shown: ExGuid) -> Result, Box> { + let listed = self.replica.pages()?; + let at = |space: ExGuid| listed.iter().position(|(listed, ..)| *listed == space); + let binned = matches!(self.library.notebook, Ok(Some(_))); + Ok(Some(match change { + Change::Delete { + pages, + show, + created, + } => { + let pages: Vec = pages + .into_iter() + .filter(|page| at(*page).is_some()) + .collect(); + let read: Vec = pages + .iter() + .map(|page| self.replica.page(*page)) + .collect::>()?; + if pages.is_empty() || created && !read.iter().all(blank) { + return Ok(None); + } + let kept: Vec = pages + .iter() + .zip(read) + .map(|(space, page)| { + let at = at(*space).expect("listed"); + Kept { + page, + before: listed[at + 1..] + .iter() + .map(|(space, ..)| *space) + .find(|space| !pages.contains(space)), + level: listed[at].2, + } + }) + .collect(); + let mut ops = vec![Op::Section(SectionOp::Delete(pages.clone()))]; + let (show, added) = match neighbor(&listed, &pages, show) { + Some(show) => (show, None), + None => { + let space = self.fresh.space(); + ops.push(Op::Section(SectionOp::Create(self.fresh.clone()))); + (space, Some(space)) + } + }; + if !created && binned { + let pages: Vec = kept.iter().map(|kept| kept.page.clone()).collect(); + self.library.reopen()?.recycle_pages(&pages, &self.author)?; + } + self.apply(ops)?; + Applied { + undo: Change::Restore { + pages: kept, + added, + created, + }, + show, + gone: pages, + } + } + Change::Restore { + pages, + added, + created, + } => { + // A page someone put back already, listed by its identity, stays as it is. + let identity = |space: ExGuid| self.replica.page(space).ok()?.identity; + let mut back = Vec::new(); + for kept in pages { + let listed_again = kept.page.identity.is_some() + && listed.iter().any(|(space, title, _)| { + *title == kept.page.title && identity(*space) == kept.page.identity + }); + if !listed_again { + back.push(kept); + } + } + if back.is_empty() { + return Ok(None); + } + let mut ops = Vec::new(); + let mut spaces = Vec::new(); + for kept in &back { + let import = notebook::session::moved(&kept.page, &self.author)?; + let space = imported(&import); + let before = kept.before.filter(|before| at(*before).is_some()); + ops.push(import); + ops.push(Op::Section(SectionOp::Pages(vec![PageEdit::move_to( + space, before, kept.level, + )?]))); + spaces.push(space); + } + let gone: Vec = added + .filter(|added| { + at(*added).is_some() && self.replica.page(*added).is_ok_and(|p| blank(&p)) + }) + .into_iter() + .collect(); + if !gone.is_empty() { + ops.push(Op::Section(SectionOp::Delete(gone.clone()))); + } + self.apply(ops)?; + if !created && binned { + let identities: Vec<[u8; 16]> = + back.iter().filter_map(|kept| kept.page.identity).collect(); + self.library.reopen()?.unrecycle_pages(&identities)?; + } + Applied { + undo: Change::Delete { + pages: spaces.clone(), + show: Some(shown), + created, + }, + show: spaces[0], + gone, + } + } + Change::Arrange { order, moved } => { + let moving = |space: &ExGuid| moved.contains(space) && at(*space).is_some(); + let mut edits = Vec::new(); + for (index, (space, level)) in order.iter().enumerate() { + if !moving(space) { + continue; + } + let before = order[index + 1..] + .iter() + .map(|(space, _)| *space) + .find(|space| !moved.contains(space) && at(*space).is_some()); + edits.push(PageEdit::move_to(*space, before, *level)?); + } + let Some(show) = edits.first().map(PageEdit::space) else { + return Ok(None); + }; + self.apply(vec![Op::Section(SectionOp::Pages(edits))])?; + Applied { + undo: Change::Arrange { + order: listed + .iter() + .map(|(space, _, level)| (*space, *level)) + .collect(), + moved, + }, + show, + gone: Vec::new(), + } + } + Change::Retitle { page, from, to } => { + if at(page).is_none_or(|at| listed[at].1.trim() != from.trim()) { + return Ok(None); + } + let op = crate::rename::retitled(&self.replica.page(page)?, page, to.clone())?; + self.apply(vec![op])?; + Applied { + undo: Change::Retitle { + page, + from: to, + to: from, + }, + show: page, + gone: Vec::new(), + } + } + Change::Send { page, target } => { + let (Some(from), Some(path)) = (at(page), self.section_path(target)) else { + return Ok(None); + }; + let import = notebook::session::moved(&self.replica.page(page)?, &self.author)?; + let arrived = imported(&import); + let before = listed[from + 1..].first().map(|(space, ..)| *space); + let level = listed[from].2; + let mut ops = vec![Op::Section(SectionOp::Delete(vec![page]))]; + let (show, added) = match neighbor(&listed, &[page], None) { + Some(show) => (show, None), + None => { + let space = self.fresh.space(); + ops.push(Op::Section(SectionOp::Create(self.fresh.clone()))); + (space, Some(space)) + } + }; + let section = self.library.open(&path, || {})?; + section.replica().apply( + &self.author, + Edit { + at: crate::filetime(), + ops: vec![import], + }, + )?; + section.close()?; + self.apply(ops)?; + Applied { + undo: Change::Return { + page: arrived, + target, + before, + level, + added, + }, + show, + gone: vec![page], + } + } + Change::Return { + page, + target, + before, + level, + added, + } => { + let Some(path) = self.section_path(target) else { + return Ok(None); + }; + let section = self.library.open(&path, || {})?; + let sent = match section.pages()?.iter().any(|(listed, ..)| *listed == page) { + true => Some(section.page(page)?), + false => None, + }; + let Some(sent) = sent else { + section.close()?; + return Ok(None); + }; + let import = notebook::session::moved(&sent, &self.author)?; + let back = imported(&import); + let mut ops = vec![ + import, + Op::Section(SectionOp::Pages(vec![PageEdit::move_to( + back, + before.filter(|before| at(*before).is_some()), + level, + )?])), + ]; + let gone: Vec = added + .filter(|added| { + at(*added).is_some() && self.replica.page(*added).is_ok_and(|p| blank(&p)) + }) + .into_iter() + .collect(); + if !gone.is_empty() { + ops.push(Op::Section(SectionOp::Delete(gone.clone()))); + } + self.apply(ops)?; + section.replica().apply( + &self.author, + Edit { + at: crate::filetime(), + ops: vec![Op::Section(SectionOp::Delete(vec![page]))], + }, + )?; + section.close()?; + Applied { + undo: Change::Send { page: back, target }, + show: back, + gone, + } + } + Change::Rename { .. } | Change::Place { .. } => { + unreachable!("a section's own change is the notebook's") + } + })) + } +} + +/// The identity of each section and group in `folder`, in order, with its catalog path; +/// the recycle bin is left out. +fn entries(folder: &Folder) -> Vec<([u8; 16], String)> { + folder + .sections + .iter() + .map(|section| (section.file_id, section.path.clone())) + .chain(folder.groups.iter().filter_map(|group| { + let toc = group.toc.as_ref()?; + (!crate::library::recycle_bin(&group.path)).then(|| (toc.file_id, group.path.clone())) + })) + .collect() +} + +/// The identity of a group's folder; none for the notebook's own. +fn folder_id(folder: &Folder) -> Option<[u8; 16]> { + let toc = folder.toc.as_ref()?; + (!folder.path.is_empty()).then_some(toc.file_id) +} + +/// Every folder of `catalog`, the notebook's own first. +fn folders(catalog: &Folder) -> Vec<&Folder> { + let mut all = vec![catalog]; + let mut at = 0; + while let Some(folder) = all.get(at) { + all.extend(&folder.groups); + at += 1; + } + all +} + +/// The folder `identity` names, the notebook's own without one. +fn folder_of(catalog: &Folder, identity: Option<[u8; 16]>) -> Option<&Folder> { + match identity { + Some(_) => folders(catalog) + .into_iter() + .find(|folder| folder_id(folder) == identity), + None => Some(catalog), + } +} + +/// The section or group `identity` names: its folder and catalog path. +fn entry_of(catalog: &Folder, identity: [u8; 16]) -> Option<(&Folder, String)> { + folders(catalog).into_iter().find_map(|folder| { + let (_, path) = entries(folder) + .into_iter() + .find(|(id, _)| *id == identity)?; + Some((folder, path)) + }) +} + +/// The identity of the section or group at catalog `path`. +fn identity_at(catalog: &Folder, path: &str) -> Option<[u8; 16]> { + folders(catalog).into_iter().find_map(|folder| { + let (id, _) = entries(folder).into_iter().find(|(_, at)| at == path)?; + Some(id) + }) +} + +/// A section or group's name, as its catalog path ends. +fn name_of(path: &str) -> &str { + let name = path.rsplit('/').next().unwrap_or(path); + name.strip_suffix(".one").unwrap_or(name) +} + +/// The action taking back `change` to `library`'s sections and groups, which is about to be +/// made; none for changes Undo does not take back, as OneNote 2010 takes back none. +pub fn structure_undo(library: &Library, change: &crate::manage::Structure) -> Option { + use crate::manage::Structure; + let catalog = library.catalog()?; + let change = match change { + Structure::Rename { path, name } => Change::Rename { + entry: identity_at(catalog, path)?, + from: name.clone(), + to: name_of(path).to_owned(), + }, + Structure::Move { path, .. } => { + let entry = identity_at(catalog, path)?; + let (folder, _) = entry_of(catalog, entry)?; + Change::Place { + entry: Some(entry), + folder: folder_id(folder), + order: entries(folder).into_iter().map(|(id, _)| id).collect(), + } + } + Structure::Reorder { folder, .. } => { + let folder = folders(catalog) + .into_iter() + .find(|listed| listed.path == *folder)?; + Change::Place { + entry: None, + folder: folder_id(folder), + order: entries(folder).into_iter().map(|(id, _)| id).collect(), + } + } + _ => return None, + }; + Some(Action { + notebook: library.location.clone(), + section: None, + change, + }) +} + +/// The structure change carrying out a section or group's `change` in `library` now, with +/// the action taking it back; none where what it changes is gone or was changed since. +fn restructuring(library: &Library, change: &Change) -> Option<(crate::manage::Structure, Action)> { + use crate::manage::Structure; + let catalog = library.catalog()?; + let (structure, undo) = match change { + Change::Rename { entry, from, to } => { + let (_, path) = entry_of(catalog, *entry)?; + if name_of(&path) != from { + return None; + } + ( + Structure::Rename { + path, + name: to.clone(), + }, + Change::Rename { + entry: *entry, + from: to.clone(), + to: from.clone(), + }, + ) + } + Change::Place { + entry, + folder, + order, + } => { + let target = folder_of(catalog, *folder)?; + let placed = match entry { + Some(entry) => Some(entry_of(catalog, *entry)?), + None => None, + }; + // The folder the entry is in now, which the action taking this back reorders. + let now = placed.as_ref().map_or(target, |(folder, _)| *folder); + let undo = Change::Place { + entry: *entry, + folder: folder_id(now), + order: entries(now).into_iter().map(|(id, _)| id).collect(), + }; + let moving = placed.filter(|(from, _)| from.path != target.path); + let arrived = moving.as_ref().map(|(_, path)| { + let name = path.rsplit('/').next().unwrap_or(path); + match target.path.as_str() { + "" => name.to_owned(), + folder => format!("{folder}/{name}"), + } + }); + let current = entries(target); + let paths: Vec = order + .iter() + .filter_map(|id| match &arrived { + Some(arrived) if Some(*id) == *entry => Some(arrived.clone()), + _ => current + .iter() + .find(|(at, _)| at == id) + .map(|(_, path)| path.clone()), + }) + .collect(); + let structure = match moving { + Some((_, path)) => Structure::Return { + path, + folder: target.path.clone(), + paths, + }, + None => Structure::Reorder { + folder: target.path.clone(), + paths, + }, + }; + (structure, undo) + } + _ => return None, + }; + Some(( + structure, + Action { + notebook: library.location.clone(), + section: None, + change: undo, + }, + )) +} + +impl State { + /// The open section and its notebook, where Undo reaches. A section opened on its own + /// is its notebook's only one. + fn here(&self) -> Option { + let session = self.session.as_ref()?; + let path = &session.tabs.get(session.tab)?.path; + Some(Here { + notebook: session.library.location.clone(), + section: session.library.section_identity(path).unwrap_or_default(), + }) + } + + /// Whether Undo, or `redo` Redo, has something to take. + pub(crate) fn can_step(&self, redo: bool) -> bool { + let editor = &self.view.editor; + (if redo { + editor.can_redo() + } else { + editor.can_undo() + }) || self + .here() + .is_some_and(|here| self.undo.reaches(redo, &here)) + } + + /// Undo, or `redo` Redo: the open page's last edit or the last action made where the + /// user is, whichever came last. + pub(crate) fn step(&mut self, redo: bool) -> Result<(), Box> { + self.persist()?; + let depth = self.view.editor.history_depth(); + // A page kept in no notebook has only its own history. + let shown = self.session.as_ref().map(|session| session.space); + let next = match (self.here(), shown) { + (Some(here), Some(page)) => self.undo.next(redo, page, depth, &here), + _ => Some(Next::Edit), + }; + match next { + None => {} + Some(Next::Edit) => { + let response = self.view.undo(redo)?; + self.respond(response); + if let Some(page) = shown + && self.view.editor.history_depth() != depth + { + self.undo.stepped(page, redo); + } + } + Some(Next::Show(page)) => self.commands.push(Command::OpenPage(page)), + Some(Next::Action(action)) => self.perform(action, Some(redo))?, + } + Ok(()) + } + + /// Does `action`: one the user asked for, or that Undo, or `taken` Redo, took. What + /// takes it back goes on the timeline. + pub(crate) fn perform( + &mut self, + action: Action, + taken: Option, + ) -> Result<(), Box> { + let Action { + notebook, + section, + change, + } = action; + let Some(section) = section else { + let library = self + .notebooks + .iter() + .find(|library| library.location == notebook) + .cloned() + .ok_or("That notebook is closed")?; + let Some((structure, undo)) = restructuring(&library, &change) else { + return match taken { + Some(redo) => self.step(redo), + None => Ok(()), + }; + }; + self.undo.note(taken, undo); + self.restructure(library, structure); + return Ok(()); + }; + self.persist()?; + let session = self.session.as_ref().ok_or("No section is open")?; + let site = Site { + replica: Arc::clone(session.section.replica()), + library: Arc::clone(&session.library), + author: self.author.clone(), + fresh: self.dated_page(None)?, + }; + let shown = session.space; + let proxy = self.proxy.clone(); + self.undo.busy += 1; + self.load(move || { + let applied = site.change(change, shown); + let show = match &applied { + Ok(Some(applied)) => applied.show, + _ => shown, + }; + let failed = applied.as_ref().err().map(ToString::to_string); + let (stale, applied) = (failed.is_none(), applied.ok().flatten()); + let _ = proxy.send_event(UserEvent::Then(Box::new(move |state: &mut State| { + state.undo.busy -= 1; + let Some(applied) = applied else { + // What it would change is gone: Undo goes on to the step before. + return match taken { + Some(redo) if stale => state.step(redo), + _ => Ok(()), + }; + }; + state.undo.forget(&applied.gone); + let undo = Action { + notebook, + section: Some(section), + change: applied.undo, + }; + state.undo.note(taken, undo); + state.edited(Vec::new()); + Ok(()) + }))); + if let Some(failed) = failed { + return Err(failed.into()); + } + Ok(Loaded::Page(show, site.replica.page(show)?)) + }); + Ok(()) + } + + /// An action the user takes on the open section's pages: `change`. + pub(crate) fn change_pages(&mut self, change: Change) -> Result<(), Box> { + let here = self.here().ok_or("No section is open")?; + self.perform( + Action { + notebook: here.notebook, + section: Some(here.section), + change, + }, + None, + ) + } + + /// Notes `change`, just made on the open section's pages, for Undo to take back. + pub(crate) fn made(&mut self, change: Change) { + if let Some(here) = self.here() { + self.undo.record(Action { + notebook: here.notebook, + section: Some(here.section), + change, + }); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use notebook::session::Notebook; + + fn page(n: u32) -> ExGuid { + ExGuid { guid: [9; 16], n } + } + + const SECTION: [u8; 16] = [1; 16]; + + fn here() -> Here { + Here { + notebook: "/n".into(), + section: SECTION, + } + } + + fn created(space: ExGuid) -> Action { + Action { + notebook: "/n".into(), + section: Some(SECTION), + change: Change::Delete { + pages: vec![space], + show: None, + created: true, + }, + } + } + + fn is_action(next: Option) -> bool { + matches!(next, Some(Next::Action(_))) + } + + fn is_edit(next: Option) -> bool { + matches!(next, Some(Next::Edit)) + } + + /// Cmd+N, a title typed, then Undo: the title goes, then the page. + #[test] + fn undo_takes_back_a_new_page_after_its_title() { + let mut timeline = Timeline::default(); + let (old, new) = (page(1), page(2)); + timeline.edited(old, [3, 0], false); + timeline.record(created(new)); + timeline.edited(new, [1, 0], false); + assert!(is_edit(timeline.next(false, new, [1, 0], &here()))); + timeline.stepped(new, false); + assert!(is_action(timeline.next(false, new, [0, 1], &here()))); + // The page shown before keeps its own history. + assert!(is_edit(timeline.next(false, old, [3, 0], &here()))); + } + + /// The newer of the page's own step and the last action goes first, as OneNote 2010 + /// takes back a page deleted after typing on the page it then shows. + #[test] + fn the_newer_of_page_and_action_goes_first() { + let mut timeline = Timeline::default(); + let shown = page(1); + timeline.edited(shown, [1, 0], false); + timeline.record(created(page(5))); + assert!(is_action(timeline.next(false, shown, [1, 0], &here()))); + timeline.edited(shown, [2, 0], false); + timeline.record(created(page(6))); + // Typing that goes on a run started before the action is newer than it. + timeline.edited(shown, [2, 0], true); + assert!(is_edit(timeline.next(false, shown, [2, 0], &here()))); + } + + /// Another page's edits are its own; a new page holding edits is shown before it goes. + #[test] + fn a_created_page_with_edits_is_shown_first() { + let mut timeline = Timeline::default(); + let (other, new) = (page(1), page(2)); + timeline.record(created(new)); + timeline.edited(new, [2, 0], false); + assert!(matches!( + timeline.next(false, other, [0, 0], &here()), + Some(Next::Show(shown)) if shown == new + )); + // Its edits taken back there, it goes. + timeline.stepped(new, false); + timeline.stepped(new, false); + assert!(is_action(timeline.next(false, other, [0, 0], &here()))); + } + + /// Actions are reached where they were made: a page change in its section, a section + /// change anywhere in its notebook. + #[test] + fn actions_are_reached_where_they_were_made() { + let mut timeline = Timeline::default(); + timeline.record(created(page(2))); + timeline.record(Action { + notebook: "/n".into(), + section: None, + change: Change::Rename { + entry: [3; 16], + from: "B".into(), + to: "A".into(), + }, + }); + let elsewhere = Here { + notebook: "/n".into(), + section: [2; 16], + }; + let other = Here { + notebook: "/m".into(), + section: SECTION, + }; + assert!(!timeline.reaches(false, &other)); + assert!(matches!( + timeline.next(false, page(7), [0, 0], &elsewhere), + Some(Next::Action(Action { + change: Change::Rename { .. }, + .. + })) + )); + assert!(timeline.next(false, page(7), [0, 0], &elsewhere).is_none()); + assert!(is_action(timeline.next(false, page(7), [0, 0], &here()))); + timeline.record(created(page(3))); + timeline.close("/n"); + assert!(!timeline.reaches(false, &here())); + } + + /// Redo takes back the last undone first; a new edit ends what actions it held, and + /// waits while an action is on its way. + #[test] + fn redo_mirrors_undo_and_new_edits_end_it() { + let mut timeline = Timeline::default(); + let shown = page(1); + timeline.record(created(page(2))); + let Some(Next::Action(action)) = timeline.next(false, shown, [0, 0], &here()) else { + panic!() + }; + timeline.note(Some(false), action); + timeline.edited(shown, [1, 0], false); + assert!(is_edit(timeline.next(false, shown, [1, 0], &here()))); + timeline.stepped(shown, false); + assert!(is_edit(timeline.next(true, shown, [0, 1], &here()))); + // The undone action went with the edit made after it. + timeline.stepped(shown, true); + timeline.stepped(shown, false); + assert!(!timeline.reaches(true, &here())); + timeline.record(created(page(3))); + timeline.busy = 1; + assert!(timeline.next(false, shown, [0, 1], &here()).is_none()); + } + + const AUTHOR: &str = "Rust Author"; + + fn dated() -> PageCreation { + PageCreation::new(None, Some(""), AUTHOR) + .unwrap() + .dated("Wednesday, September 30, 2026", "7:04 PM") + .unwrap() + } + + /// A notebook as the app makes one, its first section holding pages titled `titles` + /// and open, beside a section "B"; deleted when dropped. + struct Fixture { + folder: std::path::PathBuf, + library: Arc, + section: Option, + } + + impl Fixture { + fn new(name: &str, titles: &[&str]) -> Self { + let folder = + std::env::temp_dir().join(format!("snowbound-undo-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&folder); + std::fs::create_dir_all(&folder).unwrap(); + let root = folder.join("Notebook"); + let mut notebook = + Notebook::create(&root, folder.join("cache"), Notebook::NEW_COLOR, &dated()) + .unwrap(); + notebook.create_section("", "B", &dated()).unwrap(); + let library = Arc::new(Library::created( + root.to_str().unwrap(), + notebook, + &folder.join("cache"), + )); + let section = library.open("New Section 1.one", || {}).unwrap(); + let fixture = Self { + folder, + library, + section: Some(section), + }; + let first = fixture.pages()[0].0; + fixture.retitle(first, titles[0]); + for title in &titles[1..] { + let creation = dated(); + let space = creation.space(); + fixture.edit(vec![Op::Section(SectionOp::Create(creation))]); + fixture.retitle(space, title); + } + fixture + } + + fn section(&self) -> ¬ebook::session::Section { + self.section.as_ref().unwrap() + } + + fn site(&self) -> Site { + Site { + replica: Arc::clone(self.section().replica()), + library: Arc::clone(&self.library), + author: AUTHOR.into(), + fresh: dated(), + } + } + + /// Another device's edit, or the user's outside Undo. + fn edit(&self, ops: Vec) { + self.section() + .replica() + .apply( + AUTHOR, + Edit { + at: crate::filetime(), + ops, + }, + ) + .unwrap(); + } + + fn retitle(&self, space: ExGuid, title: &str) { + let page = self.section().page(space).unwrap(); + self.edit(vec![ + crate::rename::retitled(&page, space, title.into()).unwrap(), + ]); + } + + fn pages(&self) -> Vec<(ExGuid, String, u32)> { + self.section().pages().unwrap() + } + + fn titles(&self) -> Vec<(String, u32)> { + self.pages() + .into_iter() + .map(|(_, title, level)| (title, level)) + .collect() + } + + fn space(&self, title: &str) -> ExGuid { + self.pages() + .into_iter() + .find(|(_, listed, _)| listed == title) + .unwrap() + .0 + } + + /// The titles in the recycle bin's Deleted Pages. + fn binned(&self) -> Vec { + let file = self + .folder + .join("Notebook/OneNote_RecycleBin/OneNote_DeletedPages.one"); + std::fs::read(file).map_or_else( + |_| Vec::new(), + |bytes| { + notebook::session::stored_pages(&bytes) + .unwrap() + .into_iter() + .map(|stored| stored.page.title) + .collect() + }, + ) + } + + /// Does `change`, as the app's thread does, returning what takes it back. + fn change(&self, change: Change) -> Option { + let shown = self.pages()[0].0; + self.site().change(change, shown).unwrap() + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + if let Some(section) = self.section.take() { + let _ = section.close(); + } + let _ = std::fs::remove_dir_all(&self.folder); + } + } + + fn titled(titles: &[&str]) -> Vec<(String, u32)> { + titles.iter().map(|title| (title.to_string(), 1)).collect() + } + + /// A page deleted goes to the recycle bin; Undo puts it back where it was and takes it + /// out of the bin, as OneNote 2010 does; Redo deletes it again. A page another device + /// put back meanwhile is left alone. + #[test] + fn deleted_pages_come_back_where_they_were_and_leave_the_bin() { + let fixture = Fixture::new("delete", &["First", "Second", "Third"]); + let second = fixture.space("Second"); + let deleted = fixture + .change(Change::Delete { + pages: vec![second], + show: None, + created: false, + }) + .unwrap(); + assert_eq!(fixture.titles(), titled(&["First", "Third"])); + assert_eq!(deleted.show, fixture.space("Third")); + assert_eq!(fixture.binned(), ["Second"]); + let restored = fixture.change(deleted.undo).unwrap(); + assert_eq!(fixture.titles(), titled(&["First", "Second", "Third"])); + assert_eq!(restored.show, fixture.space("Second")); + assert!(fixture.binned().is_empty()); + let again = fixture.change(restored.undo).unwrap(); + assert_eq!(fixture.titles(), titled(&["First", "Third"])); + assert_eq!(fixture.binned(), ["Second"]); + + // Put back elsewhere first, the page stays as that device left it. + let Change::Restore { pages, .. } = &again.undo else { + panic!() + }; + let copy = notebook::session::moved(&pages[0].page, AUTHOR).unwrap(); + fixture.edit(vec![copy]); + assert!(fixture.change(again.undo).is_none()); + assert_eq!(fixture.titles(), titled(&["First", "Third", "Second"])); + } + + /// New Page taken back: the page goes for good while it holds nothing, the page shown + /// before it shows again, and Redo brings it back. Once another device wrote on it, + /// it stays. + #[test] + fn a_new_page_goes_only_while_it_holds_nothing() { + let fixture = Fixture::new("create", &["First", "Second"]); + let creation = dated(); + let new = creation.space(); + fixture.edit(vec![Op::Section(SectionOp::Create(creation))]); + let first = fixture.space("First"); + let removed = fixture + .change(Change::Delete { + pages: vec![new], + show: Some(first), + created: true, + }) + .unwrap(); + assert_eq!(fixture.titles(), titled(&["First", "Second"])); + assert_eq!(removed.show, first); + assert!(fixture.binned().is_empty()); + let back = fixture.change(removed.undo).unwrap(); + assert_eq!(fixture.titles(), titled(&["First", "Second", ""])); + let Change::Delete { pages, .. } = &back.undo else { + panic!() + }; + fixture.retitle(pages[0], "Written elsewhere"); + assert!(fixture.change(back.undo).is_none()); + assert_eq!( + fixture.titles(), + titled(&["First", "Second", "Written elsewhere"]) + ); + } + + /// Pages moved and indented go back to their places and levels, before the next page + /// still there when another device deleted the one they followed. + #[test] + fn arranged_pages_go_back_before_the_next_page_still_there() { + let fixture = Fixture::new("arrange", &["A", "B", "C", "D"]); + let order: Vec<(ExGuid, u32)> = fixture + .pages() + .into_iter() + .map(|(space, _, level)| (space, level)) + .collect(); + let [a, b, c, d] = ["A", "B", "C", "D"].map(|title| fixture.space(title)); + fixture.edit(vec![Op::Section(SectionOp::Pages(vec![ + PageEdit::move_to(a, None, 1).unwrap(), + PageEdit::set_level(c, 2).unwrap(), + ]))]); + assert_eq!( + fixture.titles(), + [("B", 1), ("C", 2), ("D", 1), ("A", 1)].map(|(t, l)| (t.to_owned(), l)) + ); + let arrange = Change::Arrange { + order: order.clone(), + moved: vec![a, c], + }; + let arranged = fixture.change(arrange).unwrap(); + assert_eq!(fixture.titles(), titled(&["A", "B", "C", "D"])); + assert_eq!(arranged.show, a); + fixture.change(arranged.undo).unwrap(); + fixture.edit(vec![Op::Section(SectionOp::Delete(vec![b]))]); + fixture + .change(Change::Arrange { + order, + moved: vec![a, c, d], + }) + .unwrap(); + assert_eq!(fixture.titles(), titled(&["A", "C", "D"])); + } + + /// A page sent to another section comes back where it was and leaves that section; + /// gone from there meanwhile, it does not. + #[test] + fn a_page_sent_away_comes_back() { + let fixture = Fixture::new("send", &["First", "Second", "Third"]); + let target = fixture.library.section_identity("B.one").unwrap(); + let in_b = |fixture: &Fixture| { + let section = fixture.library.open("B.one", || {}).unwrap(); + let titles: Vec = section + .pages() + .unwrap() + .into_iter() + .map(|(_, title, _)| title) + .collect(); + section.close().unwrap(); + titles + }; + let sent = fixture + .change(Change::Send { + page: fixture.space("Second"), + target, + }) + .unwrap(); + assert_eq!(fixture.titles(), titled(&["First", "Third"])); + assert_eq!(in_b(&fixture), ["", "Second"]); + let returned = fixture.change(sent.undo).unwrap(); + assert_eq!(fixture.titles(), titled(&["First", "Second", "Third"])); + assert_eq!(in_b(&fixture), [""]); + let sent = fixture.change(returned.undo).unwrap(); + let Change::Return { page, .. } = &sent.undo else { + panic!() + }; + let section = fixture.library.open("B.one", || {}).unwrap(); + section.delete_pages(&[*page]).unwrap(); + section.close().unwrap(); + assert!(fixture.change(sent.undo).is_none()); + assert_eq!(fixture.titles(), titled(&["First", "Third"])); + } + + /// A title given in the page list goes back while the page still has it. + #[test] + fn a_title_goes_back_while_the_page_has_it() { + let fixture = Fixture::new("retitle", &["First", "Renamed"]); + let page = fixture.space("Renamed"); + let back = fixture + .change(Change::Retitle { + page, + from: "Renamed".into(), + to: "Second".into(), + }) + .unwrap(); + assert_eq!(fixture.titles(), titled(&["First", "Second"])); + fixture.retitle(page, "Changed elsewhere"); + assert!(fixture.change(back.undo).is_none()); + } + + /// Carries out `change` as `manage::State::restructure` does. + fn carry(notebook: &mut Notebook, change: crate::manage::Structure) { + use crate::manage::Structure; + match change { + Structure::Rename { path, name } => drop(notebook.rename(&path, &name).unwrap()), + Structure::Move { path, folder } => drop(notebook.move_entry(&path, &folder).unwrap()), + Structure::Reorder { folder, paths } => { + let paths: Vec<&str> = paths.iter().map(String::as_str).collect(); + notebook.reorder(&folder, &paths).unwrap(); + } + Structure::Return { + path, + folder, + paths, + } => { + notebook.move_entry(&path, &folder).unwrap(); + let paths: Vec<&str> = paths.iter().map(String::as_str).collect(); + notebook.reorder(&folder, &paths).unwrap(); + } + _ => unreachable!(), + } + } + + /// Sections renamed, reordered and moved into a group go back, the moved one to its + /// place among the others; renamed again elsewhere, a section keeps that name. + #[test] + fn sections_go_back_to_their_names_and_places() { + use crate::manage::Structure; + let folder = + std::env::temp_dir().join(format!("snowbound-undo-sections-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&folder); + std::fs::create_dir_all(&folder).unwrap(); + let root = folder.join("Notebook"); + let mut notebook = + Notebook::create(&root, folder.join("cache"), Notebook::NEW_COLOR, &dated()).unwrap(); + for name in ["B", "C"] { + notebook.create_section("", name, &dated()).unwrap(); + } + notebook.create_group("", "Group").unwrap(); + let order = |notebook: &Notebook, folder: &str| -> Vec { + let folder = folders(notebook.catalog()) + .into_iter() + .find(|listed| listed.path == folder) + .unwrap(); + entries(folder).into_iter().map(|(_, path)| path).collect() + }; + let opened = Library::created(root.to_str().unwrap(), notebook, &folder.join("cache")); + let library = |notebook: Notebook| opened.with(notebook); + let mut notebook = opened.reopen().unwrap(); + let mut steps = Vec::new(); + for change in [ + Structure::Rename { + path: "B.one".into(), + name: "Kitchen".into(), + }, + Structure::Reorder { + folder: String::new(), + paths: vec!["C.one".into(), "New Section 1.one".into()], + }, + Structure::Move { + path: "New Section 1.one".into(), + folder: "Group".into(), + }, + ] { + let shown = library(notebook); + steps.push(structure_undo(&shown, &change).unwrap()); + notebook = shown.reopen().unwrap(); + carry(&mut notebook, change); + } + assert_eq!(order(¬ebook, ""), ["C.one", "Kitchen.one", "Group"]); + assert_eq!(order(¬ebook, "Group"), ["Group/New Section 1.one"]); + let mut redo = Vec::new(); + while let Some(step) = steps.pop() { + let shown = library(notebook); + let (structure, undo) = restructuring(&shown, &step.change).unwrap(); + redo.push(undo); + notebook = shown.reopen().unwrap(); + carry(&mut notebook, structure); + } + assert_eq!( + order(¬ebook, ""), + ["New Section 1.one", "B.one", "C.one", "Group"] + ); + // Redo, then Undo of a rename another device changed since leaves it. + let shown = library(notebook); + let rename = redo.pop().unwrap(); + let (structure, undo) = restructuring(&shown, &rename.change).unwrap(); + notebook = shown.reopen().unwrap(); + carry(&mut notebook, structure); + notebook.rename("Kitchen.one", "Pantry").unwrap(); + assert!(restructuring(&library(notebook), &undo.change).is_none()); + let _ = std::fs::remove_dir_all(&folder); + } + + /// Every kind of step taken back and redone in one notebook, written as the app writes + /// it. `SNOWBOUND_UNDO_EXPORT` names a new directory that receives it for a cold + /// reopen in OneNote 2010: "New Section 1" lists First, Second, Fourth and Recreated, + /// "B" its own page, and Deleted Pages only Third. + #[test] + fn every_step_back_is_stored_as_onenote_stores_one() { + let mut fixture = Fixture::new("export", &["First", "Second", "Third", "Fourth"]); + let back = |fixture: &Fixture, change| { + let done = fixture.change(change).unwrap(); + fixture.change(done.undo).unwrap() + }; + back( + &fixture, + Change::Delete { + pages: vec![fixture.space("Second")], + show: None, + created: false, + }, + ); + fixture.change(Change::Delete { + pages: vec![fixture.space("Third")], + show: None, + created: false, + }); + let order = fixture + .pages() + .into_iter() + .map(|(space, _, level)| (space, level)) + .collect(); + let fourth = fixture.space("Fourth"); + fixture.edit(vec![Op::Section(SectionOp::Pages(vec![ + PageEdit::move_to(fourth, Some(fixture.space("First")), 1).unwrap(), + ]))]); + fixture.change(Change::Arrange { + order, + moved: vec![fourth], + }); + back( + &fixture, + Change::Send { + page: fixture.space("First"), + target: fixture.library.section_identity("B.one").unwrap(), + }, + ); + let creation = dated(); + let created = creation.space(); + fixture.edit(vec![Op::Section(SectionOp::Create(creation))]); + let restored = back( + &fixture, + Change::Delete { + pages: vec![created], + show: None, + created: true, + }, + ); + fixture.retitle(restored.show, "Recreated"); + assert_eq!( + fixture.titles(), + titled(&["First", "Second", "Fourth", "Recreated"]) + ); + assert_eq!(fixture.binned(), ["Third"]); + + let mut notebook = fixture.library.reopen().unwrap(); + for change in [ + crate::manage::Structure::Rename { + path: "B.one".into(), + name: "Kitchen".into(), + }, + crate::manage::Structure::Reorder { + folder: String::new(), + paths: vec!["B.one".into(), "New Section 1.one".into()], + }, + ] { + let shown = fixture.library.with(notebook); + let undo = structure_undo(&shown, &change).unwrap(); + notebook = shown.reopen().unwrap(); + carry(&mut notebook, change); + let (structure, _) = + restructuring(&fixture.library.with(notebook), &undo.change).unwrap(); + notebook = fixture.library.reopen().unwrap(); + carry(&mut notebook, structure); + } + let order: Vec = entries(notebook.catalog()) + .into_iter() + .map(|(_, path)| path) + .collect(); + assert_eq!(order, ["New Section 1.one", "B.one"]); + + fixture.section.take().unwrap().close().unwrap(); + if let Some(directory) = std::env::var_os("SNOWBOUND_UNDO_EXPORT") { + copy( + &fixture.folder.join("Notebook"), + std::path::Path::new(&directory), + ); + } + } + + fn copy(from: &std::path::Path, to: &std::path::Path) { + std::fs::create_dir_all(to).unwrap(); + for entry in std::fs::read_dir(from).unwrap().flatten() { + let target = to.join(entry.file_name()); + if entry.file_type().unwrap().is_dir() { + copy(&entry.path(), &target); + } else { + std::fs::copy(entry.path(), target).unwrap(); + } + } + } + + /// Steps a page's editor no longer holds, dropped by a change made elsewhere, leave the + /// timeline; a page gone takes its steps. + #[test] + fn steps_follow_the_page_history() { + let mut timeline = Timeline::default(); + let shown = page(1); + timeline.edited(shown, [3, 0], false); + timeline.record(created(page(2))); + timeline.edited(shown, [1, 0], false); + assert!(is_action(timeline.next(false, shown, [1, 0], &here()))); + assert!(is_edit(timeline.next(false, shown, [1, 0], &here()))); + timeline.forget(&[shown]); + assert!(timeline.next(false, shown, [0, 0], &here()).is_none()); + } +} diff --git a/crates/snowbound/src/windows.rs b/crates/snowbound/src/windows.rs index ab1718368c00f50fc259b4de77f309d3271cb0e2..f15a3b1dd36d58fa9ce10f0297c419537b19be26 100644 --- a/crates/snowbound/src/windows.rs +++ b/crates/snowbound/src/windows.rs @@ -158,14 +158,72 @@ fn composed() -> bool { unsafe { Dwm::DwmIsCompositionEnabled(&mut on) >= 0 && on != 0 } } -pub fn event_loop(_headless: bool) -> Result, EventLoopError> { +pub fn event_loop(headless: bool) -> Result, EventLoopError> { let event_loop = EventLoop::with_user_event().build()?; QUIT.set(event_loop.create_proxy()) .expect("Only one application event loop is created"); COMPOSED.store(composed(), Ordering::Relaxed); + if !headless { + offer_to_open(); + } Ok(event_loop) } +/// Lists this executable under Open with for OneNote's sections and tables of contents, for +/// this user, leaving the app that opens them as it was; a moved executable lists its new +/// path. One cargo built and runs where it put it, in a folder CACHEDIR.TAG marks, isn't listed. +fn offer_to_open() { + use windows_sys::Win32::System::Registry; + const PROG_ID: &str = "Snowbound.OneNote"; + let Ok(executable) = std::env::current_exe() else { + return; + }; + if executable + .ancestors() + .any(|folder| folder.join("CACHEDIR.TAG").exists()) + { + return; + } + let executable = executable.display(); + let set = |key: &str, value: Option<&str>, data: &str| { + let (data, value) = (wide(data), value.map(wide)); + let result = unsafe { + Registry::RegSetKeyValueW( + Registry::HKEY_CURRENT_USER, + wide(format!(r"Software\Classes\{key}")).as_ptr(), + value + .as_ref() + .map_or(std::ptr::null(), |value| value.as_ptr()), + Registry::REG_SZ, + data.as_ptr().cast(), + (data.len() * 2) as u32, + ) + }; + if result != 0 { + eprintln!("Cannot register {key}: error {result}"); + } + }; + set(PROG_ID, None, "OneNote File"); + set( + &format!(r"{PROG_ID}\DefaultIcon"), + None, + &format!("{executable},0"), + ); + set( + &format!(r"{PROG_ID}\shell\open"), + Some("FriendlyAppName"), + "Snowbound", + ); + set( + &format!(r"{PROG_ID}\shell\open\command"), + None, + &format!("\"{executable}\" \"%1\""), + ); + for extension in [".one", ".onetoc2"] { + set(&format!(r"{extension}\OpenWithProgids"), Some(PROG_ID), ""); + } +} + /// The executable's icon, and where the row draws the caption buttons, no system frame: /// the desktop draws the window's shadow. pub fn window_attributes() -> WindowAttributes { @@ -760,8 +818,132 @@ pub fn move_cursor() -> winit::window::CursorIcon { /// No system border lies under the chrome. pub fn cover_border_line(_: &mut Ui, _: f32) {} -pub fn with_pool(run: impl FnOnce() -> R) -> R { - run() +/// Runs the app. Where nothing reads stderr, as when Explorer starts it, stderr goes to a +/// log, with panics' backtraces and the faults that end the process, and a run that ends +/// in an error or a panic says where the log is. +pub fn with_pool( + run: impl FnOnce() -> Result<(), Box>, +) -> Result<(), Box> { + use std::panic; + let log = log_stderr(); + let (major, minor, build) = version(); + eprintln!( + "Snowbound {} on Windows {major}.{minor}.{build}", + option_env!("SNOWBOUND_BUILD").unwrap_or("development") + ); + panic::set_hook(Box::new(|info| { + let thread = std::thread::current(); + let backtrace = std::backtrace::Backtrace::force_capture(); + eprintln!( + "Thread {:?} {info}\n{backtrace}", + thread.name().unwrap_or("") + ); + })); + unsafe { + windows_sys::Win32::System::Diagnostics::Debug::AddVectoredExceptionHandler(0, Some(fault)) + }; + let result = panic::catch_unwind(panic::AssertUnwindSafe(run)); + let Some(log) = log else { + return result.unwrap_or_else(|payload| panic::resume_unwind(payload)); + }; + let details = format!("The log is at {}.", log.display()); + // The window is gone, and a message box it owned wouldn't show. + WINDOW.store(0, Ordering::Relaxed); + match result { + Ok(Err(error)) => { + alert(&error.to_string(), &details); + Err(error) + } + Ok(done) => done, + Err(payload) => { + alert("Snowbound stopped because of a problem.", &details); + panic::resume_unwind(payload) + } + } +} + +/// Points stderr at `snowbound.log` in the cache folder where it goes nowhere, keeping the +/// last run's as `snowbound.old.log`. +fn log_stderr() -> Option { + use std::os::windows::io::IntoRawHandle; + use windows_sys::Win32::{ + Storage::FileSystem::{FILE_TYPE_UNKNOWN, GetFileType}, + System::Console::{GetStdHandle, STD_ERROR_HANDLE, SetStdHandle}, + }; + // A console's handles inherited without the console are as good as none. + if unsafe { GetFileType(GetStdHandle(STD_ERROR_HANDLE)) } != FILE_TYPE_UNKNOWN { + return None; + } + let folder = cache_dir()?; + std::fs::create_dir_all(&folder).ok()?; + let log = folder.join("snowbound.log"); + let _ = std::fs::rename(&log, folder.join("snowbound.old.log")); + let file = std::fs::File::create(&log).ok()?; + // Leaked: stderr writes to it until the process ends. + unsafe { SetStdHandle(STD_ERROR_HANDLE, file.into_raw_handle()) }; + Some(log) +} + +/// Logs an access violation or a bad instruction, such as a driver's, by the module it +/// happened in. Seen before any handler, as Windows reports one from a window procedure +/// to no unhandled-exception filter; it usually ends the process. +unsafe extern "system" fn fault( + pointers: *mut windows_sys::Win32::System::Diagnostics::Debug::EXCEPTION_POINTERS, +) -> i32 { + use windows_sys::Win32::{ + Foundation::{ + EXCEPTION_ACCESS_VIOLATION, EXCEPTION_ILLEGAL_INSTRUCTION, EXCEPTION_PRIV_INSTRUCTION, + }, + System::LibraryLoader::{ + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + GetModuleFileNameW, GetModuleHandleExW, + }, + }; + let record = unsafe { &*(*pointers).ExceptionRecord }; + if ![ + EXCEPTION_ACCESS_VIOLATION, + EXCEPTION_ILLEGAL_INSTRUCTION, + EXCEPTION_PRIV_INSTRUCTION, + ] + .contains(&record.ExceptionCode) + { + return 0; + } + let place = |address: usize| { + let mut module = std::ptr::null_mut(); + let mut name = [0u16; 260]; + let found = unsafe { + GetModuleHandleExW( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS + | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + address as *const u16, + &mut module, + ) != 0 + && GetModuleFileNameW(module, name.as_mut_ptr(), name.len() as u32) != 0 + }; + if found { + format!("{} + {:#x}", narrow(&name), address - module as usize) + } else { + format!("{address:#x}, in no module") + } + }; + // Where a call went to a bad address, the backtrace can't leave it: the return address + // names the caller. + let context = unsafe { &*(*pointers).ContextRecord }; + #[cfg(target_arch = "x86_64")] + let caller = unsafe { *(context.Rsp as *const usize) }; + #[cfg(target_arch = "aarch64")] + let caller = unsafe { context.Anonymous.Anonymous.Lr } as usize; + eprintln!( + "Fault {:#010x} at {}, on {:#x}, perhaps called from {}\n{}", + record.ExceptionCode as u32, + place(record.ExceptionAddress as usize), + record.ExceptionInformation[1], + place(caller), + std::backtrace::Backtrace::force_capture() + ); + // EXCEPTION_CONTINUE_SEARCH. + 0 } /// The interface keeps its own font, which fontique finds as Segoe UI. @@ -820,6 +1002,10 @@ impl Clipboard { self.0.get().file_list().unwrap_or_default() } + pub fn get_html(&mut self) -> Option { + self.0.get().html().ok() + } + /// The clipboard's PNG, or else its device-independent bitmap. pub fn get_picture(&mut self) -> Option> { crate::paste::bitmap(self.0.get_image().ok()?) diff --git a/crates/snowbound/tests/replay.rs b/crates/snowbound/tests/replay.rs index 78d7b72d3c46673d3f2dd68a3099cac69b84ff75..5784a87790897a2b952824201abbbc95e2462d2f 100644 --- a/crates/snowbound/tests/replay.rs +++ b/crates/snowbound/tests/replay.rs @@ -122,3 +122,133 @@ fn each_toolbar_control_has_a_name_of_its_own_at_every_width() { assert_named_once(&tree); } } + +/// The menu items listed under the first `Menu` of `tree`, one a line. +fn menu_items(tree: &str) -> Vec<&str> { + tree.lines() + .skip_while(|line| !line.trim_start().starts_with("Menu")) + .skip(1) + .take_while(|line| !line.trim_start().starts_with("Menu") || line.contains("MenuItem")) + .filter(|line| line.contains("MenuItem")) + .map(|line| line.split('"').nth(1).unwrap_or_default()) + .collect() +} + +#[test] +fn the_palette_lists_recent_pages_and_the_actions_a_context_menu_offers() { + let scratch = Scratch::new("palette-actions"); + let notebook = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate"); + let palette = ["modifiers command", "key p", "modifiers", "wait 400"]; + let chord = ["modifiers command", "key k", "modifiers", "wait 400"]; + let mut steps = Vec::from(palette); + steps.extend(["type type over", "wait 200", "key Enter", "wait 800"]); + steps.extend(palette); + steps.push("accessibility recent"); + steps.extend(chord); + steps.push("accessibility actions"); + steps.extend(["key Escape", "wait 400", "accessibility back"]); + steps.extend(["key Escape", "wait 400", "move 1000 87", "press right"]); + steps.extend(["release right", "wait 400", "accessibility context"]); + let [recent, actions, back, context] = replay(&scratch, Some(¬ebook), &steps) + .try_into() + .unwrap(); + let first = recent + .lines() + .find(|line| line.contains("ListBoxOption")) + .unwrap_or_default(); + assert!( + first.contains(r#""Delete into a table" -- "Cross" [selected]"#), + "the page left starts the list, highlighted:\n{recent}" + ); + let actions = menu_items(&actions); + assert_eq!(actions.first(), Some(&"Open"), "{actions:?}"); + assert!( + back.contains("Dialog") && menu_items(&back).is_empty(), + "{back}" + ); + // Open leads; rows past what the panel shows unscrolled are not built. + let context = menu_items(&context); + assert!( + actions.len() > 5 && context.starts_with(&actions[1..]), + "one list feeds both: {actions:?} {context:?}" + ); +} + +/// The page tabs listed in `tree`, the selected one marked with `*`. +fn page_tabs(tree: &str) -> Vec { + tree.lines() + .skip_while(|line| line.trim() != r#"TabList "Pages""#) + .skip(1) + .take_while(|line| line.trim_start().starts_with("Tab ")) + .map(|line| { + let name = line.split('"').nth(1).unwrap_or_default(); + match line.ends_with("[selected]") { + true => format!("*{name}"), + false => name.to_owned(), + } + }) + .collect() +} + +/// Undo walks back through two new pages and what was typed on each, crossing pages as +/// it goes: each page keeps its own history once left, and a new page goes once what was +/// typed on it has gone. +#[test] +fn undo_walks_back_through_new_pages_and_their_titles() { + let scratch = Scratch::new("undo-pages"); + let notebook = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate"); + let chord = + |modifiers: &str, key: &str| [modifiers, key, "modifiers", "wait 800"].map(String::from); + let mut steps = Vec::new(); + for title in ["type One", "type Two"] { + steps.extend(chord("modifiers command", "key n")); + steps.extend([title, "wait 300"].map(String::from)); + } + steps.extend(chord("modifiers command control", "key Left")); + steps.push("accessibility back".into()); + for name in ["shown", "two", "removed", "one", "first"] { + steps.extend(chord("modifiers command", "key z")); + steps.push(format!("accessibility {name}")); + } + steps.extend(chord("modifiers command shift", "key z")); + steps.push("accessibility redone".into()); + let steps: Vec<&str> = steps.iter().map(String::as_str).collect(); + let trees = replay(&scratch, Some(¬ebook), &steps); + let tabs: Vec> = trees.iter().map(|tree| page_tabs(tree)).collect(); + let corpus = [ + "Delete into a table", + "Delete out of a table", + "Type over a row", + "Delete a whole table", + ]; + let list = |extra: &[&str], selected: &str| -> Vec { + corpus + .iter() + .chain(extra) + .map(|name| match *name == selected { + true => format!("*{name}"), + false => name.to_string(), + }) + .collect() + }; + let expected = [ + // Back to the first new page. + list(&["One", "Two"], "One"), + // Undo there shows the newer page, whose typing goes first. + list(&["One", "Two"], "Two"), + list(&["One", "Untitled page"], "Untitled page"), + // Then the page, back to the page shown before it. + list(&["One"], "One"), + // The first page's own typing, kept while it was left. + list(&["Untitled page"], "Untitled page"), + list(&[], "Delete into a table"), + // Redo brings the page back. + list(&["Untitled page"], "Untitled page"), + ]; + assert_eq!(tabs.len(), expected.len()); + for ((tabs, expected), tree) in tabs.iter().zip(&expected).zip(&trees) { + assert_eq!(tabs, expected, "{tree}"); + } +} diff --git a/crates/ui/src/layout.rs b/crates/ui/src/layout.rs index 4ecaac97da187948705fc7723765b022d04e04e9..45c29680eb70c5ef39f06f18cc571488fd5ca92e 100644 --- a/crates/ui/src/layout.rs +++ b/crates/ui/src/layout.rs @@ -1,4 +1,6 @@ -use crate::{Anchor, Axis, Built, Flags, ICON, ICON_GAP, Id, Overflow, Size, State, text::Texts}; +use crate::{ + Anchor, Axis, Built, Flags, ICON, ICON_GAP, Id, Overflow, Size, State, fitting, text::Texts, +}; use std::collections::HashMap; /// Sizes each box on both axes, then places it: standalone sizes, sizes taken from @@ -49,8 +51,13 @@ pub(crate) fn solve( Size::Fraction(_) | Size::Children => 0.0, }; } + fit_popups(nodes, axis); for index in 1..nodes.len() { if let Size::Fraction(fraction) = nodes[index].size[axis].size { + if stretches(nodes, index, axis) { + nodes[index].computed[axis] = 0.0; + continue; + } let mut ancestor = nodes[index].parent; while ancestor != 0 && nodes[ancestor].size[axis].size == Size::Children { ancestor = nodes[ancestor].parent; @@ -65,6 +72,8 @@ pub(crate) fn solve( nodes[index].computed[axis] = content + 2.0 * nodes[index].pad[axis]; } } + yield_popups(nodes, axis); + fit_popups(nodes, axis); for index in 0..nodes.len() { let node = &nodes[index]; if node.children.is_empty() || (axis == 1 && node.flags.contains(Flags::SCROLL)) { @@ -118,6 +127,12 @@ pub(crate) fn solve( for child in children { let room = (across(nodes, index, child, axis) - 2.0 * nodes[index].pad[axis]).max(0.0); + if let Size::Fraction(fraction) = nodes[child].size[axis].size + && stretches(nodes, child, axis) + { + nodes[child].computed[axis] = room * fraction; + continue; + } let over = nodes[child].computed[axis] - room; if over > 0.0 { nodes[child].computed[axis] -= @@ -197,6 +212,50 @@ pub(crate) fn solve( } } +/// Whether `index`, sized from an ancestor, is across a parent sized by its children: it takes +/// no room while the parent sums its other children, then that share of the parent's room. +fn stretches(nodes: &[Built], index: usize, axis: usize) -> bool { + let node = &nodes[index]; + let parent = &nodes[node.parent]; + index != 0 + && parent.size[axis].size == Size::Children + && !along(parent, axis) + && !(axis == 1 && parent.flags.contains(Flags::SCROLL)) + && !node.flags.contains(Flags::FLOAT) + && node.anchor.is_none() +} + +/// Lets each popup sized loosely give way to the window, as far as its strictness lets it: +/// a dialog keeps below it the margin it opens under. +fn yield_popups(nodes: &mut [Built], axis: usize) { + let window = nodes[0].computed[axis]; + for node in &mut nodes[1..] { + let room = match node.anchor { + None => continue, + Some(Anchor::Dialog | Anchor::Top) if axis == 1 => window * 3.0 / 4.0, + Some(_) => fitting(window), + }; + let over = node.computed[axis] - room; + if over > 0.0 { + node.computed[axis] -= over * (1.0 - node.size[axis].strictness.clamp(0.0, 1.0)); + } + } +} + +/// Shrinks each popup longer than the window lets it be on `axis`; one cut short vertically +/// scrolls what it holds. +fn fit_popups(nodes: &mut [Built], axis: usize) { + let most = fitting(nodes[0].computed[axis]); + for node in &mut nodes[1..] { + if node.anchor.is_some() && node.computed[axis] > most { + node.computed[axis] = most; + if axis == 1 { + node.flags = node.flags | Flags::SCROLL | Flags::CLIP; + } + } + } +} + /// The length `parent` lays `child` out across: a popup widening over its anchor lays its /// contents out at its full width, but for boxes standing in for the anchor, which widen with it. fn across(nodes: &[Built], parent: usize, child: usize, axis: usize) -> f32 { @@ -205,7 +264,7 @@ fn across(nodes: &[Built], parent: usize, child: usize, axis: usize) -> f32 { (Some(Anchor::Over(_)), Size::Pixels(full)) if axis == 0 && !nodes[child].flags.contains(Flags::STILL) => { - full + full.min(fitting(nodes[0].computed[axis])) } _ => node.computed[axis], } diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 1395106f7de45167b5738ab99de16ecf81fb149e..cff8eade2267a91433a280a7019ff8fe8fc6bc77 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -85,7 +85,8 @@ pub enum Size { Pixels(f32), /// The label and padding. Text, - /// A fraction of the nearest ancestor not sized by its children. + /// A fraction of the nearest ancestor not sized by its children; across a parent sized by + /// its children, a fraction of what the parent's other children make it. Fraction(f32), /// The children laid out along this axis, and padding. Children, @@ -207,6 +208,14 @@ pub enum Overflow { Ellipsis, } +/// The least a popup keeps between itself and each of the window's edges. +const POPUP_MARGIN: f32 = 4.0; + +/// The longest a popup may be in a window `room` long; one longer scrolls or squeezes. +fn fitting(room: f32) -> f32 { + (room - 2.0 * POPUP_MARGIN).max(0.0) +} + /// Where a popup opens, flipping to the far side of its anchor where the window ends first. #[derive(Clone, Copy, Debug, PartialEq)] pub enum Anchor { @@ -231,13 +240,18 @@ impl Anchor { /// anchor on the axis it opens along, level with it otherwise. Shown only `shown` long /// as it opens, it keeps the edge it would have at full size. fn place(self, axis: usize, size: f32, shown: f32, room: f32) -> f32 { + let size = size.min(fitting(room)); + let shown = shown.min(size); + let most = (room - POPUP_MARGIN - size).max(POPUP_MARGIN); let (rect, along) = match self { Anchor::Below(rect) => (rect, Some(1)), Anchor::Right(rect) => (rect, Some(0)), Anchor::Over(rect) => (rect, None), Anchor::Point([x, y]) => ([x, y, x, y], Some(1)), - Anchor::Dialog | Anchor::Top if axis == 0 => return ((room - size) / 2.0).max(0.0), - Anchor::Dialog | Anchor::Top => return (room / 8.0).min(room - size).max(0.0), + Anchor::Dialog | Anchor::Top if axis == 0 => { + return ((room - size) / 2.0).max(POPUP_MARGIN); + } + Anchor::Dialog | Anchor::Top => return (room / 8.0).clamp(POPUP_MARGIN, most), }; let [low, high] = [rect[axis], rect[axis + 2]]; let (first, second) = if along == Some(axis) { @@ -245,12 +259,12 @@ impl Anchor { } else { (low, high - size) }; - if first + size <= room { - first - } else if second >= 0.0 { - second + size - shown + if first + size <= room - POPUP_MARGIN { + first.max(POPUP_MARGIN) + } else if second >= POPUP_MARGIN { + second.min(most) + size - shown } else { - first.min(room - size).max(0.0) + size - shown + first.clamp(POPUP_MARGIN, most) + size - shown } } @@ -323,6 +337,8 @@ pub struct Spec<'a> { pub font_size: Option, /// Shapes the label semibold. pub bold: bool, + /// Shapes the label italic. + pub italic: bool, pub font: Option<&'a str>, pub overflow: Overflow, /// The label's colour; the theme's text colour otherwise. @@ -548,6 +564,8 @@ struct State { held: Option<(ScrollerPart, Instant)>, /// An animated value and its target, for `Ui::animate`. tween: Option<[f32; 2]>, + /// When the condition `Ui::lasted` follows last became true. + since: Option, } /// An open popup, above the one opened before it. @@ -839,6 +857,18 @@ impl Ui { self.states.get(&id).map(|state| state.rect) } + /// Scrolls `scroller`, a scrolling box, until `target` within it starts at its top, both + /// as last laid out; the wheel's easing carries it there. + pub fn scroll_to(&mut self, scroller: Id, target: Id) { + let (Some(top), Some(start)) = (self.rect(target), self.rect(scroller)) else { + return; + }; + let state = self.states.entry(scroller).or_default(); + let most = (state.content - (state.rect[3] - state.rect[1])).max(0.0); + state.scroll_target = (state.scroll + top[1] - start[1]).clamp(0.0, most); + self.animating |= state.scroll != state.scroll_target; + } + /// The pointer's cursor, or None over a custom box, whose host chooses. pub fn cursor(&self) -> Option { let hit = self @@ -1180,7 +1210,7 @@ impl Ui { let label = spec.text.map(|text| { let size = spec.font_size.unwrap_or(self.theme.font_size); self.texts - .label(text, size, spec.bold, spec.font, self.frame) + .styled(text, size, spec.bold, spec.italic, spec.font, self.frame) }); // Popups hang from the root, outside the clips and flow of where they are built. let parent = if spec.anchor.is_some() { @@ -1236,6 +1266,18 @@ impl Ui { *value } + /// How long `held` has stayed true through the frames that ask of `id`; zero while + /// false, as a status waits before showing a state that may pass at once. + pub fn lasted(&mut self, id: Id, held: bool) -> Duration { + let now = self.now; + let state = self.states.entry(id).or_default(); + state.touched = self.frame; + state.since = held.then(|| state.since.unwrap_or(now)); + state + .since + .map_or(Duration::ZERO, |since| now.saturating_duration_since(since)) + } + /// Makes `family` the interface's font, where fontique doesn't know the system's, as /// with Mac OS X 10.6's Lucida Grande. pub fn set_system_font(&mut self, family: &str) { @@ -1627,10 +1669,12 @@ impl Ui { radius: *radius, }); } + // A box squeezed narrower than its padding keeps an empty inside, not an inverted one. + let left = (painted[0] + node.pad[0]).min(painted[2]); let inner = [ - painted[0] + node.pad[0], + left, painted[1], - painted[2] - node.pad[0], + (painted[2] - node.pad[0]).max(left), painted[3], ]; let mut x = if node.center { @@ -1650,12 +1694,8 @@ impl Ui { let rect = if node.label.is_some() { [x, top, x + ICON, top + ICON] } else { - [ - inner[0], - painted[1] + node.pad[1], - inner[2], - painted[3] - node.pad[1], - ] + let top = (painted[1] + node.pad[1]).min(painted[3]); + [inner[0], top, inner[2], (painted[3] - node.pad[1]).max(top)] }; self.display.push(Display::Image { image: image.clone(), diff --git a/crates/ui/src/list.rs b/crates/ui/src/list.rs index b812511a4086a3d3611dfe41068c98a228fc9875..ef1b977f391b929ad9bd84fa8a59881e49cc85d1 100644 --- a/crates/ui/src/list.rs +++ b/crates/ui/src/list.rs @@ -55,6 +55,13 @@ pub(crate) struct State { selected: Option, } +impl State { + /// The keys of the rows built last frame. + pub(crate) fn shown(&self) -> impl Iterator + '_ { + self.shown.iter().map(|(key, _)| *key) + } +} + /// Builds `list` into a box of `spec`, calling `build` inside each row in view, and moves /// `selected` with the list's keys. Returns the index of the row clicked. pub fn list( diff --git a/crates/ui/src/popup.rs b/crates/ui/src/popup.rs index d7f8376a7db7ecffe76b34e1b9237c6f9505c9af..6b20f19b7d423c091d15201b51424223d0934701 100644 --- a/crates/ui/src/popup.rs +++ b/crates/ui/src/popup.rs @@ -75,6 +75,8 @@ pub struct Item<'a> { pub separated: bool, /// Names the group it starts: shown only while unfiltered, never chosen. pub heading: bool, + /// Repeats an item listed further on, so shows only while unfiltered. + pub repeated: bool, } impl Item<'_> { @@ -327,22 +329,38 @@ fn tooltip_below( ui.close(); } +/// A palette's item picked, by mode and index: to run, or for its actions, which the menu +/// `actions` names lists beside its row. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Pick { + Run(usize, usize), + Actions(usize, usize), +} + +/// The menu of the actions on palette `id`'s item, which Escape closes back to the palette. +pub fn actions(id: Id) -> Id { + id.child("actions") +} + /// Builds popup `id` as a command palette across the top of the window while it is open, /// under a filter field showing `placeholder` while empty. What is typed picks the first of /// `modes` whose prefix it starts with, and the rest of it narrows that mode's items, most -/// relevant first. Returns the mode and the index of the item chosen. -pub fn palette( - ui: &mut Ui, - id: Id, - modes: &[(&str, &[Item])], - placeholder: &str, -) -> Option<(usize, usize)> { +/// relevant first. Enter or a click runs the item; ⌘K (Ctrl+K elsewhere) on the highlighted +/// one or a right-click on any opens its `actions` menu. +pub fn palette(ui: &mut Ui, id: Id, modes: &[(&str, &[Item])], placeholder: &str) -> Option { if !ui.popup_open(id) { return None; } + let rows = id.child("rows"); + let clicked = (ui.lists.get(&rows).into_iter()) + .flat_map(crate::list::State::shown) + .find(|key| { + (ui.signals.get(&rows.child(*key))).is_some_and(|signal| signal.context.is_some()) + }); + let chord = chord(ui, id.child("filter"), "k"); let window = ui.rect(Id::ROOT).unwrap_or_default(); let width = PALETTE.min(window[2] - 8.0 * PAD).max(NARROWEST); - choose( + if let Some((mode, index)) = choose( ui, id, Role::Dialog, @@ -351,7 +369,39 @@ pub fn palette( Some(placeholder), width, ROW, - ) + ) { + return Some(Pick::Run(mode, index)); + } + if !ui.popup_open(id) { + return None; + } + let popup = state(ui, id); + popup.highlight = clicked.or(popup.highlight); + let key = clicked.or(popup.highlight.filter(|_| chord))?; + let mode = modes + .iter() + .position(|(prefix, _)| popup.query.starts_with(prefix))?; + if modes[mode].1.get(key as usize)?.heading { + return None; + } + ui.open_submenu(actions(id), id, rows.child(key)); + Some(Pick::Actions(mode, key as usize)) +} + +/// Takes a press of `character` with the shortcut modifier, ⌘ or Ctrl, routed to `owner`. +fn chord(ui: &mut Ui, owner: Id, character: &str) -> bool { + if !crate::edit_modifiers(ui.modifiers).command { + return false; + } + let Some(signal) = ui.signals.get_mut(&owner) else { + return false; + }; + let before = signal.events.len(); + signal.events.retain(|event| { + !matches!(event, Event::Key { key: Key::Character(typed), .. } + if typed.eq_ignore_ascii_case(character)) + }); + signal.events.len() != before } /// Opens popup `id` with `query` typed in its filter field and the caret after it, or @@ -1409,7 +1459,7 @@ impl<'a> Matches<'a> { items .iter() .enumerate() - .filter(|(_, item)| !item.heading) + .filter(|(_, item)| !item.heading && !item.repeated) .filter_map(|(index, item)| { let text = Utf32Str::new(item.text, &mut chars); Some((pattern.score(text, matcher)?, index)) diff --git a/crates/ui/src/tests.rs b/crates/ui/src/tests.rs index 10702a332fe96faa17abb25aff2a3e0675ed01ea..6d13f603bc016374b6ec65446a28c2ffc5f527c6 100644 --- a/crates/ui/src/tests.rs +++ b/crates/ui/src/tests.rs @@ -15,14 +15,19 @@ thread_local! { static FRAMES: std::cell::Cell = const { std::cell::Cell::new(0) }; } -/// Builds a frame 16 ms after this thread's previous one. +/// Builds a frame in a 400 by 300 window, 16 ms after this thread's previous one. fn frame(ui: &mut Ui, build: impl FnOnce(&mut Ui)) { + sized_frame(ui, [400.0, 300.0], build); +} + +/// Builds a frame in a window `size` large, 16 ms after this thread's previous one. +fn sized_frame(ui: &mut Ui, size: [f32; 2], build: impl FnOnce(&mut Ui)) { let frames = FRAMES.with(|frames| { frames.set(frames.get() + 1); frames.get() }); let now = START.with(|start| *start) + Duration::from_millis(16) * frames; - ui.begin([400.0, 300.0], 2.0, now); + ui.begin(size, 2.0, now); build(ui); ui.end(); } @@ -1087,13 +1092,17 @@ fn tooltips_wait_then_switch_at_once_and_hide_on_press_until_left() { let [Some(first), None] = rest(&mut ui, 200) else { panic!("the first tooltip shows after its delay"); }; - assert_eq!(first[..2], [0.0, 20.0 + popup::PAD]); + assert_eq!( + first[..2], + [POPUP_MARGIN, 20.0 + popup::PAD], + "off the window's edge" + ); ui.event(Event::PointerMoved([390.0, 10.0])); let [None, Some(second)] = tip_frame(&mut ui) else { panic!("moving to another shows its tooltip at once"); }; - assert_eq!(second[2], 400.0, "kept inside the window"); + assert_eq!(second[2], 400.0 - POPUP_MARGIN, "kept inside the window"); press(&mut ui, Instant::now(), true); press(&mut ui, Instant::now(), false); @@ -1140,6 +1149,96 @@ fn popups_open_beside_their_anchor_and_flip_to_stay_in_the_window() { ); } +#[test] +fn popups_too_large_for_a_narrow_window_shrink_inside_its_margin() { + let inside = |rect: [f32; 4], window: [f32; 2]| { + rect[0] >= POPUP_MARGIN + && rect[1] >= POPUP_MARGIN + && rect[2] <= window[0] - POPUP_MARGIN + && rect[3] <= window[1] - POPUP_MARGIN + }; + let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); + let narrow = [100.0, 300.0]; + let menu = |ui: &mut Ui| { + sized_frame(ui, narrow, |ui| { + popup::menu(ui, menu_id(), BELOW, &items(), None); + }); + }; + menu(&mut ui); + ui.open_popup(menu_id()); + for _ in 0..40 { + menu(&mut ui); + } + let rect = ui.rect(menu_id()).unwrap(); + assert!(inside(rect, narrow), "{rect:?} leaves the window's margin"); + assert_eq!( + rect[2] - rect[0], + 100.0 - 2.0 * POPUP_MARGIN, + "as wide as fits" + ); + let row = ui.rect(menu_id().child("rows").child(0_u64)).unwrap(); + assert!(row[2] <= rect[2], "its rows narrow with it"); + + // A dialog taller than a short window scrolls what it holds. + let short = [400.0, 120.0]; + let dialog = Id::ROOT.child("dialog"); + let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); + let build = |ui: &mut Ui| { + sized_frame(ui, short, |ui| { + ui.open_as( + dialog, + Spec { + axis: Axis::Y, + size: [px(600.0), children()], + anchor: Some(Anchor::Dialog), + ..Spec::default() + }, + ); + for line in 0..10 { + ui.leaf( + line, + Spec { + size: [fill(), px(30.0)], + text: Some("Line"), + // Wider than the squeezed dialog leaves it. + pad: [250.0, 0.0], + ..Spec::default() + }, + ); + } + ui.close(); + }); + }; + build(&mut ui); + ui.open_popup(dialog); + for _ in 0..40 { + build(&mut ui); + } + let rect = ui.rect(dialog).unwrap(); + assert!(inside(rect, short), "{rect:?} leaves the window's margin"); + let first = ui.rect(dialog.child(0)).unwrap(); + ui.event(Event::PointerMoved([200.0, 60.0])); + ui.event(Event::Wheel([0.0, -100.0])); + for _ in 0..40 { + build(&mut ui); + } + let scrolled = ui.rect(dialog.child(0)).unwrap(); + assert!(scrolled[1] < first[1], "the wheel scrolls it"); + for layer in ui.layers() { + let Layer::Primitives(layer) = layer else { + continue; + }; + for primitive in &layer.primitives { + if let draw::Primitive::Text { + clip: Some(clip), .. + } = primitive + { + assert!(clip[0] <= clip[2], "{clip:?} is inverted"); + } + } + } +} + #[test] fn popups_show_at_once_by_their_anchor() { 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() { frame(ui, |ui| { chosen = popup::palette(ui, palette, &[("", &commands)], "Search commands") }); - chosen.map(|(_, index)| index) + match chosen { + Some(popup::Pick::Run(_, index)) => Some(index), + _ => None, + } }; build(&mut ui); ui.open_popup(palette); @@ -1385,7 +1487,11 @@ fn a_prefix_typed_in_the_palette_switches_what_it_lists() { build(&mut ui); ui.event(typed("sel")); ui.event(key(NamedKey::Enter)); - assert_eq!(build(&mut ui), Some((0, 3)), "typed after the prefix"); + assert_eq!( + build(&mut ui), + Some(popup::Pick::Run(0, 3)), + "typed after the prefix" + ); popup::open_with(&mut ui, palette, ">"); build(&mut ui); ui.event(key(NamedKey::Backspace)); @@ -1393,7 +1499,75 @@ fn a_prefix_typed_in_the_palette_switches_what_it_lists() { assert_eq!(popup::query(&ui, palette), Some("")); ui.event(typed("tnr")); ui.event(key(NamedKey::Enter)); - assert_eq!(build(&mut ui), Some((1, 42)), "back among the places"); + assert_eq!( + build(&mut ui), + Some(popup::Pick::Run(1, 42)), + "back among the places" + ); +} + +#[test] +fn the_palette_opens_an_items_actions_on_command_k_or_a_right_click() { + let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); + let palette = Id::ROOT.child("palette"); + let actions = popup::actions(palette); + let mut places = items(); + // The first repeats an item further on, as a recent page does. + places[0].repeated = true; + let build = |ui: &mut Ui| { + let mut picked = None; + frame(ui, |ui| { + picked = popup::palette(ui, palette, &[("", &places)], "Go to"); + popup::menu( + ui, + actions, + Anchor::Point([0.0; 2]), + &items(), + Some("Actions"), + ); + }); + picked + }; + build(&mut ui); + ui.open_popup(palette); + build(&mut ui); + ui.event(key(NamedKey::ArrowDown)); + build(&mut ui); + ui.event(Event::Modifiers(SHORTCUT)); + ui.event(typed("k")); + assert_eq!(build(&mut ui), Some(popup::Pick::Actions(0, 0))); + assert!(ui.popup_open(actions)); + assert_eq!( + popup::query(&ui, palette), + Some(""), + "the chord types nothing" + ); + ui.event(Event::Modifiers(ModifiersState::empty())); + build(&mut ui); + ui.event(key(NamedKey::Escape)); + build(&mut ui); + assert!( + ui.popup_open(palette) && !ui.popup_open(actions), + "Escape goes back to the palette" + ); + let row = ui.rect(palette.child("rows").child(3_u64)).unwrap(); + ui.event(Event::PointerMoved([row[0] + 4.0, row[1] + 4.0])); + ui.event(Event::Button { + button: MouseButton::Right, + pressed: true, + at: Instant::now(), + }); + assert_eq!(build(&mut ui), Some(popup::Pick::Actions(0, 3))); + assert!(ui.popup_open(actions)); + ui.event(key(NamedKey::Escape)); + build(&mut ui); + ui.event(typed("c")); + build(&mut ui); + assert_eq!( + ui.rect(palette.child("rows").child(0_u64)), + None, + "a repeated item shows only while unfiltered" + ); } #[test] @@ -2297,11 +2471,21 @@ fn every_control_in_an_open_dialog_takes_clicks_where_it_rests() { fn a_combo_s_field_widens_over_rows_laid_out_where_they_end_and_its_rows_take_clicks() { let mut ui = Ui::new(Theme::light(), DOUBLE_CLICK); let fonts = fonts(); - let combo = Id::ROOT.child("combo"); + // Inset, as a popup keeps off the window's edges. + let combo = Id::ROOT.child("inset").child("combo"); let mut chosen = None; let mut build = |ui: &mut Ui| { frame(ui, |ui| { + ui.open( + "inset", + Spec { + size: [children(), children()], + pad: [10.0, 10.0], + ..Spec::default() + }, + ); shell::combo(ui, "combo", "Font", "Calibri", 120.0, menu_id(), true); + ui.close(); let anchor = Anchor::Over(ui.rect(combo).unwrap_or_default()); chosen = popup::menu(ui, menu_id(), anchor, &fonts, Some("Font")); }); @@ -2754,3 +2938,106 @@ fn password_fields_edit_the_characters_behind_their_bullets() { edit(&mut ui, &mut text); assert_eq!(text, "pß"); } + +#[test] +fn a_loose_dialog_fits_its_contents_to_the_window_and_its_side_stretches_to_its_list() { + let dialog = Id::ROOT.child("dialog"); + let side = dialog.child("body").child("side"); + let list = dialog.child("body").child("list"); + let loose = Extent { + size: Size::Children, + strictness: 0.0, + }; + let build = |ui: &mut Ui, rows: usize| { + frame(ui, |ui| { + ui.open_as( + dialog, + Spec { + axis: Axis::Y, + size: [px(300.0), loose], + anchor: Some(Anchor::Dialog), + ..Spec::default() + }, + ); + ui.leaf( + "header", + Spec { + size: [fill(), px(30.0)], + ..Spec::default() + }, + ); + ui.open( + "body", + Spec { + size: [fill(), loose], + ..Spec::default() + }, + ); + ui.leaf( + "side", + Spec { + size: [px(100.0), fill()], + ..Spec::default() + }, + ); + ui.open( + "list", + Spec { + flags: Flags::SCROLL | Flags::CLIP, + axis: Axis::Y, + size: [fill(), loose], + ..Spec::default() + }, + ); + for row in 0..rows { + ui.leaf( + row, + Spec { + size: [fill(), px(30.0)], + ..Spec::default() + }, + ); + } + ui.close(); + ui.close(); + ui.leaf( + "footer", + Spec { + size: [fill(), px(30.0)], + ..Spec::default() + }, + ); + ui.close(); + }); + }; + let height = |ui: &Ui, id: Id| ui.rect(id).map(|rect| rect[3] - rect[1]).unwrap(); + let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); + build(&mut ui, 2); + ui.open_popup(dialog); + for _ in 0..40 { + build(&mut ui, 2); + } + assert_eq!(height(&ui, dialog), 120.0, "as tall as what it holds"); + assert_eq!(height(&ui, side), 60.0, "the side as tall as the list"); + + for _ in 0..40 { + build(&mut ui, 20); + } + assert_eq!( + height(&ui, dialog), + 225.0, + "no taller than the window lets a dialog be" + ); + assert_eq!(height(&ui, list), 165.0, "the list gives way"); + assert_eq!(height(&ui, side), 165.0); + + ui.scroll_to(list, list.child(10_usize)); + for _ in 0..60 { + build(&mut ui, 20); + } + let [top, row] = [list, list.child(10_usize)].map(|id| ui.rect(id).unwrap()[1]); + assert!( + (row - top).abs() < 0.5, + "row 10 at {row}, the list's top at {top}" + ); +} diff --git a/crates/ui/src/text.rs b/crates/ui/src/text.rs index d091cd818971b224f8cac8c9e5c58a32c9adbc24..c8076a82a2281aacdc8eaa357b53abd75f458a28 100644 --- a/crates/ui/src/text.rs +++ b/crates/ui/src/text.rs @@ -12,7 +12,7 @@ pub(crate) struct Label { pub key: LabelKey, } -type LabelKey = (String, u32, bool, Option); +type LabelKey = (String, u32, bool, bool, Option); /// Labels shaped this frame or the previous one, keyed by their text and size. #[derive(Default)] @@ -32,11 +32,25 @@ impl Texts { bold: bool, font: Option<&str>, frame: u64, + ) -> Rc