diff --git a/Cargo.lock b/Cargo.lock index b6a15291fdcbdb831ac12c18250d3d85c26e70cb..e20e679dbdee903dedff8325f5612f9c093525ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -228,9 +228,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" dependencies = [ "clipboard-win", + "image", "log", "objc2 0.6.4", "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", @@ -633,6 +636,7 @@ dependencies = [ "draw", "icu_normalizer", "image", + "miniz_oxide 0.8.9", "onestore", "parley", "png", @@ -974,10 +978,13 @@ dependencies = [ "bytemuck", "image", "linebender_resource_handle", + "miniz_oxide 0.8.9", "parley", + "pdf-writer", "png", "pollster", "roxmltree", + "subsetter", "swash", "wgpu", ] @@ -1971,7 +1978,7 @@ dependencies = [ "naga-types", "num-traits", "once_cell", - "rustc-hash", + "rustc-hash 1.1.0", "spirv", "thiserror 2.0.20", "unicode-ident", @@ -1985,7 +1992,7 @@ checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" dependencies = [ "hashbrown 0.17.1", "indexmap", - "rustc-hash", + "rustc-hash 1.1.0", "thiserror 2.0.20", ] @@ -2046,6 +2053,7 @@ dependencies = [ "tempfile", "thiserror 2.0.20", "tokio", + "windows-sys 0.61.2", "zeroize", ] @@ -2544,6 +2552,18 @@ dependencies = [ "hmac", ] +[[package]] +name = "pdf-writer" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5e456864a7a304047bff84977dc6fb162bd956475d40ba50b2dcecaada7f753" +dependencies = [ + "bitflags 2.13.1", + "itoa", + "memchr", + "ryu", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2951,6 +2971,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -3027,6 +3053,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -3414,6 +3446,15 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "subsetter" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38803281d1c23166c5ebcb455439a5d2afe711cc909cf88af72448c297756ad6" +dependencies = [ + "rustc-hash 2.1.3", +] + [[package]] name = "subtle" version = "2.6.1" @@ -4157,7 +4198,7 @@ dependencies = [ "portable-atomic", "profiling", "raw-window-handle", - "rustc-hash", + "rustc-hash 1.1.0", "smallvec", "thiserror 2.0.20", "wgpu-core-deps-apple", diff --git a/apps/ios/Snowbound.xcodeproj/project.pbxproj b/apps/ios/Snowbound.xcodeproj/project.pbxproj index 90582edbc6c67670d0933c2dd8613b9dc9bc7e88..494656c41f48fbe70e14f43441d296196416928c 100644 --- a/apps/ios/Snowbound.xcodeproj/project.pbxproj +++ b/apps/ios/Snowbound.xcodeproj/project.pbxproj @@ -8,14 +8,18 @@ /* Begin PBXBuildFile section */ 5B0000000000000000000101 /* Sample in Resources */ = {isa = PBXBuildFile; fileRef = 5B0000000000000000000201 /* Sample */; }; + 5B0000000000000000000126 /* Snowbound Guide in Resources */ = {isa = PBXBuildFile; fileRef = 5B0000000000000000000226 /* Snowbound Guide */; }; + 5B0000000000000000000102 /* Snowbound-Tahoe.icon in Resources */ = {isa = PBXBuildFile; fileRef = 5B0000000000000000000205 /* Snowbound-Tahoe.icon */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 5B0000000000000000000200 /* Snowbound.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Snowbound.app; sourceTree = BUILT_PRODUCTS_DIR; }; 5B0000000000000000000201 /* Sample */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Sample; path = "../../corpus/m6/native-features-01/notebook"; sourceTree = SOURCE_ROOT; }; + 5B0000000000000000000226 /* Snowbound Guide */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "Snowbound Guide"; path = "../../docs/guide-notebook/Snowbound Guide"; sourceTree = SOURCE_ROOT; }; 5B0000000000000000000202 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 5B0000000000000000000203 /* Snowbound-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Snowbound-Bridging-Header.h"; sourceTree = ""; }; 5B0000000000000000000204 /* build-rust.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "build-rust.sh"; sourceTree = ""; }; + 5B0000000000000000000205 /* Snowbound-Tahoe.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; name = "Snowbound-Tahoe.icon"; path = "../../crates/snowbound/assets/icon/sources/Snowbound-Tahoe.icon"; sourceTree = SOURCE_ROOT; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -42,6 +46,8 @@ children = ( 5B0000000000000000000300 /* Snowbound */, 5B0000000000000000000201 /* Sample */, + 5B0000000000000000000226 /* Snowbound Guide */, + 5B0000000000000000000205 /* Snowbound-Tahoe.icon */, 5B0000000000000000000202 /* Info.plist */, 5B0000000000000000000203 /* Snowbound-Bridging-Header.h */, 5B0000000000000000000204 /* build-rust.sh */, @@ -121,6 +127,8 @@ buildActionMask = 2147483647; files = ( 5B0000000000000000000101 /* Sample in Resources */, + 5B0000000000000000000126 /* Snowbound Guide in Resources */, + 5B0000000000000000000102 /* Snowbound-Tahoe.icon in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -198,7 +206,7 @@ isa = XCBuildConfiguration; buildSettings = { ARCHS = arm64; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = "Snowbound-Tahoe"; CODE_SIGN_ENTITLEMENTS = Snowbound.entitlements; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; @@ -235,7 +243,7 @@ isa = XCBuildConfiguration; buildSettings = { ARCHS = arm64; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = "Snowbound-Tahoe"; CODE_SIGN_ENTITLEMENTS = Snowbound.entitlements; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; diff --git a/apps/ios/Snowbound/App.swift b/apps/ios/Snowbound/App.swift index 35753ed3d7871e21d337fb6f9211b7543d9bf944..37c8e5b2bf004626abf814304a791664146358b7 100644 --- a/apps/ios/Snowbound/App.swift +++ b/apps/ios/Snowbound/App.swift @@ -44,6 +44,32 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { } } +/// The desktop's Appearance option: the system's, or light or dark regardless of it. +enum Appearance { + static let key = "appearance" + + static var style: UIUserInterfaceStyle { + get { UIUserInterfaceStyle(rawValue: UserDefaults.standard.integer(forKey: key)) ?? .unspecified } + set { + UserDefaults.standard.set(newValue.rawValue, forKey: key) + for case let scene as UIWindowScene in UIApplication.shared.connectedScenes { + for window in scene.windows { window.overrideUserInterfaceStyle = newValue } + } + } + } + + static func menu() -> UIMenu { + let choices: [(String, UIUserInterfaceStyle)] = [("System", .unspecified), ("Light", .light), ("Dark", .dark)] + return UIMenu( + title: "Appearance", image: UIImage(systemName: "circle.lefthalf.filled"), + children: choices.map { title, style in + let action = UIAction(title: title) { _ in Appearance.style = style } + action.state = Appearance.style == style ? .on : .off + return action + }) + } +} + /// Where the reader was, reopened on the next launch as Notes reopens its last note. private struct Place: Codable { let notebook: String @@ -71,6 +97,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) { guard let scene = scene as? UIWindowScene else { return } let window = UIWindow(windowScene: scene) + window.overrideUserInterfaceStyle = Appearance.style split.delegate = self split.scene = self split.preferredDisplayMode = .oneBesideSecondary diff --git a/apps/ios/Snowbound/CanvasView.swift b/apps/ios/Snowbound/CanvasView.swift index 584ee4abb5346049c6b9e4c8bc5d7cfd0ad91f11..a28de9f73aa5d87e6203d72203bd6713a6ec8a3e 100644 --- a/apps/ios/Snowbound/CanvasView.swift +++ b/apps/ios/Snowbound/CanvasView.swift @@ -771,7 +771,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI case #selector(copy(_:)), #selector(cut(_:)), #selector(delete(_:)), #selector(toggleBoldface(_:)), #selector(toggleItalics(_:)), #selector(toggleUnderline(_:)): return lo < hi - case #selector(paste(_:)): return UIPasteboard.general.hasStrings + case #selector(paste(_:)): return UIPasteboard.general.hasStrings || UIPasteboard.general.hasImages case #selector(select(_:)): return lo == hi && hasText case #selector(selectAll(_:)): return hasText && (lo > 0 || hi < endOfDocument.offset) default: return super.canPerformAction(action, withSender: sender) @@ -799,10 +799,15 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI edit(external: true) { replacing(range, with: "") } } - /// Pasted text takes the keyboard's language, as Windows gives the clipboard. + /// Pasted text takes the keyboard's language, as Windows gives the clipboard. Text wins + /// over a picture, which apps put beside copied text. override func paste(_ sender: Any?) { - guard let handle, let text = UIPasteboard.general.string else { return } - edit(external: true) { sb_paste(handle, text, textInputMode?.primaryLanguage ?? "") } + guard let handle else { return } + if let text = UIPasteboard.general.string { + edit(external: true) { sb_paste(handle, text, textInputMode?.primaryLanguage ?? "") } + } else if let image = UIPasteboard.general.image { + insertPicture(image) + } } override func select(_ sender: Any?) { @@ -958,7 +963,27 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI return focused } - /// Puts a JPEG or PNG after the caret's paragraph at `size` points. + /// A photo as OneNote 2010 can read it: JPEG, at most 2048 pixels across, shown at most + /// 220 points wide. + func insertPicture(_ image: UIImage) { + let longest: CGFloat = 2048 + let scale = min(1, longest / max(image.size.width * image.scale, image.size.height * image.scale)) + let pixels = CGSize( + width: (image.size.width * image.scale * scale).rounded(), + height: (image.size.height * image.scale * scale).rounded()) + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let drawn = UIGraphicsImageRenderer(size: pixels, format: format).image { _ in + image.draw(in: CGRect(origin: .zero, size: pixels)) + } + guard let data = drawn.jpegData(compressionQuality: 0.85) else { return } + // OneNote shows a picture's pixels at 96 per inch. + var size = CGSize(width: pixels.width * 0.75, height: pixels.height * 0.75) + if size.width > 220 { size = CGSize(width: 220, height: size.height * 220 / size.width) } + insertPicture(data, size: size) + } + + /// Puts a JPEG or PNG at the caret at `size` points. func insertPicture(_ data: Data, size: CGSize) { guard let handle else { return } edit(external: true) { diff --git a/apps/ios/Snowbound/Lists.swift b/apps/ios/Snowbound/Lists.swift index 6bb640f9ed14d97dd02f808d818bf92bc4ccfc61..0c31c065fbd92eeb58915cf5544f1bb615db1245 100644 --- a/apps/ios/Snowbound/Lists.swift +++ b/apps/ios/Snowbound/Lists.swift @@ -22,6 +22,8 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke /// Opening, or why the notebook cannot be. case status(notebook: String) case newNotebook, openFolder, connect + /// Copies the Snowbound Guide to On My iPhone and opens it, until it is there. + case guide /// New Notebook in iCloud Drive, and the way to it while iCloud Drive is off. case newInCloud, turnOnICloud } @@ -101,6 +103,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke guard let self else { return } Author.ask(from: self) {} }) + more.append(Appearance.menu()) provide(actions + [UIMenu(options: .displayInline, children: more)]) } ]) @@ -128,13 +131,15 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke func reload() { guard isViewLoaded else { return } + let copied = Notebooks.onDevice.contains { $0.source == .documents(path: Notebooks.guide) } + let guide: [Item] = Notebooks.guideOffered && !copied ? [.guide] : [] let cloud: [(Location, [Notebook], [Item])] = ICloud.documents != nil ? [(.icloud, Notebooks.inCloud, [.newInCloud])] : ICloud.signedIn ? [] : [(.icloud, [], [.turnOnICloud])] let locations: [(Location, [Notebook], [Item])] = cloud + (Notebooks.showsOnDevice ? [(.onDevice, Notebooks.onDevice, [.newNotebook])] : []) - + [(.elsewhere, Notebooks.elsewhere, [.openFolder, .connect])] + + [(.elsewhere, Notebooks.elsewhere, [.openFolder, .connect] + guide)] var sections = NSDiffableDataSourceSnapshot() sections.appendSections(locations.map(\.0)) dataSource.apply(sections, animatingDifferences: false) @@ -260,6 +265,10 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke content.text = "New Notebook…" content.image = UIImage(systemName: "plus") content.textProperties.color = .tintColor + case .guide: + content.text = "Open the Snowbound Guide" + content.image = UIImage(systemName: "book") + content.textProperties.color = .tintColor case .newNotebook, .openFolder, .connect: content.text = item == .newNotebook ? "New Notebook…" : item == .openFolder ? "Open Folder…" : "Connect to Server…" @@ -511,6 +520,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke if let settings = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(settings) } case .openFolder: openFolder() case .connect: connect() + case .guide: openGuide() case .location: break } return false @@ -586,6 +596,30 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke } } + /// Copies the guide the app carries to On My iPhone and opens the copy, so the carried one + /// stays as shipped. + private func openGuide() { + let copy = documentsDirectory.appendingPathComponent(Notebooks.guide) + background({ () -> String? in + guard !FileManager.default.fileExists(atPath: copy.path) else { return nil } + guard let carried = Bundle.main.url(forResource: Notebooks.guide, withExtension: nil) else { + return "The guide is missing from this copy of Snowbound." + } + do { try FileManager.default.copyItem(at: carried, to: copy) } catch { return error.localizedDescription } + return nil + }) { [weak self] problem in + guard let self else { return } + if let problem { return refuse("Can’t Open the Guide", problem) } + if !Notebooks.showsOnDevice { Notebooks.showsOnDevice = true } + rescan { [weak self] notebook in + guard notebook.source == .documents(path: Notebooks.guide), + let tab = notebook.tabs.first(where: \.readable) + else { return } + self?.onOpen?(tab, notebook) + } + } + } + private func refuse(_ title: String, _ message: String) { let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "OK", style: .default)) diff --git a/apps/ios/Snowbound/Notebooks.swift b/apps/ios/Snowbound/Notebooks.swift index b90708d6335467d83cdf9042cfdf667272046b6f..b408549b0a0daad3010304af9144075cc7fddd5a 100644 --- a/apps/ios/Snowbound/Notebooks.swift +++ b/apps/ios/Snowbound/Notebooks.swift @@ -391,6 +391,10 @@ enum Notebooks { private static let key = "notebooks" private static let hidden = "hidesOnDevice" + /// The Snowbound Guide's folder, in the app and in Documents once copied there. + static let guide = "Snowbound Guide" + /// Whether the list offers the guide; off until Clover has read it through. + static let guideOffered = false private static let scripted = ProcessInfo.processInfo.environment["SNOWBOUND_NOTEBOOK"] /// The folders and sections in Documents, as On My iPhone lists them. private(set) static var onDevice: [Notebook] = [] diff --git a/apps/ios/Snowbound/PageViewController.swift b/apps/ios/Snowbound/PageViewController.swift index 0872c786d689e3c803c241a690cf332bef051f47..937623ef55f5be22250b987a417530218da2e5c2 100644 --- a/apps/ios/Snowbound/PageViewController.swift +++ b/apps/ios/Snowbound/PageViewController.swift @@ -330,7 +330,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return } provider.loadObject(ofClass: UIImage.self) { [weak self] image, _ in guard let image = image as? UIImage else { return } - DispatchQueue.main.async { self?.insert(image) } + DispatchQueue.main.async { self?.canvas.insertPicture(image) } } } @@ -338,27 +338,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate _ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] ) { picker.dismiss(animated: true) - if let image = info[.originalImage] as? UIImage { insert(image) } - } - - /// A photo as OneNote 2010 can read it: JPEG, at most 2048 pixels across, shown at most - /// 220 points wide. - private func insert(_ image: UIImage) { - let longest: CGFloat = 2048 - let scale = min(1, longest / max(image.size.width * image.scale, image.size.height * image.scale)) - let pixels = CGSize( - width: (image.size.width * image.scale * scale).rounded(), - height: (image.size.height * image.scale * scale).rounded()) - let format = UIGraphicsImageRendererFormat() - format.scale = 1 - let drawn = UIGraphicsImageRenderer(size: pixels, format: format).image { _ in - image.draw(in: CGRect(origin: .zero, size: pixels)) - } - guard let data = drawn.jpegData(compressionQuality: 0.85) else { return } - // OneNote shows a picture's pixels at 96 per inch. - var size = CGSize(width: pixels.width * 0.75, height: pixels.height * 0.75) - if size.width > 220 { size = CGSize(width: 220, height: size.height * 220 / size.width) } - canvas.insertPicture(data, size: size) + if let image = info[.originalImage] as? UIImage { canvas.insertPicture(image) } } } diff --git a/arc/canvas.md b/arc/canvas.md index 6dffa9a1b2c4c033d282ded5370cbcf43c4d9eca..433dd51f6adeeb3ca4f8ca0f18a3459f6ad7313c 100644 --- a/arc/canvas.md +++ b/arc/canvas.md @@ -117,6 +117,14 @@ package. The page draws the PNG OneNote rendered of each pictures. Snowbound does not write XPS, so a printout page deleted and brought back by undo returns as that picture (`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. + ## Recordings Record Audio and Record Video work as OneNote 2010's do. The caret's paragraph @@ -222,6 +230,19 @@ iOS) offers the dictionary's corrections, Ignore and Add to Dictionary, and the Spelling pane (F7) walks the page's marked words. A correction is an ordinary edit, one revision. Nothing about spelling is stored in the page. +## Printing and PDF + +`canvas::print` lays pages on paper as OneNote 2010 prints them (`corpus/print`): between +half-inch top and bottom margins with the margin origin an inch in, the whole page shrunk +when its content reaches past the paper's right edge, and each sheet after the first +starting at the line of text or picture the one before would have cut. Rule lines and +template art print across the paper; the page colour does not. The footer names the +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. + ## Search, dates, conflicts Smaller modules follow the same pattern of reproducing OneNote's behaviour diff --git a/corpus/equation-select/README.md b/corpus/equation-select/README.md new file mode 100644 index 0000000000000000000000000000000000000000..5a15a60f4aeff634e51a009b18ecec9824fcdea9 --- /dev/null +++ b/corpus/equation-select/README.md @@ -0,0 +1,26 @@ +# Edits reaching into equation objects + +`lab/` holds COM reads of a disposable OneNote 2010 clone editing a copy of +`corpus/math-edit/native-editor-4` by hand. `before.xml` is the page as it opened and +`after.xml` as it ended; the rest are reads between steps. + +- A mouse selection from inside `√x+1`'s root into `∛x`'s shows both objects selected + whole, and Backspace removes both paragraphs (`roots-deleted.xml`). +- From after `a₁` in `a₁+b₂` into the body of a sum ending its paragraph: the sum is taken + whole with the paragraph's end, so Backspace joins `a₁` to the integral below. +- From a fraction's numerator in `One a/b+c two` into the root of `Three √x+y four`: + both objects whole, leaving `One +y four`. +- Enter between `→` and `∞` in `lim_(n→∞) a_n` makes the limit an equation array + (`limit-enter.xml`). +- A click in "Type equation here." selects the placeholder whole; Enter leaves its + paragraph empty above a new one (`placeholder-enter.xml`), and typing `x` replaces it. +- A drag past a line's end also takes the paragraph's end: "Alpha beta", "Gamma delta" + selected from `Alp` to past `delta` delete to `Alpepsilon`. + +`candidate/` is the output of `selections_into_equation_objects_take_them_whole` in +`crates/canvas/tests/links_equations.rs`: the same edits through the editor on the same +page, one revision each. `cold/` is a fresh OneNote 2010 read of it, whose MathML matches +the lab's equation for equation. `tools/test_equation_select.py` checks this without a VM. +Regenerate with `CANVAS_EQUATION_SELECT_EXPORT` set to a new absolute directory while +running the test, then cold-open it with +`tools/native_runner.py OUTPUT COLD --expected-pages 1 --collect-notebook --screenshots`. diff --git a/corpus/equation-select/candidate/Open Notebook.onetoc2 b/corpus/equation-select/candidate/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..89012a8103c7db27f04b03e102d5417cdc4beedf Binary files /dev/null and b/corpus/equation-select/candidate/Open Notebook.onetoc2 differ diff --git a/corpus/equation-select/candidate/links.one b/corpus/equation-select/candidate/links.one new file mode 100644 index 0000000000000000000000000000000000000000..499dffd89ef7f8485eb7399eb029b15a44a67623 Binary files /dev/null and b/corpus/equation-select/candidate/links.one differ diff --git a/corpus/equation-select/cold/commands.jsonl b/corpus/equation-select/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..e43eb6693521ec8318c9a26b1b633e87dc8b2895 --- /dev/null +++ b/corpus/equation-select/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-D9D5B6CC -ExpectedPages 1 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} diff --git a/corpus/equation-select/cold/machine.json b/corpus/equation-select/cold/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..91ec61b52cc5e96db4f3d44ccd4a2e5a73851002 --- /dev/null +++ b/corpus/equation-select/cold/machine.json @@ -0,0 +1 @@ +{"name": "m6-d9d5b6cc", "hostname": "ONE-M6-D9D5B6CC"} diff --git a/corpus/equation-select/cold/notebook/Open Notebook.onetoc2 b/corpus/equation-select/cold/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..6e236c4988a6c09f92becec0b688fdc01de35655 Binary files /dev/null and b/corpus/equation-select/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/equation-select/cold/notebook/links.one b/corpus/equation-select/cold/notebook/links.one new file mode 100644 index 0000000000000000000000000000000000000000..f69a73aecc5830f99ad186d192a50833e42fd301 Binary files /dev/null and b/corpus/equation-select/cold/notebook/links.one differ diff --git a/corpus/equation-select/cold/read/environment.json b/corpus/equation-select/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..8e1670add3e555669117a511fad04ad053c1f9c4 --- /dev/null +++ b/corpus/equation-select/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-D9D5B6CC", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/equation-select/cold/read/hierarchy.xml b/corpus/equation-select/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..8f42f86578e50cae5477044813fedbecb621fdc3 --- /dev/null +++ b/corpus/equation-select/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/equation-select/cold/read/page-000.navigation.json b/corpus/equation-select/cold/read/page-000.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..83fb4df3362bb2ac7e9fa657fe6db76614b54e31 --- /dev/null +++ b/corpus/equation-select/cold/read/page-000.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Read about Rust the Rust site - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/equation-select/cold/read/page-000.png b/corpus/equation-select/cold/read/page-000.png new file mode 100644 index 0000000000000000000000000000000000000000..185c6752d935f6f0f488fe86a26af5c7518c409a Binary files /dev/null and b/corpus/equation-select/cold/read/page-000.png differ diff --git a/corpus/equation-select/cold/read/page-000.xml b/corpus/equation-select/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..5430f691678271143e6d97af31a84bee29a84c54 --- /dev/null +++ b/corpus/equation-select/cold/read/page-000.xml @@ -0,0 +1,5 @@ + +the Rust site]]>a1∫01x dx ]]>xn]]>ab]]>∏k=1nk ]]>a+b]]>x¯]]>x^]]>1234]]>x=1y=2]]>x2]]>x2]]>x]]>x]]>fxx2+1]]>∑nx ]]>ex+1]]>𝛼+𝛽]]>a+bc+d]]>x2+y2=z2]]>limn→∞⁡an]]>a+b]]>One four]]> diff --git a/corpus/equation-select/cold/read/payloads.json b/corpus/equation-select/cold/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/equation-select/cold/read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/equation-select/cold/run.json b/corpus/equation-select/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..f2bf1cb2aecf2b9d716003bc7c09f477b5cd7e3a --- /dev/null +++ b/corpus/equation-select/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/eqlab/candidate", + "expected_pages": 1, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35" + } +} diff --git a/corpus/equation-select/cold/scripts/cold.ps1 b/corpus/equation-select/cold/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/equation-select/cold/scripts/cold.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } +$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' +if ($CloneHost) { + if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { + throw 'The disposable clone hostname does not match this machine.' + } + New-Item "$key\Options\Paths" -Force | Out-Null + New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null +} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or + -not (Test-Path 'C:\one-tests\profile-original-cache')) { + throw 'Park the personal OneNote profile before resetting the test cache.' +} +$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' +$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) +if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } +New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null +if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } +if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } +New-Item "$key\OpenNotebooks" | Out-Null +New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null diff --git a/corpus/equation-select/cold/scripts/read.ps1 b/corpus/equation-select/cold/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a42366b32e8baaf13dae92e9472cae7a071220cc --- /dev/null +++ b/corpus/equation-select/cold/scripts/read.ps1 @@ -0,0 +1,144 @@ +param( + [Parameter(Mandatory=$true)][string]$Root, + [int]$ExpectedPages = -1, + [switch]$UseCurrentCache, + [switch]$Pdf, + [switch]$KeepOpen, + [string]$CloneHost = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +$notebook = Join-Path $root 'notebook' +$output = Join-Path $root 'read' +if (Test-Path $output) { throw 'Choose a new read destination.' } +if ($UseCurrentCache) { + if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { + throw 'Park the personal OneNote profile before reading test notebooks.' + } +} else { + & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost +} +New-Item -ItemType Directory -Path $output | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +$failure = $null +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $process = Get-Process ONENOTE + @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; + powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | + ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 + $sections = @() + foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { + $id = '' + $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) + $sections += $id + } + $deadline = [DateTime]::UtcNow.AddSeconds(300) + $previous = '' + $lastChange = '' + $stableSince = [DateTime]::UtcNow + $settled = $false + do { + $pages = @{} + foreach ($section in $sections) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$xml = $hierarchy + foreach ($node in $xml.SelectNodes('//*[@path]')) { + if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { + throw 'OneNote opened a section outside the copied notebook.' + } + } + foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { + $id = $node.GetAttribute('ID') + $content = '' + $app.GetPageContent($id, [ref]$content, 1, 1) + $pages[$id] = $content + } + } + $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) + if ($signature -ne $previous) { + $lastChange = $previous + $previous = $signature + $stableSince = [DateTime]::UtcNow + } + if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or + ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and + ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { + [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) + $index = 0 + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + $index++ + } + $hierarchy = '' + $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) + throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." + } + $index = 0 + $payloads = @() + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + if ($Pdf) { + $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) + $app.NavigateTo($id, '', $false) + $app.Publish($id, $pdfPath, 3, '') + if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } + } + [xml]$page = $pages[$id] + foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { + $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) + # A paragraph's file is named by its paragraph, one on the page by itself. + $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode } + $payloads += @{ page = $id; object = $owner.GetAttribute('objectID'); + kind = $file.LocalName; name = $file.GetAttribute('preferredName'); + sha256 = $hash; bytes = $bytes.Length } + } + $index++ + } + [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) + $all = '' + $app.GetHierarchy($notebookId, 4, [ref]$all, 1) + [xml]$finalTree = $all + $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) + if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { + throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' + } + [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) + Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." +} catch { + $failure = $_ + [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + throw +} finally { + try { + try { + if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } + if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { + $app.CloseNotebook($notebookId, $false) + } + } catch { + if ($null -eq $failure) { throw } + [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + } + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + if (-not $UseCurrentCache -and -not $CloneHost) { + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + } +} diff --git a/corpus/equation-select/cold/source.json b/corpus/equation-select/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..53e25e1f3cf80609f44a3d4904ef11986ad4e1da --- /dev/null +++ b/corpus/equation-select/cold/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 1604, + "sha256": "90206bfbbba99bcebfd2d4896c458dbca5d83120f6649b11abb885730fdb69b9", + "mtime_ns": 1790805000764803414 + }, + { + "path": "links.one", + "bytes": 46824, + "sha256": "2e6bfeb5f7eded840d97512230cf18bb67924d4e6d28484cf957e9882eaedae1", + "mtime_ns": 1790805000764567872 + } +] diff --git a/corpus/equation-select/cold/teardown.json b/corpus/equation-select/cold/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/equation-select/cold/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/equation-select/lab/after.xml b/corpus/equation-select/lab/after.xml new file mode 100644 index 0000000000000000000000000000000000000000..70a4b86d7cbd59d3bde5b5895e76ee605eb6f78e --- /dev/null +++ b/corpus/equation-select/lab/after.xml @@ -0,0 +1,5 @@ + +the Rust site]]>a1∫01x dx ]]>xn]]>ab]]>∏k=1nk ]]>a+b]]>x¯]]>x^]]>1234]]>x=1y=2]]>x2]]>x2]]>x]]>x]]>fxx2+1]]>∑nx ]]>ex+1]]>𝛼+𝛽]]>a+bc+d]]>x2+y2=z2]]>limn→∞⁡an]]>a+b]]>One four]]>x]]> diff --git a/corpus/equation-select/lab/before.xml b/corpus/equation-select/lab/before.xml new file mode 100644 index 0000000000000000000000000000000000000000..44ab03a193fc06752be15f08eaf679ac7436e75d --- /dev/null +++ b/corpus/equation-select/lab/before.xml @@ -0,0 +1,3 @@ + +the Rust site]]>a1+b2]]>Type equation here.]]>∑i=1ni ]]>∫01x dx ]]>x+1 ]]>x3]]>xn]]>ab]]>∏k=1nk ]]>a+b]]>x¯]]>x^]]>1234]]>x=1y=2]]>x2]]>x2]]>x]]>x]]>fxx2+1]]>∑nx ]]>ex+1]]>𝛼+𝛽]]>a+bc+d]]>x2+y2=z2]]>limn→∞⁡an]]>a+b]]> diff --git a/corpus/equation-select/lab/limit-enter.xml b/corpus/equation-select/lab/limit-enter.xml new file mode 100644 index 0000000000000000000000000000000000000000..725b6a2d1931aa1a38f2f465a23db83206afed09 --- /dev/null +++ b/corpus/equation-select/lab/limit-enter.xml @@ -0,0 +1,5 @@ + +the Rust site]]>a1∫01x dx ]]>xn]]>ab]]>∏k=1nk ]]>a+b]]>x¯]]>x^]]>1234]]>x=1y=2]]>x2]]>x2]]>x]]>x]]>fxx2+1]]>∑nx ]]>ex+1]]>𝛼+𝛽]]>a+bc+d]]>x2+y2=z2]]>limn→∞⁡an]]>a+b]]>One four]]> diff --git a/corpus/equation-select/lab/placeholder-enter.xml b/corpus/equation-select/lab/placeholder-enter.xml new file mode 100644 index 0000000000000000000000000000000000000000..825e5df9e0acd27edc87c995c64b2fea8f402c48 --- /dev/null +++ b/corpus/equation-select/lab/placeholder-enter.xml @@ -0,0 +1,5 @@ + +the Rust site]]>a1∫01x dx ]]>xn]]>ab]]>∏k=1nk ]]>a+b]]>x¯]]>x^]]>1234]]>x=1y=2]]>x2]]>x2]]>x]]>x]]>fxx2+1]]>∑nx ]]>ex+1]]>𝛼+𝛽]]>a+bc+d]]>x2+y2=z2]]>limn→∞⁡an]]>a+b]]>One four]]> diff --git a/corpus/equation-select/lab/roots-deleted.xml b/corpus/equation-select/lab/roots-deleted.xml new file mode 100644 index 0000000000000000000000000000000000000000..a97fb26f4821217994b4734d3cee2767c856881b --- /dev/null +++ b/corpus/equation-select/lab/roots-deleted.xml @@ -0,0 +1,3 @@ + +the Rust site]]>a1+b2]]>Type equation here.]]>∑i=1ni ]]>∫01x dx ]]>xn]]>ab]]>∏k=1nk ]]>a+b]]>x¯]]>x^]]>1234]]>x=1y=2]]>x2]]>x2]]>x]]>x]]>fxx2+1]]>∑nx ]]>ex+1]]>𝛼+𝛽]]>a+bc+d]]>x2+y2=z2]]>limn→∞⁡an]]>a+b]]> diff --git a/corpus/print/README.md b/corpus/print/README.md new file mode 100644 index 0000000000000000000000000000000000000000..719d2d58d9b650d671f30a7b689a36f0ddbfc764 --- /dev/null +++ b/corpus/print/README.md @@ -0,0 +1,33 @@ +# Print + +`native/Print.one` is a section the COM API made in OneNote 2010: "Printing test", a +ruled page (horizontal lines every 0.2 points, as OneNote reads the XML's spacing, and a +margin line) with a 70-paragraph outline running past the first sheet, a picture, an +outline near the first sheet's foot and one reaching 867 points across; "Second page"; +and "Wide 1200" and "Wide 560", titled pages whose one word sits that far across. +`native/section.pdf` is OneNote's File, Save As, PDF of the section, the same sheets File, +Print prints. `native/rules-section.pdf` is its PDF of `page-background/candidate`. + +What they show, on a 612.36 by 790.92 point Letter sheet: + +- The page prints between 36-point top and bottom margins, the margin origin at (72, 36). + Content reaching past the paper's right edge shrinks the whole page to fit it with 9 + points to spare: "Printing test" prints at 0.672, "Wide 560" at 0.978, "Wide 1200" at + 0.484. +- A sheet ends before the first line it would cut: the first holds paragraphs 1 to 37 and + the second starts at paragraph 38. Content below that on the first sheet waits for the + next. +- Rule lines print across the paper's whole width, down to the bottom margin of every + sheet. The page colour does not print. Template art does, and a page longer than a sheet + only through its art ("VeryLargeGrid") takes a second sheet. +- The footer, "Print Page 1", is the section's name and the sheet's number counted across + the whole printout, in 10-point Times New Roman centred near the paper's bottom edge; it + shrinks with the page towards the bottom left. No header prints; the title, date and + time are the page's own. + +Print Preview and Settings offers Print range (Current Page, Page Group, Current Section), +Paper size, Scale content to paper width (on), Orientation, Footer (section and page +number, page number, section, none) and Start page numbering at 1. Save As, PDF offers +Selected Pages, Current Section and Current Notebook. + +`crates/canvas/src/print.rs` checks Snowbound's sheets against these without a VM. diff --git a/corpus/print/native/Print.one b/corpus/print/native/Print.one new file mode 100644 index 0000000000000000000000000000000000000000..2288f4f00964171131c73248afb794d00923b9c9 Binary files /dev/null and b/corpus/print/native/Print.one differ diff --git a/corpus/print/native/rules-section.pdf b/corpus/print/native/rules-section.pdf new file mode 100644 index 0000000000000000000000000000000000000000..410af674027acfb2e1ef522f91dd55f6b06a5a5d Binary files /dev/null and b/corpus/print/native/rules-section.pdf differ diff --git a/corpus/print/native/section.pdf b/corpus/print/native/section.pdf new file mode 100644 index 0000000000000000000000000000000000000000..3b461b4717ee33c4c17d27791f17915abcb3ab96 Binary files /dev/null and b/corpus/print/native/section.pdf differ diff --git a/crates/canvas/Cargo.toml b/crates/canvas/Cargo.toml index d83605e92f3122f8d2820759c5d89655f6a83ae7..d96a8c6f405146d8a63bf188521aeca85e63664b 100644 --- a/crates/canvas/Cargo.toml +++ b/crates/canvas/Cargo.toml @@ -8,6 +8,8 @@ publish = false gpu = ["draw/render", "dep:color_quant", "dep:image", "dep:png", "dep:resvg", "dep:sha2"] # How a page responds to input and presents itself to assistive technology. interaction = ["gpu", "dep:accesskit"] +# Pages printed as PDF, as OneNote prints them. +pdf = ["interaction", "draw/pdf"] [dependencies] draw = { path = "../draw", default-features = false } @@ -35,6 +37,7 @@ path = "src/bin/page_probe.rs" [dev-dependencies] draw = { path = "../draw", features = ["wgpu"] } +miniz_oxide = "0.8" accesskit_consumer = "0.39.0" pollster = "0.4" wgpu.workspace = true @@ -42,3 +45,7 @@ wgpu.workspace = true [[example]] name = "render_page" required-features = ["gpu"] + +[[example]] +name = "print_pdf" +required-features = ["pdf"] diff --git a/crates/canvas/examples/print_pdf.rs b/crates/canvas/examples/print_pdf.rs new file mode 100644 index 0000000000000000000000000000000000000000..a2615124b8b16e09fdfc558c48d7ed16c3208190 --- /dev/null +++ b/crates/canvas/examples/print_pdf.rs @@ -0,0 +1,40 @@ +//! Writes a section's pages, or one titled page, as the PDF Print and Export as PDF make. +//! `print_pdf SECTION.one NEW.pdf [TITLE] [--a4]` + +use canvas::{layout::TextEngine, print}; +use onestore::{RevisionIndex, Store, document::Document, page::Page}; +use std::{env, fs, path::Path}; + +fn main() -> Result<(), Box> { + let args: Vec = env::args().skip(1).collect(); + let [section, destination, rest @ ..] = args.as_slice() else { + return Err("Expected a section, a new PDF path and optionally a page title".into()); + }; + let a4 = rest.iter().any(|arg| arg == "--a4"); + let title = rest.iter().find(|arg| *arg != "--a4"); + let bytes = fs::read(section)?; + let store = Store::parse(&bytes)?; + let index = RevisionIndex::parse(&store)?; + let document = Document::parse(&index)?; + let mut spaces: Vec<_> = document + .pages()? + .into_iter() + .map(|(space, _)| space) + .collect(); + spaces.dedup(); + let pages = spaces + .into_iter() + .map(|space| Page::from_space(&document, space)) + .collect::, _>>()? + .into_iter() + .filter(|page| title.is_none_or(|title| page.title == *title)) + .collect(); + let name = Path::new(section) + .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)?; + fs::write(destination, pdf)?; + Ok(()) +} diff --git a/crates/canvas/examples/scratch_eqref.rs b/crates/canvas/examples/scratch_eqref.rs new file mode 100644 index 0000000000000000000000000000000000000000..9f5aab70bf7be18cbc32f13a60e08fa50a84b333 --- /dev/null +++ b/crates/canvas/examples/scratch_eqref.rs @@ -0,0 +1,667 @@ +//! 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 new file mode 100644 index 0000000000000000000000000000000000000000..6c458d6a182c00b8e003cf3e211a47fd58620915 --- /dev/null +++ b/crates/canvas/examples/scratch_files.rs @@ -0,0 +1,15 @@ +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 new file mode 100644 index 0000000000000000000000000000000000000000..33b31e9ebba3048c62ffe441927688290c8187e5 --- /dev/null +++ b/crates/canvas/examples/scratch_runs.rs @@ -0,0 +1,23 @@ +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/src/editor.rs b/crates/canvas/src/editor.rs index e58c4651dbde740ddb90c48f358d46910c3811d5..e99e394f84450d108456ec3dac63641cc8b926b9 100644 --- a/crates/canvas/src/editor.rs +++ b/crates/canvas/src/editor.rs @@ -2178,39 +2178,29 @@ impl CanvasEditor { Ok(true) } - /// Puts `image` in the active outline's flow after the caret's paragraph, as OneNote - /// inserts a picture into an outline; the caret stays. + /// 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. pub fn insert_picture( &mut self, engine: &mut TextEngine, - image: onestore::page::Image, + mut image: onestore::page::Image, ) -> Result<(), EditorError> { - let selection = self.selection(); - let caret = selection.positions[0].max(selection.positions[1]); - let (container, index, node) = self - .active_outline() - .document - .leaf(caret.paragraph) - .ok_or(EditError::InvalidRange)?; - let picture = PageParagraph { - id: onestore::page::text::new_id()?, - content: ParagraphContent::Image(image), - style: None, - lists: Vec::new(), - tags: Vec::new(), - collapsed: false, - ..node.clone() + let Focus::Caret { outline, .. } = &self.active else { + return self.insert_in_flow(engine, ParagraphContent::Image(image)); }; - self.commit( - engine, - DocumentEdit { - columns: BTreeMap::new(), - container, - range: index + 1..index + 1, - replacement: vec![picture], - }, - selection, - ) + let [x, y] = outline.origin(); + [image.layout.x, image.layout.y] = [Some(x), Some(y)]; + image.layout.width_set_by_user = Some(false); + let height = image.layout.max_height.unwrap_or_default(); + self.finish_composition(); + let index = self.objects.len(); + 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) } /// Attaches `file` at the caret as OneNote 2010 does (`corpus/attachment-insert`): the @@ -2241,11 +2231,21 @@ impl CanvasEditor { self.redo.clear(); return Ok(()); } - let file = match self.across(engine, |editor, engine| { - editor.insert_attachment(engine, file.clone()) + self.insert_in_flow(engine, ParagraphContent::Attachment(file)) + } + + /// Splits the caret's paragraph around `content`, which takes the place of an empty first + /// half; the caret starts the second. + fn insert_in_flow( + &mut self, + engine: &mut TextEngine, + content: ParagraphContent, + ) -> Result<(), EditorError> { + let content = match self.across(engine, |editor, engine| { + editor.insert_in_flow(engine, content.clone()) })? { Some(()) => return Ok(()), - None => file, + None => content, }; let [anchor, focus] = self.selection().positions; let (start, end) = (anchor.min(focus), anchor.max(focus)); @@ -2260,7 +2260,7 @@ impl CanvasEditor { }; let node = PageParagraph { id: onestore::page::text::new_id()?, - content: ParagraphContent::Attachment(file), + content, style: None, lists: Vec::new(), tags: Vec::new(), @@ -3209,6 +3209,7 @@ impl CanvasEditor { engine: &mut TextEngine, replacement: Vec, ) -> Result<(), EditorError> { + self.take_objects()?; let start = self.active_outline().selection.positions[0] .min(self.active_outline().selection.positions[1]); let end = self.active_outline().selection.positions[0] @@ -3308,6 +3309,7 @@ impl CanvasEditor { editor.paste(engine, text, language) }); } + self.take_objects()?; let lines = text .split('\n') .map(|line| line.strip_suffix('\r').unwrap_or(line)) @@ -3569,6 +3571,7 @@ impl CanvasEditor { self.grouped(|editor| editor.remove_page(engine, false))?; return Ok(true); } + self.take_objects()?; let selection = self.active_outline().selection; let [anchor, focus] = selection.positions; let mut range = anchor.min(focus)..anchor.max(focus); diff --git a/crates/canvas/src/editor/equation.rs b/crates/canvas/src/editor/equation.rs index 57b55a8cddb86b51d06ad84623b1114a07f18fb7..d76775c96e8565819d09180990165e5eeb9cffe2 100644 --- a/crates/canvas/src/editor/equation.rs +++ b/crates/canvas/src/editor/equation.rs @@ -36,6 +36,107 @@ pub(crate) fn zone(text: &Paragraph, offset: u32) -> Option> { current.filter(|zone| zone.start <= offset && offset <= zone.end) } +/// An object of an equation holding a position: its range, from its opening control to past +/// its closing one, and the argument the position lies in. +#[derive(PartialEq)] +struct Holder { + range: Range, + argument: usize, + placeholder: bool, +} + +/// The objects of `text` holding UTF-16 `offset` strictly inside, outermost first. +fn holders(text: &Paragraph, offset: u32) -> Vec { + // Each object's range and whether it is a placeholder; open objects and held ones by + // index, with the argument reached. + let mut objects: Vec<(Range, bool)> = Vec::new(); + let (mut open, mut held) = (Vec::new(), None); + let mut unit = 0; + for c in text.text().chars() { + if unit == offset { + held = Some(open.clone()); + } + match c { + OBJECT_START => { + // An equation's empty placeholder, "Type equation here.", is a box OneNote + // marks with this symbol. + let placeholder = text.format_at(unit + 1).is_ok_and(|format| { + format + .math_object + .as_ref() + .is_some_and(|object| object.kind == 11 && object.symbols == ['⬚']) + }); + open.push((objects.len(), 0)); + objects.push((unit..unit, placeholder)); + } + ARGUMENT_SEPARATOR => { + if let Some((_, argument)) = open.last_mut() { + *argument += 1; + } + } + OBJECT_END => { + if let Some((index, _)) = open.pop() { + objects[index].0.end = unit + 1; + } + } + _ => {} + } + unit += c.len_utf16() as u32; + } + held.unwrap_or(open) + .into_iter() + .map(|(index, argument)| Holder { + range: objects[index].0.clone(), + argument, + placeholder: objects[index].1, + }) + .collect() +} + +/// `range` as OneNote 2010's equation editor selects it (`corpus/equation-select`): an end +/// inside an object not holding the other end in the same argument takes the whole object, as +/// does an end inside a placeholder, and across paragraphs an object so taken at the end of +/// its paragraph takes the paragraph's end with it. +pub(crate) fn widen( + document: &TextDocument, + range: Range, +) -> Result, EditError> { + let text = |paragraph| document.paragraph(paragraph).ok_or(EditError::InvalidRange); + let last = text(range.end.paragraph)?; + let starts = holders(text(range.start.paragraph)?, range.start.offset); + let ends = holders(last, range.end.offset); + let common = if range.start.paragraph == range.end.paragraph { + starts.iter().zip(&ends).take_while(|(a, b)| a == b).count() + } else { + 0 + }; + let level = |holders: &[Holder]| { + holders + .iter() + .position(|holder| holder.placeholder) + .map_or(common, |placeholder| placeholder.min(common)) + }; + let mut widened = range.clone(); + if let Some(holder) = starts.get(level(&starts)) { + widened.start.offset = holder.range.start; + } + if let Some(holder) = ends.get(level(&ends)) { + widened.end.offset = holder.range.end; + let next = range.end.paragraph + 1; + let container = |paragraph| document.leaf(paragraph).map(|(container, ..)| container); + if range.start.paragraph != range.end.paragraph + && widened.end.offset == last.utf16_offset(last.text().len())? + && container(next).is_some_and(|next| Some(next) == container(range.end.paragraph)) + { + widened.end = TextPosition { + paragraph: next, + offset: 0, + }; + } + } + Ok(widened) +} + /// The format text typed after an equation in `text` takes: the paragraph's last text's, or /// its first run's without the equation editor's. pub(super) fn text_after(text: &Paragraph) -> Format { @@ -70,6 +171,20 @@ impl CanvasEditor { Some((caret.paragraph, zone(text, caret.offset)?)) } + /// Widens the selection an edit is about to replace as the equation editor selects. + pub(super) fn take_objects(&mut self) -> Result<(), EditError> { + let [anchor, focus] = self.active_outline().selection.positions; + let range = anchor.min(focus)..anchor.max(focus); + let widened = widen(&self.active_outline().document, range.clone())?; + if widened != range { + self.active_outline_mut().selection = Selection { + positions: [widened.start, widened.end], + affinities: [Affinity::Downstream, Affinity::Upstream], + }; + } + Ok(()) + } + /// Whether the caret is in an equation, which Linear and Professional act on. pub fn in_equation(&self) -> bool { self.caret_zone().is_some() @@ -373,13 +488,10 @@ impl CanvasEditor { for part in parts { broken.append(part)?; } - let Some(linear) = Math::parse(&broken) - .ok() - .and_then(|nodes| Math::linear(&nodes)) - else { + let Ok(nodes) = Math::parse(&broken) else { return Ok(false); }; - let built = Math::paragraph(&Math::from_linear(&linear), &math.spans()[0].format); + let built = Math::paragraph(&nodes, &math.spans()[0].format); // The caret starts the new row: after as many characters other than spaces as came // before it. let before = broken diff --git a/crates/canvas/src/editor/table.rs b/crates/canvas/src/editor/table.rs index bfaf7e654e54211ab74c6d7a4998d0ec74b36325..54bdaed6ce298322c513d0ed0510601196f002e2 100644 --- a/crates/canvas/src/editor/table.rs +++ b/crates/canvas/src/editor/table.rs @@ -398,6 +398,7 @@ impl CanvasEditor { if soft { return self.insert(engine, "\u{000b}"); } + self.take_objects()?; if self.active_outline().title { return self.leave_title(engine); } diff --git a/crates/canvas/src/gpu/mod.rs b/crates/canvas/src/gpu/mod.rs index 8a3e7579f7f85a85dda0e737e224e533d31e67e3..5bce91c1880590db8e3b5b83f9e5fba175b22f6c 100644 --- a/crates/canvas/src/gpu/mod.rs +++ b/crates/canvas/src/gpu/mod.rs @@ -110,6 +110,7 @@ impl Glyphs for TextLayout { }; draw::paint_parley_run( &run, + &self.text, bounds.baseline, run.style().brush.rise, [bounds.top, bounds.height], diff --git a/crates/canvas/src/gpu/page.rs b/crates/canvas/src/gpu/page.rs index 72952fc0b0caaa3d635135fbdef07b760bd49749..dcbef897c20d30ccf14cb64e54bf1526b6bab70a 100644 --- a/crates/canvas/src/gpu/page.rs +++ b/crates/canvas/src/gpu/page.rs @@ -766,6 +766,91 @@ impl PageScene { None } + /// Where the page read without an editor lies, in page coordinates: the rectangle all + /// its content covers, and the top and bottom of each line of text and each picture, which + /// a sheet of paper does not cut through where it can help it. + pub(crate) fn printed_extent(&self) -> Result<([f32; 4], Vec<[f32; 2]>), SceneError> { + let mut bounds = [ + f32::INFINITY, + f32::INFINITY, + f32::NEG_INFINITY, + f32::NEG_INFINITY, + ]; + let mut rows = Vec::new(); + let mut cover = |[x0, y0, x1, y1]: [f32; 4], row: bool| { + bounds = [ + bounds[0].min(x0), + bounds[1].min(y0), + bounds[2].max(x1), + bounds[3].max(y1), + ]; + if row { + rows.push([y0, y1]); + } + }; + for content in self.objects(None)? { + match content { + Content::Outline { + source, + layout, + below_title, + } => { + let [x, y] = outline_origin( + [ + source.layout.x.unwrap_or(0.0), + source.layout.y.unwrap_or(0.0), + ], + *below_title, + None, + )?; + cover([x, y, x + layout.size[0], y + layout.size[1]], false); + for paragraph in &layout.paragraphs { + let [px, py] = [x + paragraph.origin[0], y + paragraph.origin[1]]; + for (line, bounds) in paragraph.text.lines() { + let top = py + bounds.top; + let right = px + line.metrics().advance; + cover([px, top, right, top + bounds.height], true); + } + } + for object in &layout.objects { + let [x0, y0, x1, y1] = object.rect; + cover([x + x0, y + y0, x + x1, y + object.bottom.max(y1)], true); + } + } + Content::Image(source) => { + let [x, y] = [ + source.layout.x.unwrap_or(0.0), + source.layout.y.unwrap_or(0.0), + ]; + 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]), + }; + if let Some([width, height]) = size { + cover([x, y, x + width, y + height], !source.background); + } + } + Content::File { .. } => { + if let Some((_, rect)) = content.file() { + cover(rect, true); + } + } + Content::Ink(ink) => { + if let Some(rect) = crate::editor::page::ink_bounds(ink) { + cover(rect, false); + } + } + Content::ReadOnly(object) => cover(object.rect(), true), + Content::Date { .. } | Content::Editable(_) => {} + } + } + Ok((bounds, rows)) + } + /// The offset is in document points. pub fn append_primitives<'a>( &'a self, diff --git a/crates/canvas/src/interaction/ink.rs b/crates/canvas/src/interaction/ink.rs index 706a37c19cd33eca883d34fb31386c1cd1464017..ec8d621dfbd1fa456892bb6bcbc9365bd50b2499 100644 --- a/crates/canvas/src/interaction/ink.rs +++ b/crates/canvas/src/interaction/ink.rs @@ -2,7 +2,7 @@ //! dragged out on the grid, the stroke eraser, and the lasso whose drawings move and delete, //! as OneNote 2010 has them (`corpus/ink-tools`). -use super::{PageView, Response, Result, snap_to_grid}; +use super::{PageView, Response, Result}; use crate::editor::{FAVORITES, Pen}; use crate::gpu::colorref; use draw::{ @@ -184,14 +184,6 @@ impl PageView { Ok(Some(self.changed()?)) } - fn grid(&self, point: [f32; 2]) -> [f32; 2] { - if self.modifiers.option { - point - } else { - snap_to_grid(point, self.editor.margin_origin()) - } - } - /// The pointer moved to page point `point` during a gesture. pub(super) fn ink_moved(&mut self, point: [f32; 2]) -> Result { let pressure = self.ink.pressure; diff --git a/crates/canvas/src/interaction/mod.rs b/crates/canvas/src/interaction/mod.rs index 5ce5301ccc8a135d98d4a080a4fc8b8fbde34b20..7593d0281772abb4c6d65be2ed42fe7764e41383 100644 --- a/crates/canvas/src/interaction/mod.rs +++ b/crates/canvas/src/interaction/mod.rs @@ -258,6 +258,9 @@ pub struct PageView { /// A platform scroll view owns the viewport: changes neither clamp it nor reveal the /// caret, which the host does knowing its bars and keyboard. pub host_viewport: bool, + /// OneNote's Snap to Grid: clicks, drags and shapes land on the placement grid unless + /// Option (Alt) is held. Off, they land where the pointer is. + pub snap_to_grid: bool, /// Matches of the search shown on the page, marked under their text. pub found: Vec, /// The note playing, highlighted as See Playback highlights it. @@ -271,6 +274,9 @@ pub struct PageView { /// When the caret last moved, which restarts its blink. blink_from: Instant, ink: ink::State, + /// What the view showed before each zoom since the page opened, in document points: + /// scrolling still reaches it, so a zoom keeps the point under the pointer in place. + reach: Option<[f32; 4]>, } /// Room, in OneNote pixels, the view leaves beyond content it scrolls to. @@ -326,6 +332,7 @@ impl PageView { focused: true, touch: false, host_viewport: false, + snap_to_grid: true, found: Vec::new(), played: None, spelling: None, @@ -333,6 +340,7 @@ impl PageView { caret: 1.0, blink_from: Instant::now(), ink: Default::default(), + reach: None, }; view.place_opened(); view @@ -376,6 +384,7 @@ impl PageView { place: Option, ) { self.replace(editor, scene); + self.reach = None; let Some(place) = place else { return self.place_opened(); }; @@ -461,14 +470,7 @@ impl PageView { } let point = self.viewport.document_point(self.pointer); let position = [point[0] - grab[0], point[1] - grab[1]]; - Some(( - *id, - if self.modifiers.option { - position - } else { - snap_to_grid(position, self.editor.margin_origin()) - }, - )) + Some((*id, self.grid(position))) } /// The dragged outline and how far it would move. @@ -505,12 +507,16 @@ impl PageView { return Some((*id, origin, size)); } let origin = [origin[0] + delta[0], origin[1] + delta[1]]; - let origin = if self.modifiers.option { - origin + Some((*id, self.grid(origin), size)) + } + + /// `point` on the placement grid, where Snap to Grid is on and Option isn't held. + fn grid(&self, point: [f32; 2]) -> [f32; 2] { + if self.snap_to_grid && !self.modifiers.option { + snap_to_grid(point, self.editor.margin_origin()) } else { - snap_to_grid(origin, self.editor.margin_origin()) - }; - Some((*id, origin, size)) + point + } } fn pixel(&self) -> f32 { @@ -786,14 +792,27 @@ impl PageView { self.moved() } - /// Scales the view by `factor` about the pointer, as a trackpad pinch does. - pub fn pinch(&mut self, factor: f32) -> Result { - self.zoom_about(factor, self.pointer); + /// Scales the view by `factor` about `anchor`, in device pixels from the view's + /// top-left, as a trackpad pinch does about the pointer. + pub fn pinch(&mut self, factor: f32, anchor: [f32; 2]) -> Result { + self.zoom_about(factor, anchor); self.moved() } /// Scales the view by `factor`, keeping the document point under `anchor` in place. fn zoom_about(&mut self, factor: f32, anchor: [f32; 2]) { + let [x0, y0] = self.viewport.document_point([0.0; 2]); + let [x1, y1] = self + .viewport + .document_point(self.viewport.size.map(|size| size as f32)); + self.reach = Some(self.reach.map_or([x0, y0, x1, y1], |reach| { + [ + reach[0].min(x0), + reach[1].min(y0), + reach[2].max(x1), + reach[3].max(y1), + ] + })); let point = self.viewport.document_point(anchor); let dpr = self.display_scale; self.viewport.scale = (self.viewport.scale * factor).clamp(dpr / 3.0, dpr * 16.0 / 3.0); @@ -841,7 +860,16 @@ impl PageView { ) }) }); - scroll::Scroll::new(self.viewport, editable.map(|rect| (rect, pad)).chain(fixed)) + let mut scroll = + scroll::Scroll::new(self.viewport, editable.map(|rect| (rect, pad)).chain(fixed)); + if let Some(reach) = self.reach { + for axis in 0..2 { + scroll.min[axis] = scroll.min[axis].min(reach[axis] * self.viewport.scale); + scroll.max[axis] = scroll.max[axis] + .max(reach[axis + 2] * self.viewport.scale - self.viewport.size[axis] as f32); + } + } + scroll } /// Scrolls the view's corner `offset` device pixels from the page origin along `axis`, @@ -1263,11 +1291,7 @@ impl PageView { /// A fresh click places text 7 px above the pointer, on the grid. fn place_caret(&mut self, point: [f32; 2]) -> Result<()> { let position = [point[0], point[1] - 7.0 * self.pixel()]; - let position = if self.modifiers.option { - position - } else { - snap_to_grid(position, self.editor.margin_origin()) - }; + let position = self.grid(position); self.editor .place_caret(&mut self.engine, position, DEFAULT_OUTLINE_WIDTH)?; Ok(()) @@ -1662,7 +1686,8 @@ impl PageView { self.edited() } - /// Insert, Picture: a PNG, JPEG or GIF `size` points large after the caret's paragraph. + /// Insert, Picture, or a pasted one: a PNG, JPEG or GIF `size` points large; see + /// [`CanvasEditor::insert_picture`]. pub fn insert_picture(&mut self, bytes: Vec, size: [f32; 2]) -> Result { if !self.accepts_text() || self.editor.marked_range().is_some() { return Ok(Response::default()); @@ -1682,6 +1707,7 @@ impl PageView { printout: None, }; self.editor.insert_picture(&mut self.engine, image)?; + self.follow_pictures()?; self.edited() } @@ -2269,7 +2295,7 @@ fn page_hit( /// The page's rule lines over `visible`, a document rectangle, as OneNote 2010 draws them: /// 1/96 inch wide, or a device pixel where that is wider, horizontal lines from the margin /// origin down, and a grid's vertical lines through it or a margin line 1/96 inch left of it. -fn rule_primitives( +pub(crate) fn rule_primitives( lines: Option, [x, y]: [f32; 2], [left, top, right, bottom]: [f32; 4], diff --git a/crates/canvas/src/interaction/tests.rs b/crates/canvas/src/interaction/tests.rs index e33c5f3c08083621e588a55dca172bb8a116ab15..7d0e57ba3ed8231938cc7104d0150757ce26f2b6 100644 --- a/crates/canvas/src/interaction/tests.rs +++ b/crates/canvas/src/interaction/tests.rs @@ -1753,6 +1753,12 @@ fn events_route_through_the_view_as_the_host_delivers_them() { click(&mut view, [400.0, 300.0], now + Duration::from_secs(3)); let caret = view.editor.caret_outline().unwrap().origin(); assert_eq!(caret, snap_to_grid([400.0, 293.0], [36.0, 14.4])); + + // With Snap to Grid off it lands where the pointer is, as OneNote 2010 places it. + view.snap_to_grid = false; + click(&mut view, [410.0, 330.0], now + Duration::from_secs(5)); + let caret = view.editor.caret_outline().unwrap().origin(); + assert_eq!(caret, [410.0, 323.0]); } #[test] @@ -2471,6 +2477,8 @@ fn the_page_selection_does_not_return_with_its_text_selection() { assert_eq!(view.editor.whole(), None); } +/// The point under the pointer stays put through a pinch, as in Safari, even where the +/// page's content is too small to scroll that far; scrolling afterwards still reaches it. #[test] fn pinch_zooms_about_the_pointer_within_range() { let mut engine = TextEngine::default(); @@ -2488,17 +2496,28 @@ fn pinch_zooms_about_the_pointer_within_range() { 1.0, Duration::from_millis(500), ); - // The host's own scrolling leaves the anchor unclamped, as iOS's does. - view.host_viewport = true; - let pointer = [300.0, 200.0]; - let _ = view.pointer_moved(pointer).unwrap(); + let pointer = [700.0, 500.0]; let under = view.viewport.document_point(pointer); + let shown = view.viewport.document_point([800.0, 600.0]); + let stays = |view: &PageView| { + let after = view.viewport.document_point(pointer); + assert!( + (after[0] - under[0]).abs() < 1e-3 && (after[1] - under[1]).abs() < 1e-3, + "{under:?} moved to {after:?}" + ); + }; let zoom = view.zoom(); - assert!(view.pinch(1.5).unwrap().moved); - assert!((view.zoom() / zoom - 1.5).abs() < 1e-5); - let after = view.viewport.document_point(pointer); - assert!((after[0] - under[0]).abs() < 1e-3 && (after[1] - under[1]).abs() < 1e-3); - let _ = view.pinch(100.0).unwrap(); + for factor in [1.05, 1.0 / 1.05, 1.05] { + for _ in 0..10 { + assert!(view.pinch(factor, pointer).unwrap().moved); + stays(&view); + } + } + assert!((view.zoom() / zoom - 1.05_f32.powi(10)).abs() < 1e-4); + let _ = view.wheel([-5000.0; 2]).unwrap(); + let corner = view.viewport.document_point([800.0, 600.0]); + assert!((corner[0] - shown[0]).abs() < 1e-3 && (corner[1] - shown[1]).abs() < 1e-3); + let _ = view.pinch(100.0, pointer).unwrap(); assert!((view.zoom() - 4.0).abs() < 1e-5); } diff --git a/crates/canvas/src/layout.rs b/crates/canvas/src/layout.rs index 3f6f40114280f49b46e608687695dee5652ae0c6..2bccd6e2fa920c5da1291838b19baa9635f3dbfb 100644 --- a/crates/canvas/src/layout.rs +++ b/crates/canvas/src/layout.rs @@ -61,6 +61,8 @@ pub struct LineBox { pub struct TextLayout { id: u64, pub(crate) shaped: Arc>, + /// The paragraph text laid out, which a PDF's text layer maps glyphs back to. + pub(crate) text: Arc, lines: Vec, /// Where each inline space lies: its index in the spaces laid out, its x, and its line. spaces: Vec<(usize, f32, usize)>, @@ -518,6 +520,7 @@ impl TextEngine { Ok(TextLayout { id, shaped: Arc::new(shaped), + text: text.into(), lines, spaces: placed, }) diff --git a/crates/canvas/src/lib.rs b/crates/canvas/src/lib.rs index 8027f0e2606e64a4e0d3dfaf146810a907fc04c9..de0389776bb173090782f64dc8f19dd22381a50c 100644 --- a/crates/canvas/src/lib.rs +++ b/crates/canvas/src/lib.rs @@ -14,6 +14,8 @@ pub mod date; pub mod gpu; #[cfg(feature = "interaction")] pub mod interaction; +#[cfg(feature = "pdf")] +pub mod print; /// Parley's caret affinity mapped onto the page model's hidden-field affinity. pub fn affinity(affinity: parley::Affinity) -> onestore::page::text::Affinity { diff --git a/crates/canvas/src/print.rs b/crates/canvas/src/print.rs new file mode 100644 index 0000000000000000000000000000000000000000..02692d56d346f706170cd6fc2d51001b73872295 --- /dev/null +++ b/crates/canvas/src/print.rs @@ -0,0 +1,553 @@ +//! Pages on sheets of paper as OneNote 2010 prints them and saves them as PDF. +//! +//! OneNote draws a page between half-inch top and bottom margins, the page's margin origin +//! an inch from the paper's left edge and at the top margin, shrinking the whole page +//! 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. + +use crate::gpu::{Paper, page::PageScene}; +use crate::layout::{LayoutError, TextEngine}; +use draw::{Layer, Primitive, RenderError, Sheet}; +use onestore::page::{Page, text::Paragraph}; +use std::fmt; + +pub const LETTER: [f32; 2] = [612.0, 792.0]; +pub const A4: [f32; 2] = [595.276, 841.89]; + +/// The paper's top and bottom margins, where no page content prints. +const MARGIN: f32 = 36.0; +/// How far from the paper's left edge the margin origin prints at full size. +const LEFT: f32 = 72.0; +/// The room OneNote leaves right of the content when it shrinks a page to the paper's width. +const RIGHT: f32 = 9.0; +/// Pixels per point pictures are decoded at, 300 a inch. +const DENSITY: f32 = 300.0 / 72.0; + +/// How a page lies on its sheets. +struct Pagination { + /// Paper points per page point. + scale: f32, + /// The page x at the paper's left edge. + left: f32, + /// Where each sheet starts down the page. + tops: Vec, + /// How much of the page a sheet holds, in page points. + band: f32, +} + +impl Pagination { + /// Lays content covering `bounds`, whose `rows` (lines of text, pictures) no sheet + /// should cut, on `paper` from the page's `margin_origin`. + fn new( + bounds: [f32; 4], + rows: &[[f32; 2]], + margin_origin: [f32; 2], + paper: [f32; 2], + ) -> Self { + let left = margin_origin[0] - LEFT; + let scale = if bounds[2].is_finite() { + (paper[0] / (bounds[2] - left + RIGHT)).min(1.0) + } else { + 1.0 + }; + let band = (paper[1] - 2.0 * MARGIN) / scale; + let first = if bounds[1].is_finite() { + margin_origin[1].min(bounds[1]) + } else { + margin_origin[1] + }; + let bottom = bounds[3].max(first); + let mut tops = vec![first]; + let mut top = first; + while top + band < bottom { + // Moves the cut up to the top of whatever it would cross, until nothing shorter + // than a sheet crosses it. + let mut cut = top + band; + while let Some(row) = rows.iter().find(|[start, end]| { + *start < cut && *end > cut && *start > top + 1.0 && end - start <= band + }) { + cut = row[0]; + } + tops.push(cut); + top = cut; + } + Self { + scale, + left, + tops, + band, + } + } + + /// Each sheet's rows of the page, top and bottom: to where the next sheet starts, or + /// the paper's bottom margin on the last. + fn sheets(&self) -> impl Iterator + '_ { + self.tops.iter().enumerate().map(|(at, top)| { + [ + *top, + self.tops.get(at + 1).copied().unwrap_or(top + self.band), + ] + }) + } +} + +#[derive(Debug)] +pub enum PrintError { + Scene(crate::gpu::page::SceneError), + Layout(LayoutError), + Render(RenderError), +} + +impl fmt::Display for PrintError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Scene(error) => write!(formatter, "The page could not be laid out: {error}"), + Self::Layout(error) => write!(formatter, "The footer could not be laid out: {error:?}"), + Self::Render(error) => write!(formatter, "The PDF could not be written: {error:?}"), + } + } +} + +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`. +pub fn pdf( + pages: Vec, + engine: &mut TextEngine, + paper: [f32; 2], + section: &str, +) -> Result, PrintError> { + let title = pages + .first() + .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 contents = Vec::with_capacity(printed.len()); + for (scene, pagination, margin_origin, rules) in &printed { + let mut primitives = Vec::new(); + scene + .append_primitives(&mut primitives, [0.0; 2], Paper::WHITE) + .map_err(PrintError::Scene)?; + let rules: Vec<_> = pagination + .sheets() + .map(|[top, _]| { + let right = pagination.left + paper[0] / pagination.scale; + crate::interaction::rule_primitives( + *rules, + *margin_origin, + [pagination.left, top, right, top + pagination.band], + 0.0, + Paper::WHITE, + ) + }) + .collect(); + contents.push((primitives, rules)); + } + let format = onestore::document::Format { + font: Some("Times New Roman".into()), + font_size: Some(10.0), + ..Default::default() + }; + let mut footers = Vec::new(); + for (sheet, _) in printed + .iter() + .flat_map(|(_, pagination, ..)| pagination.sheets()) + .enumerate() + { + let text = match section { + "" => format!("Page {}", sheet + 1), + section => format!("{section} Page {}", sheet + 1), + }; + let layout = engine + .layout(&Paragraph::new(text, format.clone()), f32::MAX) + .map_err(PrintError::Layout)?; + footers.push(layout); + } + let footer_primitives: Vec<_> = footers + .iter() + .map(|layout| { + let (line, bounds) = layout.lines().next().expect("A laid-out footer has a line"); + [Primitive::Text { + text: layout, + origin: [-line.metrics().advance / 2.0, -bounds.baseline], + clip: None, + ink: Paper::WHITE.ink, + }] + }) + .collect(); + let mut sheets = Vec::new(); + let mut footer = footer_primitives.iter(); + for ((_, pagination, ..), (primitives, rules)) in printed.iter().zip(&contents) { + 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 { + scale, + origin, + clip: Some([0.0, MARGIN, paper[0], MARGIN + (bottom - top) * scale]), + backdrop: None, + round: None, + motion: None, + primitives, + }; + // OneNote shrinks the footer with the page, towards the paper's bottom left. + let footer = Layer { + scale, + origin: [ + 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"), + }; + sheets.push(Sheet { + size: paper, + layers: vec![ + page(rules, top + pagination.band), + page(primitives, bottom), + footer, + ], + }); + } + } + draw::pdf(&title, &sheets).map_err(PrintError::Render) +} + +#[cfg(test)] +mod tests { + use super::*; + use onestore::{RevisionIndex, Store, document::Document}; + use std::collections::HashMap; + + fn section(path: &str) -> Vec { + let bytes = std::fs::read(format!("{}/../../{path}", env!("CARGO_MANIFEST_DIR"))).unwrap(); + let store = Store::parse(&bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let mut spaces: Vec<_> = document + .pages() + .unwrap() + .into_iter() + .map(|(space, _)| space) + .collect(); + spaces.dedup(); + spaces + .into_iter() + .map(|space| Page::from_space(&document, space).unwrap()) + .collect() + } + + /// Each sheet's size and text, as a reader extracts it through the fonts' ToUnicode maps. + fn sheets(pdf: &[u8]) -> Vec<([f32; 2], String)> { + let find = |haystack: &[u8], needle: &[u8]| { + haystack + .windows(needle.len()) + .position(|window| window == needle) + }; + let mut objects: HashMap)> = HashMap::new(); + let mut rest = pdf; + while let Some(start) = find(rest, b" 0 obj\n") { + let number = rest[..start] + .rsplit(|byte| !byte.is_ascii_digit()) + .next() + .unwrap(); + let number: u32 = std::str::from_utf8(number).unwrap().parse().unwrap(); + rest = &rest[start + 7..]; + let end = find(rest, b"endobj").unwrap(); + let object = &rest[..end]; + let (dictionary, stream) = match find(object, b"stream\n") { + Some(at) => { + let data = &object[at + 7..find(object, b"\nendstream").unwrap()]; + ( + &object[..at], + miniz_oxide::inflate::decompress_to_vec_zlib(data).unwrap_or_default(), + ) + } + None => (object, Vec::new()), + }; + objects.insert(number, (dictionary, stream)); + rest = &rest[end..]; + } + let text = |bytes: &[u8]| String::from_utf8_lossy(bytes).into_owned(); + let reference = |dictionary: &[u8], key: &str| -> Option { + let dictionary = text(dictionary); + let at = dictionary.find(&format!("{key} "))? + key.len() + 1; + dictionary[at..].split(' ').next()?.parse().ok() + }; + let tree = objects + .values() + .find(|(dictionary, _)| text(dictionary).contains("/Type /Pages")) + .unwrap(); + let kids = text(tree.0); + let kids = &kids[kids.find("/Kids [").unwrap() + 7..]; + let kids: Vec = kids[..kids.find(']').unwrap()] + .split(" 0 R") + .filter_map(|kid| kid.trim().parse().ok()) + .collect(); + kids.into_iter() + .map(|page| { + let dictionary = text(objects[&page].0); + let media: Vec = dictionary[dictionary.find("/MediaBox [").unwrap() + 11..] + .split(']') + .next() + .unwrap() + .split_whitespace() + .map(|value| value.parse().unwrap()) + .collect(); + let mut maps: HashMap> = HashMap::new(); + for (at, _) in dictionary.match_indices("/F") { + let mut words = dictionary[at + 1..].split_whitespace(); + let (Some(name), Some(font)) = ( + words.next(), + words.next().and_then(|id| id.parse::().ok()), + ) else { + continue; + }; + let Some(unicode) = reference(objects[&font].0, "/ToUnicode") else { + continue; + }; + let cmap = text(&objects[&unicode].1); + let mut map = HashMap::new(); + for line in cmap.lines().filter(|line| line.starts_with('<')) { + let hex: Vec<&str> = line + .split(['<', '>', ' ']) + .filter(|part| !part.is_empty()) + .collect(); + let units: Vec = hex[1] + .as_bytes() + .chunks(4) + .map(|unit| { + u16::from_str_radix(std::str::from_utf8(unit).unwrap(), 16).unwrap() + }) + .collect(); + map.insert( + u16::from_str_radix(hex[0], 16).unwrap(), + String::from_utf16_lossy(&units), + ); + } + maps.insert(name.to_owned(), map); + } + let content = &objects[&reference(objects[&page].0, "/Contents").unwrap()].1; + let (mut shown, mut font, mut name, mut at) = + (String::new(), String::new(), String::new(), 0); + while at < content.len() { + let byte = content[at]; + at += 1; + let mut string = Vec::new(); + match byte { + b'(' => { + while content[at] != b')' { + if content[at] == b'\\' { + at += 1; + if content[at].is_ascii_digit() { + let octal = + std::str::from_utf8(&content[at..at + 3]).unwrap(); + string.push(u8::from_str_radix(octal, 8).unwrap()); + at += 3; + continue; + } + string.push(match content[at] { + b'n' => b'\n', + b'r' => b'\r', + b't' => b'\t', + b'b' => 8, + b'f' => 12, + other => other, + }); + } else { + string.push(content[at]); + } + at += 1; + } + at += 1; + } + b'<' if content[at] != b'<' => { + let end = + at + content[at..].iter().position(|byte| *byte == b'>').unwrap(); + string = content[at..end] + .chunks(2) + .map(|pair| { + u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16) + .unwrap() + }) + .collect(); + at = end + 1; + } + b'/' => { + let end = at + + content[at..] + .iter() + .position(|byte| byte.is_ascii_whitespace()) + .unwrap(); + name = text(&content[at..end]); + at = end; + } + b'T' if content.get(at) == Some(&b'f') => font = name.clone(), + b'T' if content.get(at) == Some(&b'm') => shown.push('\n'), + _ => {} + } + if let Some(map) = maps.get(&font) { + for cid in string.chunks(2).filter(|pair| pair.len() == 2) { + if let Some(text) = map.get(&u16::from_be_bytes([cid[0], cid[1]])) { + shown.push_str(text); + } + } + } + } + ([media[2], media[3]], shown) + }) + .collect() + } + + /// A ligature's glyph reads back as the letters it joins. + #[test] + fn glyphs_read_back_as_their_text() { + let mut engine = TextEngine::default(); + let format = onestore::document::Format { + font: Some("Calibri".into()), + font_size: Some(11.0), + ..Default::default() + }; + let layout = engine + .layout(&Paragraph::new("office fish ffi".into(), format), f32::MAX) + .unwrap(); + let primitives = [Primitive::Text { + text: &layout, + origin: [72.0, 72.0], + clip: None, + ink: Paper::WHITE.ink, + }]; + let sheet = Sheet { + size: LETTER, + layers: vec![Layer { + scale: 1.0, + origin: [0.0; 2], + clip: None, + backdrop: None, + round: None, + motion: None, + primitives: &primitives, + }], + }; + let pdf = draw::pdf("", &[sheet]).unwrap(); + assert_eq!(sheets(&pdf)[0].1.trim(), "office fish ffi"); + } + + #[test] + fn sheets_break_between_lines() { + // Lines 20 points tall from 90 down; a Letter sheet holds 720 points of page. + 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); + 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]); + let sheets: Vec<_> = pagination.sheets().collect(); + assert_eq!(sheets, [[14.4, 730.0], [730.0, 1450.0]]); + } + + #[test] + fn a_line_taller_than_a_sheet_is_cut() { + let pagination = Pagination::new( + [36.0, 14.4, 400.0, 1000.0], + &[[20.0, 1000.0]], + [36.0, 14.4], + LETTER, + ); + assert_eq!(pagination.tops, [14.4, 734.4]); + } + + /// OneNote's scales for pages whose content ends 866.7, 581.8 and 1221.4 points across, + /// on its 612.36-point Letter sheet (`corpus/print`). + #[test] + fn wide_pages_shrink_to_the_paper_as_onenote_shrinks_them() { + for (right, onenote) in [ + (866.7, 0.67167), + (581.8, 0.978), + (1221.4, 0.48361), + (400.0, 1.0), + ] { + let scale = Pagination::new( + [36.0, 14.4, right, 100.0], + &[], + [36.0, 14.4], + [612.36, 790.92], + ) + .scale; + assert!( + (scale - onenote).abs() < 0.002, + "{right}: {scale} for {onenote}" + ); + } + } + + /// `corpus/print`: the section OneNote 2010 saved as `native/section.pdf`, on the + /// same sheets with the same text. + #[test] + fn a_section_prints_on_onenotes_sheets() { + let pdf = pdf( + section("corpus/print/native/Print.one"), + &mut TextEngine::default(), + LETTER, + "Print", + ) + .unwrap(); + let sheets = sheets(&pdf); + assert_eq!(sheets.len(), 5); + for (at, (size, text)) in sheets.iter().enumerate() { + assert_eq!(*size, LETTER); + assert!(text.contains(&format!("Print Page {}", at + 1)), "{text}"); + } + let [first, second] = [&sheets[0].1, &sheets[1].1]; + assert!(first.contains("Printing test")); + assert!( + first.contains( + "Line 37 of the long outline, with enough words to show wrapping where the" + ) + ); + assert!(first.contains("outline is narrow. Searchable-37")); + assert!(first.contains("Far right outline past the paper width")); + assert!(!first.contains("Searchable-38")); + assert!(second.contains("Line 38 of the long outline")); + assert!(second.contains("Searchable-70")); + assert!(!second.contains("Searchable-37\n")); + assert!(sheets[2].1.contains("Short second page")); + } + + /// `corpus/page-background`'s pages, ruled and with template art, take the twelve + /// sheets OneNote 2010 prints them on (`corpus/print/native/rules-section.pdf`), the + /// art reaching past the Very Large Grid page's first. + #[test] + fn ruled_pages_and_art_print_on_onenotes_sheets() { + let pdf = pdf( + section("corpus/page-background/candidate/Rules.one"), + &mut TextEngine::default(), + LETTER, + "Rules", + ) + .unwrap(); + let sheets = sheets(&pdf); + assert_eq!(sheets.len(), 12); + assert!(sheets[8].1.contains("VeryLargeGrid")); + assert!(sheets[9].1.contains("Rules Page 10")); + assert!(!sheets[9].1.contains("VeryLargeGrid")); + } +} diff --git a/crates/canvas/tests/links_equations.rs b/crates/canvas/tests/links_equations.rs index 7a76e09f8056b72dfc3d9b700f140ab484940337..0129c39e54a88eaf80dbc1dd7b760f9185d71152 100644 --- a/crates/canvas/tests/links_equations.rs +++ b/crates/canvas/tests/links_equations.rs @@ -797,3 +797,190 @@ fn equations_draw_inline_and_url_text_shows_as_a_link() { assert_eq!(canvas::editor::shown_urls(&url), vec![5..22]); assert!(url.spans()[0].format.hyperlink.is_none()); } + +/// Selections reaching into equation objects, and Enter in a placeholder and in a limit, act +/// as OneNote 2010's equation editor acted on the same page (`corpus/equation-select`): an +/// end inside an object the other end is outside takes the whole object, and across +/// paragraphs the end of the paragraph with it; a placeholder is taken whole; Enter inside a +/// limit makes its argument an equation array. Each action saves as one revision. +#[test] +fn selections_into_equation_objects_take_them_whole() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let source = + std::fs::read(root.join("corpus/math-edit/native-editor-4/notebook/links.one")).unwrap(); + let (space, page) = pages(&source).remove(0); + let mut engine = TextEngine::default(); + let engine = &mut engine; + let mut editor = CanvasEditor::from_page(page, engine).unwrap(); + let body = editor + .outlines() + .iter() + .find(|outline| !outline.title) + .unwrap() + .id; + editor.focus_outline(body).unwrap(); + let texts = |editor: &CanvasEditor| -> Vec { + editor + .active_outline() + .document() + .paragraphs() + .map(|text| text.text().to_owned()) + .collect() + }; + let original = texts(&editor); + let find = |editor: &CanvasEditor, needle: &str| { + let paragraph = texts(editor) + .iter() + .position(|text| text.contains(needle)) + .unwrap(); + let text = editor + .active_outline() + .document() + .paragraphs() + .nth(paragraph) + .unwrap() + .clone(); + TextPosition { + paragraph, + offset: text + .utf16_offset(text.text().find(needle).unwrap()) + .unwrap(), + } + }; + let arena = Arena::default(); + let mut section = Section::open(&arena, source).unwrap(); + let mut save = |editor: &mut CanvasEditor| { + let ops = editor + .take_ops() + .unwrap() + .into_iter() + .map(|op| onestore::op::Op::Page { space, op }) + .collect(); + section + .apply( + "Author", + &onestore::op::Edit { + at: 134_000_000_000_000_000, + ops, + }, + ) + .unwrap(); + section.seal().unwrap().unwrap(); + }; + let delete = |editor: &mut CanvasEditor, engine: &mut TextEngine, from, to| { + editor.select([from, to].into()).unwrap(); + assert!(editor.delete(engine, true).unwrap()); + }; + + // 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); + editor.enter(engine, false).unwrap(); + save(&mut editor); + let after = texts(&editor); + 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. + let (from, to) = (find(&editor, "\u{fdef}+1"), find(&editor, "3\u{fdee}𝑥")); + let to = TextPosition { + offset: to.offset + 4, + ..to + }; + delete(&mut editor, engine, from, to); + save(&mut editor); + let mut expected = after.clone(); + expected.drain(from.paragraph..=to.paragraph); + assert_eq!(texts(&editor), expected); + + // From after a₁ into a sum: the sum goes whole with its paragraph's end, joining the + // integral below it. + let from = find(&editor, "+\u{fdd0}𝑏"); + let to = find(&editor, "𝑖 \u{fdef}"); + let to = TextPosition { + offset: to.offset + 2, + ..to + }; + delete(&mut editor, engine, from, to); + save(&mut editor); + assert_eq!( + texts(&editor)[from.paragraph], + "\u{fdd0}𝑎\u{fdee}1\u{fdef}\u{fdd0}0\u{fdee}1\u{fdee}𝑥 𝑑𝑥 \u{fdef}" + ); + + // Enter inside a limit's argument makes it an equation array. + let limit = find(&editor, "∞"); + select(&mut editor, limit.paragraph, limit.offset..limit.offset); + editor.enter(engine, false).unwrap(); + save(&mut editor); + assert!(texts(&editor)[limit.paragraph].contains("𝑛→\u{fdee}∞\u{fdef}")); + + // Text around equations: from a fraction's numerator into a root, both taken whole. + let end = texts(&editor).len() - 1; + let offset = editor + .active_outline() + .document() + .paragraphs() + .nth(end) + .unwrap() + .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")] { + typed(&mut editor, engine, before); + editor.insert_equation(engine).unwrap(); + typed(&mut editor, engine, linear); + editor + .move_selection(engine, draw::edit::Movement::Right, false) + .unwrap(); + typed(&mut editor, engine, after); + line(&mut editor, engine); + } + save(&mut editor); + let from = find(&editor, "𝑎\u{fdee}𝑏\u{fdef}+𝑐"); + let to = find(&editor, "𝑥\u{fdef}+𝑦"); + let (from, to) = ( + TextPosition { + offset: from.offset + 2, + ..from + }, + TextPosition { + offset: to.offset + 2, + ..to + }, + ); + delete(&mut editor, engine, from, to); + save(&mut editor); + let joined = &texts(&editor)[from.paragraph]; + assert!(joined.starts_with("One +𝑦"), "{joined:?}"); + assert!(joined.ends_with(" four"), "{joined:?}"); + + let written = section.image(); + let (_, stored) = pages(&written).remove(0); + let stored: Vec = stored + .objects + .iter() + .filter_map(|object| match object { + PageObject::Outline(outline) if !outline.title => Some(outline), + _ => None, + }) + .flat_map(|outline| &outline.paragraphs) + .filter_map(|paragraph| paragraph.text()) + .map(|text| text.text.text().to_owned()) + .collect(); + assert_eq!(stored, texts(&editor)); + if let Some(directory) = std::env::var_os("CANVAS_EQUATION_SELECT_EXPORT") { + let directory = std::path::PathBuf::from(directory); + std::fs::create_dir(&directory).unwrap(); + std::fs::write(directory.join("links.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", &[("links.one", file_id)]) + .unwrap(), + ) + .unwrap(); + } + while editor.undo(engine).unwrap() {} + assert_eq!(texts(&editor), original); +} diff --git a/crates/canvas/tests/picture_paste.rs b/crates/canvas/tests/picture_paste.rs new file mode 100644 index 0000000000000000000000000000000000000000..940b40d8fe90a6a35efcf21a3692f84b99055feb --- /dev/null +++ b/crates/canvas/tests/picture_paste.rs @@ -0,0 +1,196 @@ +//! Pictures pasted or inserted through the editor, placed as OneNote 2010 places them (lab, +//! 2026-09-30): at a caret in text the paragraph splits around the picture, and at a caret +//! on blank page the picture lies on the page there with the caret on the grid row below. +//! Each is one edit and one undo step. `CANVAS_PICTURE_EXPORT` names a directory receiving +//! the section as a notebook, for a cold reopen in OneNote 2010. + +use canvas::{ + document::TextPosition, + editor::{CanvasEditor, Selection}, + layout::TextEngine, +}; +use onestore::{ + Arena, Section, Store, + document::Layout, + op::{Edit, Op}, + page::{Image, PageObject, ParagraphContent}, +}; +use parley::Affinity; + +fn picture(size: [f32; 2]) -> Image { + let mut png = Vec::new(); + image::RgbaImage::from_pixel(200, 100, image::Rgba([30, 144, 255, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + Image { + id: onestore::page::text::new_id().unwrap(), + layout: Layout { + max_width: Some(size[0]), + max_height: Some(size[1]), + ..Default::default() + }, + size: Some(size), + bytes: Some(png.into()), + display: None, + alt: None, + background: false, + printout: None, + } +} + +fn caret(paragraph: usize, offset: u32) -> Selection { + let position = TextPosition { paragraph, offset }; + Selection { + positions: [position; 2], + affinities: [Affinity::Downstream; 2], + } +} + +/// The body outline's paragraphs: text as itself, a picture as its size. +fn body(editor: &CanvasEditor) -> Vec { + let page = editor.page().unwrap(); + let outline = page + .objects + .iter() + .find_map(|object| match object { + PageObject::Outline(outline) if !outline.title => Some(outline), + _ => None, + }) + .unwrap(); + outline + .paragraphs + .iter() + .map(|paragraph| match ¶graph.content { + ParagraphContent::Text(text) => text.text.text().to_owned(), + ParagraphContent::Image(image) => format!("{:?}", image.size.unwrap()), + _ => panic!(), + }) + .collect() +} + +fn page_pictures(editor: &CanvasEditor) -> Vec { + editor + .page() + .unwrap() + .objects + .into_iter() + .filter_map(|object| match object { + PageObject::Image(image) if !image.background => Some(image), + _ => None, + }) + .collect() +} + +#[test] +fn pasted_pictures_split_the_text_or_lie_on_the_page_as_onenote_places_them() { + let source = onestore::create_section("pictures.one", "Pasted pictures", "Author").unwrap(); + let arena = Arena::default(); + let mut section = Section::open(&arena, source.clone()).unwrap(); + let (space, ..) = section.pages().unwrap()[0].clone(); + let mut engine = TextEngine::default(); + let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap(); + let mut at = 133_000_000_000_000_000; + let mut store = |editor: &mut CanvasEditor| { + let ops = editor.take_ops().unwrap(); + assert!(!ops.is_empty()); + at += 10_000_000; + let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect(); + section.apply("Author", &Edit { at, ops }).unwrap(); + }; + let text = editor + .outlines() + .iter() + .find(|outline| !outline.title) + .unwrap() + .id; + editor.focus_outline(text).unwrap(); + + // Within text, the caret starting the second half. + editor.select(caret(0, 6)).unwrap(); + editor + .insert_picture(&mut engine, picture([150.0, 75.0])) + .unwrap(); + store(&mut editor); + assert_eq!(body(&editor), ["Pasted", "[150.0, 75.0]", " pictures"]); + assert_eq!( + editor.selection().positions, + [TextPosition { + paragraph: 1, + offset: 0 + }; 2] + ); + 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(); + editor + .insert_picture(&mut engine, picture([150.0, 75.0])) + .unwrap(); + store(&mut editor); + editor + .insert_picture(&mut engine, picture([200.0, 100.0])) + .unwrap(); + store(&mut editor); + assert_eq!( + body(&editor), + ["Pasted pictures", "[150.0, 75.0]", "[200.0, 100.0]", ""] + ); + + // On blank page: at the caret, the caret moving to the grid row below (OneNote put a + // 75 pt picture at y 266.4 and the caret at 356.4, a 72 pt one at 248.4 and 338.4). + for (y, height, below) in [(266.4, 75.0, 356.4), (248.4 + 180.0, 72.0, 338.4 + 180.0)] { + editor.place_caret(&mut engine, [270.0, y], 468.0).unwrap(); + editor + .insert_picture(&mut engine, picture([150.0, height])) + .unwrap(); + store(&mut editor); + let placed = page_pictures(&editor).pop().unwrap(); + assert_eq!([placed.layout.x, placed.layout.y], [Some(270.0), Some(y)]); + assert_eq!(editor.active_outline().origin(), [270.0, below]); + } + editor.insert(&mut engine, "below").unwrap(); + store(&mut editor); + editor.undo(&mut engine).unwrap(); + editor.undo(&mut engine).unwrap(); + store(&mut editor); + assert_eq!(page_pictures(&editor).len(), 1); + + 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 stored: Vec<_> = page + .objects + .iter() + .filter_map(|object| match object { + PageObject::Image(image) => Some(image), + _ => None, + }) + .collect(); + assert_eq!(stored.len(), 1); + assert_eq!( + [stored[0].layout.x, stored[0].layout.y], + [Some(270.0), Some(266.4)] + ); + assert_eq!(*stored[0], page_pictures(&editor)[0]); + + if let Some(directory) = std::env::var_os("CANVAS_PICTURE_EXPORT") { + let directory = std::path::PathBuf::from(directory); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write(directory.join("pictures.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", + &[("pictures.one", file_id)], + ) + .unwrap(), + ) + .unwrap(); + } +} diff --git a/crates/draw/Cargo.toml b/crates/draw/Cargo.toml index 571f2a3c0753d51db4bb8e725488bb32347bccb4..6e0ac70b2dbea62d7086e2e43cd55dc04c356a6b 100644 --- a/crates/draw/Cargo.toml +++ b/crates/draw/Cargo.toml @@ -11,6 +11,8 @@ default = ["wgpu"] # Windows 7 has no Direct3D 12. render = ["dep:base64", "dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:swash"] wgpu = ["render", "dep:wgpu"] +# Writes layers as PDF pages, text as real text in subset fonts. +pdf = ["render", "dep:miniz_oxide", "dep:pdf-writer", "dep:subsetter"] [dependencies] parley.workspace = true @@ -19,7 +21,10 @@ base64 = { version = "0.22.1", optional = true } bytemuck = { version = "1.25", features = ["derive"], optional = true } image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png", "tiff"], optional = true } linebender_resource_handle = { version = "0.1.1", optional = true } +miniz_oxide = { version = "0.8", optional = true } +pdf-writer = { version = "0.15", optional = true } roxmltree = { version = "0.21", optional = true } +subsetter = { version = "0.2.6", default-features = false, optional = true } swash = { version = "0.2.10", optional = true } wgpu = { workspace = true, optional = true } diff --git a/crates/draw/src/render.rs b/crates/draw/src/render.rs index 9ffeb1cd045adffae3d836421e9450fa5ebdaf3c..551e387be5a83c8d4606b97e8e801df5bc6f6c93 100644 --- a/crates/draw/src/render.rs +++ b/crates/draw/src/render.rs @@ -3,6 +3,8 @@ mod dual; #[cfg(any(windows, not(feature = "wgpu")))] mod gl; mod icon; +#[cfg(feature = "pdf")] +mod pdf; mod text; #[cfg(feature = "wgpu")] mod translucent; @@ -20,6 +22,8 @@ pub use backend::Target; #[cfg(all(feature = "wgpu", windows))] pub use gl::Target as GlTarget; pub use icon::{Palette, picture_icon}; +#[cfg(feature = "pdf")] +pub use pdf::{Sheet, pdf}; pub use text::{Decoration, Glyph, GlyphRun, Glyphs, paint_parley_run}; #[cfg(feature = "wgpu")] pub use translucent::Translucent; @@ -1736,6 +1740,7 @@ mod tests { if let PositionedLayoutItem::GlyphRun(run) = item { paint_parley_run( &run, + "", metrics.baseline, 0.0, [metrics.block_min_coord, metrics.line_height], diff --git a/crates/draw/src/render/pdf.rs b/crates/draw/src/render/pdf.rs new file mode 100644 index 0000000000000000000000000000000000000000..ab01d8fe5400740d90ec43617f1a83591ee01fcd --- /dev/null +++ b/crates/draw/src/render/pdf.rs @@ -0,0 +1,1004 @@ +//! Layers written as the sheets of a PDF: glyphs as text in subset fonts, which viewers +//! select, search and copy, shapes and ink as vector paths, pictures and icons as images. + +use super::{ + GlyphRun, Layer, PathStyle, Primitive, RasterImage, RenderError, Stroke, icon, srgb_byte, +}; +use parley::fontique::Blob; +use pdf_writer::{ + Content, Filter, Finish, Name, Pdf, Rect, Ref, Str, TextStr, + types::{ + BlendMode, CidFontType, FontFlags, LineCapStyle, LineJoinStyle, SystemInfo, + TextRenderingMode, UnicodeCmap, + }, +}; +use std::collections::{BTreeMap, HashMap, btree_map::Entry}; +use swash::{ + FontRef, StringId, + scale::{Render, ScaleContext, Source, StrikeWith, image::Content as Pixels}, + tag_from_bytes, + zeno::{Command, PathData}, +}; + +/// One sheet of paper. +pub struct Sheet<'a> { + /// Width and height in points. + pub size: [f32; 2], + /// Layers whose device units are points from the sheet's top left. + pub layers: Vec>, +} + +/// `sheets` as a PDF document titled `title`. +pub fn pdf(title: &str, sheets: &[Sheet<'_>]) -> Result, RenderError> { + let mut document = Document { + pdf: Pdf::new(), + next: 1, + fonts: Vec::new(), + images: HashMap::new(), + states: HashMap::new(), + scale: ScaleContext::new(), + rows: None, + }; + let catalog = document.reserve(); + let tree = document.reserve(); + let pages: Vec<_> = sheets + .iter() + .map(|sheet| { + let page = document.reserve(); + let content = document.reserve(); + let used = document.sheet(sheet, content)?; + Ok((page, used)) + }) + .collect::>()?; + for ((page, used), sheet) in pages.iter().zip(sheets) { + let mut writer = document.pdf.page(*page); + writer + .parent(tree) + .media_box(Rect::new(0.0, 0.0, sheet.size[0], sheet.size[1])) + .contents(used.content); + let mut resources = writer.resources(); + let mut fonts = resources.fonts(); + for font in &used.fonts { + let reference = document.fonts[*font].reference; + fonts.pair(Name(format!("F{font}").as_bytes()), reference); + } + fonts.finish(); + let mut images = resources.x_objects(); + for image in &used.images { + images.pair(Name(format!("I{}", image.get()).as_bytes()), *image); + } + images.finish(); + let mut states = resources.ext_g_states(); + for state in &used.states { + states.pair(Name(format!("G{}", state.get()).as_bytes()), *state); + } + } + document + .pdf + .pages(tree) + .kids(pages.iter().map(|(page, _)| *page)) + .count(pages.len() as i32); + document.pdf.catalog(catalog).pages(tree); + let info = document.reserve(); + document + .pdf + .document_info(info) + .title(TextStr(title)) + .producer(TextStr("Snowbound")); + let fonts = std::mem::take(&mut document.fonts); + for font in fonts.into_iter().filter(|font| font.outlined) { + document.write_font(font)?; + } + Ok(document.pdf.finish()) +} + +struct Document { + pdf: Pdf, + next: i32, + fonts: Vec, + /// Image XObjects by what they show. + images: HashMap, + /// Graphics states by fill and stroke opacity bits and whether they multiply. + states: HashMap<(u32, bool), Ref>, + scale: ScaleContext, + /// The top and bottom of the layer drawn, in its units, where it clips; lines of text + /// outside it are left out, so that no sheet's text layer holds text it does not show. + rows: Option<[f32; 2]>, +} + +#[derive(Hash, PartialEq, Eq)] +enum ImageKey { + Raster(u64), + Icon { + sources: usize, + size: u32, + tint: [u32; 3], + palette: [Option<[u32; 3]>; 3], + }, + Glyph { + font: u64, + index: u32, + glyph: u32, + size: u32, + color: [u32; 3], + }, +} + +/// A face the document shows glyphs of. +struct Font { + reference: Ref, + data: Blob, + index: u32, + /// Whether its glyphs are outlines the PDF embeds; others draw as pictures. + outlined: bool, + glyphs: subsetter::GlyphRemapper, + /// The text each subset glyph shows where it first appeared. + text: BTreeMap, +} + +/// What a sheet's content refers to. +struct Used { + content: Ref, + fonts: Vec, + images: Vec, + states: Vec, +} + +impl Used { + fn add(list: &mut Vec, item: T) { + if !list.contains(&item) { + list.push(item); + } + } +} + +fn compress(data: &[u8]) -> Vec { + miniz_oxide::deflate::compress_to_vec_zlib(data, 6) +} + +/// sRGB components of a linear colour. +fn encoded(color: [f32; 4]) -> [f32; 3] { + [color[0], color[1], color[2]].map(|value| f32::from(srgb_byte(value.clamp(0.0, 1.0))) / 255.0) +} + +/// Bézier handle length for a quarter circle of radius one. +const KAPPA: f32 = 0.552_284_8; + +/// Appends an ellipse's quarter arcs around `rect`'s corners of radii `radius`, as a +/// closed path. +fn rounded(content: &mut Content, [left, top, right, bottom]: [f32; 4], radius: [f32; 2]) { + let rx = radius[0].clamp(0.0, (right - left) / 2.0); + let ry = radius[1].clamp(0.0, (bottom - top) / 2.0); + if rx <= 0.0 || ry <= 0.0 { + content.rect(left, top, right - left, bottom - top); + return; + } + let [kx, ky] = [rx * KAPPA, ry * KAPPA]; + content.move_to(left + rx, top); + content.line_to(right - rx, top); + content.cubic_to(right - rx + kx, top, right, top + ry - ky, right, top + ry); + content.line_to(right, bottom - ry); + content.cubic_to( + right, + bottom - ry + ky, + right - rx + kx, + bottom, + right - rx, + bottom, + ); + content.line_to(left + rx, bottom); + content.cubic_to( + left + rx - kx, + bottom, + left, + bottom - ry + ky, + left, + bottom - ry, + ); + content.line_to(left, top + ry); + content.cubic_to(left, top + ry - ky, left + rx - kx, top, left + rx, top); + content.close_path(); +} + +fn circle(content: &mut Content, [x, y]: [f32; 2], radius: f32) { + rounded( + content, + [x - radius, y - radius, x + radius, y + radius], + [radius; 2], + ); +} + +/// The text each of `count` glyphs shows: a cluster's text goes to its first glyph, and a +/// ligature's later clusters to the glyph before them. +fn glyph_text( + text: &str, + clusters: &mut dyn Iterator, usize)>, + count: usize, +) -> Vec { + let mut texts = vec![String::new(); count]; + let mut at = 0_usize; + for (range, glyphs) in clusters { + let shown = text.get(range).unwrap_or_default(); + if glyphs == 0 { + if let Some(previous) = at.checked_sub(1).and_then(|at| texts.get_mut(at)) { + previous.push_str(shown); + } + continue; + } + if let Some(first) = texts.get_mut(at) { + first.push_str(shown); + } + at += glyphs; + } + texts +} + +impl Document { + fn reserve(&mut self) -> Ref { + let reference = Ref::new(self.next); + self.next += 1; + reference + } + + fn sheet(&mut self, sheet: &Sheet<'_>, content_ref: Ref) -> Result { + let mut used = Used { + content: content_ref, + fonts: Vec::new(), + images: Vec::new(), + states: Vec::new(), + }; + let mut content = Content::new(); + // Layers measure down from the top left, as the renderer's do. + content.transform([1.0, 0.0, 0.0, -1.0, 0.0, sheet.size[1]]); + for layer in &sheet.layers { + content.save_state(); + if let Some([left, top, right, bottom]) = layer.clip { + content + .rect(left, top, right - left, bottom - top) + .clip_nonzero() + .end_path(); + } + self.rows = layer + .clip + .map(|clip| [clip[1], clip[3]].map(|y| (y - layer.origin[1]) / layer.scale)); + content.transform([ + layer.scale, + 0.0, + 0.0, + layer.scale, + layer.origin[0], + layer.origin[1], + ]); + for primitive in layer.primitives { + self.primitive(&mut content, &mut used, primitive)?; + } + content.restore_state(); + } + let data = compress(&content.finish()); + self.pdf + .stream(content_ref, &data) + .filter(Filter::FlateDecode); + Ok(used) + } + + /// Sets the fill, or with `stroke` the stroke, to `color` with its opacity. + fn paint(&mut self, content: &mut Content, used: &mut Used, color: [f32; 4], stroke: bool) { + let [red, green, blue] = encoded(color); + if stroke { + content.set_stroke_rgb(red, green, blue); + } else { + content.set_fill_rgb(red, green, blue); + } + if color[3] < 1.0 { + let state = self.state(color[3], false); + Used::add(&mut used.states, state); + content.set_parameters(Name(format!("G{}", state.get()).as_bytes())); + } + } + + fn state(&mut self, opacity: f32, multiply: bool) -> Ref { + let key = (opacity.clamp(0.0, 1.0).to_bits(), multiply); + if let Some(state) = self.states.get(&key) { + return *state; + } + let state = self.reserve(); + let mut writer = self.pdf.ext_graphics(state); + writer + .non_stroking_alpha(opacity.clamp(0.0, 1.0)) + .stroking_alpha(opacity.clamp(0.0, 1.0)); + if multiply { + writer.blend_mode(BlendMode::Multiply); + } + writer.finish(); + self.states.insert(key, state); + state + } + + fn primitive( + &mut self, + content: &mut Content, + used: &mut Used, + primitive: &Primitive<'_>, + ) -> Result<(), RenderError> { + match primitive { + Primitive::Text { + text, + origin, + clip, + ink, + } => { + content.save_state(); + if let Some([left, top, right, bottom]) = *clip { + content + .rect(left, top, right - left, bottom - top) + .clip_nonzero() + .end_path(); + } + text.runs(&mut |run| self.run(content, used, run, *origin, *ink))?; + content.restore_state(); + } + Primitive::Icon { + sources, + origin, + size, + tint, + palette, + } => { + // Icons are small art; eight pixels a point keeps them sharp on paper. + let side = (size * 8.0).ceil().clamp(16.0, 512.0) as u32; + let key = ImageKey::Icon { + sources: sources.as_ptr() as usize, + size: side, + tint: [tint[0], tint[1], tint[2]].map(f32::to_bits), + palette: palette.bits(), + }; + let image = match self.images.get(&key) { + Some(image) => *image, + None => { + let raster = + icon::rasterize(sources, side, [tint[0], tint[1], tint[2]], palette); + let image = self.image([side; 2], &raster.data, false); + self.images.insert(key, image); + image + } + }; + content.save_state(); + if tint[3] < 1.0 { + let state = self.state(tint[3], false); + Used::add(&mut used.states, state); + content.set_parameters(Name(format!("G{}", state.get()).as_bytes())); + } + self.place( + content, + used, + image, + [origin[0], origin[1], origin[0] + size, origin[1] + size], + ); + content.restore_state(); + } + Primitive::Path { + data, + origin, + style, + colors, + } => { + let fill = matches!(style, PathStyle::Fill); + let (PathStyle::Fill | PathStyle::Stroke(_)) = style else { + return Ok(()); + }; + content.save_state(); + content.transform([1.0, 0.0, 0.0, 1.0, origin[0], origin[1]]); + let average = std::array::from_fn(|at| (colors[0][at] + colors[1][at]) / 2.0); + self.paint(content, used, average, !fill); + let mut last = [0.0_f32; 2]; + for command in data.commands() { + match command { + Command::MoveTo(to) => { + content.move_to(to.x, to.y); + last = [to.x, to.y]; + } + Command::LineTo(to) => { + content.line_to(to.x, to.y); + last = [to.x, to.y]; + } + Command::CurveTo(one, two, to) => { + content.cubic_to(one.x, one.y, two.x, two.y, to.x, to.y); + last = [to.x, to.y]; + } + Command::QuadTo(control, to) => { + let near = + |from: f32, control: f32| from + (control - from) * 2.0 / 3.0; + content.cubic_to( + near(last[0], control.x), + near(last[1], control.y), + near(to.x, control.x), + near(to.y, control.y), + to.x, + to.y, + ); + last = [to.x, to.y]; + } + Command::Close => { + content.close_path(); + } + } + } + match style { + PathStyle::Stroke(width) => { + content + .set_line_width(*width) + .set_line_cap(LineCapStyle::RoundCap) + .set_line_join(LineJoinStyle::RoundJoin) + .stroke(); + } + _ => { + content.fill_nonzero(); + } + } + content.restore_state(); + } + Primitive::Rect { rect, color } => { + content.save_state(); + self.paint(content, used, *color, false); + content + .rect(rect[0], rect[1], rect[2] - rect[0], rect[3] - rect[1]) + .fill_nonzero(); + content.restore_state(); + } + Primitive::RoundedRect { + rect, + radius, + stroke, + color, + } => { + content.save_state(); + self.paint(content, used, *color, stroke.is_some()); + rounded(content, *rect, *radius); + match stroke { + None => { + content.fill_nonzero(); + } + Some(Stroke::Solid(width)) => { + content.set_line_width(*width).stroke(); + } + Some(Stroke::Dashed(width)) => { + content + .set_line_width(*width) + .set_dash_pattern([width * 2.0, width * 2.0], 0.0) + .stroke(); + } + } + content.restore_state(); + } + Primitive::Gradient { + rect, + radius, + colors, + } => { + content.save_state(); + let average = std::array::from_fn(|at| (colors[0][at] + colors[1][at]) / 2.0); + self.paint(content, used, average, false); + rounded(content, *rect, *radius); + content.fill_nonzero(); + content.restore_state(); + } + // Soft shadows are for the screen. + Primitive::Shadow { .. } => {} + Primitive::Image { image, rect } => { + let key = ImageKey::Raster(image.id()); + let reference = match self.images.get(&key) { + Some(reference) => *reference, + None => { + let reference = self.raster(image); + self.images.insert(key, reference); + reference + } + }; + self.place(content, used, reference, *rect); + } + Primitive::Segment { + from, + to, + width, + round, + color, + } => { + content.save_state(); + self.paint(content, used, *color, true); + content + .set_line_width(*width) + .set_line_cap(if *round { + LineCapStyle::RoundCap + } else { + LineCapStyle::ProjectingSquareCap + }) + .move_to(from[0], from[1]) + .line_to(to[0], to[1]) + .stroke(); + content.restore_state(); + } + Primitive::Taper { + from, + to, + widths, + color, + } => { + content.save_state(); + self.paint(content, used, *color, false); + let [from_radius, to_radius] = widths.map(|width| width / 2.0); + // Each part fills on its own, as their windings differ. + circle(content, *from, from_radius); + content.fill_nonzero(); + circle(content, *to, to_radius); + content.fill_nonzero(); + let [dx, dy] = [to[0] - from[0], to[1] - from[1]]; + let length = dx.hypot(dy); + if length > (from_radius - to_radius).abs() { + // The lines touching both ends' circles. + let [ux, uy] = [dx / length, dy / length]; + let sine = (from_radius - to_radius) / length; + let cosine = (1.0 - sine * sine).sqrt(); + let touch = |center: &[f32; 2], radius: f32, side: f32| { + [ + center[0] + radius * (-uy * side * cosine + ux * sine), + center[1] + radius * (ux * side * cosine + uy * sine), + ] + }; + let corners = [ + touch(from, from_radius, 1.0), + touch(to, to_radius, 1.0), + touch(to, to_radius, -1.0), + touch(from, from_radius, -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::Highlight { + from, + to, + width, + color, + } => { + content.save_state(); + let state = self.state(color[3], true); + Used::add(&mut used.states, state); + content.set_parameters(Name(format!("G{}", state.get()).as_bytes())); + let [red, green, blue] = encoded(*color); + content + .set_stroke_rgb(red, green, blue) + .set_line_width(*width) + .set_line_cap(LineCapStyle::ProjectingSquareCap) + .move_to(from[0], from[1]) + .line_to(to[0], to[1]) + .stroke(); + content.restore_state(); + } + } + Ok(()) + } + + /// Draws image XObject `image` filling `rect`. + fn place(&mut self, content: &mut Content, used: &mut Used, image: Ref, rect: [f32; 4]) { + Used::add(&mut used.images, image); + content.save_state(); + // Image space runs up from the bottom left. + content.transform([ + rect[2] - rect[0], + 0.0, + 0.0, + rect[1] - rect[3], + rect[0], + rect[3], + ]); + content.x_object(Name(format!("I{}", image.get()).as_bytes())); + content.restore_state(); + } + + /// A picture as an image XObject: opaque photographs as JPEG, others losslessly. + fn raster(&mut self, image: &RasterImage) -> Ref { + // Pixels are premultiplied in linear light. + let straight: Vec = image + .pixels() + .chunks_exact(4) + .flat_map(|pixel| { + let alpha = pixel[3]; + if alpha == 255 || alpha == 0 { + return [pixel[0], pixel[1], pixel[2], alpha]; + } + let [red, green, blue] = [pixel[0], pixel[1], pixel[2]].map(|byte| { + let linear = super::linear(f32::from(byte) / 255.0); + srgb_byte((linear * 255.0 / f32::from(alpha)).min(1.0)) + }); + [red, green, blue, alpha] + }) + .collect(); + let size = image.size(); + let opaque = straight.chunks_exact(4).all(|pixel| pixel[3] == 255); + if opaque && size[0] * size[1] >= 64 * 64 { + let rgb: Vec = straight + .chunks_exact(4) + .flat_map(|pixel| [pixel[0], pixel[1], pixel[2]]) + .collect(); + let mut jpeg = Vec::new(); + let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 92); + if image::ImageEncoder::write_image( + encoder, + &rgb, + size[0], + size[1], + image::ExtendedColorType::Rgb8, + ) + .is_ok() + { + let reference = self.reserve(); + let mut writer = self.pdf.image_xobject(reference, &jpeg); + writer.filter(Filter::DctDecode); + writer + .width(size[0] as i32) + .height(size[1] as i32) + .color_space_name(Name(b"DeviceRGB")) + .bits_per_component(8) + .interpolate(true); + return reference; + } + } + self.image(size, &straight, true) + } + + /// Straight sRGB RGBA pixels as a losslessly compressed image XObject. + fn image(&mut self, [width, height]: [u32; 2], rgba: &[u8], interpolate: bool) -> Ref { + let rgb: Vec = rgba + .chunks_exact(4) + .flat_map(|pixel| [pixel[0], pixel[1], pixel[2]]) + .collect(); + let mask = rgba.chunks_exact(4).any(|pixel| pixel[3] != 255).then(|| { + let alpha: Vec = rgba.chunks_exact(4).map(|pixel| pixel[3]).collect(); + let mask = self.reserve(); + let data = compress(&alpha); + let mut writer = self.pdf.image_xobject(mask, &data); + writer.filter(Filter::FlateDecode); + writer + .width(width as i32) + .height(height as i32) + .color_space_name(Name(b"DeviceGray")) + .bits_per_component(8) + .interpolate(interpolate); + mask + }); + let reference = self.reserve(); + let data = compress(&rgb); + let mut writer = self.pdf.image_xobject(reference, &data); + writer.filter(Filter::FlateDecode); + writer + .width(width as i32) + .height(height as i32) + .color_space_name(Name(b"DeviceRGB")) + .bits_per_component(8) + .interpolate(interpolate); + if let Some(mask) = mask { + writer.s_mask(mask); + } + reference + } + + /// The document's face for a run's font, added on first use. + fn font(&mut self, data: &Blob, index: u32) -> Result { + if let Some(found) = self + .fonts + .iter() + .position(|font| font.data.id() == data.id() && font.index == index) + { + return Ok(found); + } + let face = FontRef::from_index(data.as_ref(), index as usize) + .ok_or(RenderError::UnreadableFont)?; + let outlined = face.table(tag_from_bytes(b"glyf")).is_some() + || face.table(tag_from_bytes(b"CFF ")).is_some(); + let reference = self.reserve(); + self.fonts.push(Font { + reference, + data: data.clone(), + index, + outlined, + glyphs: subsetter::GlyphRemapper::new(), + text: BTreeMap::new(), + }); + Ok(self.fonts.len() - 1) + } + + fn run( + &mut self, + content: &mut Content, + used: &mut Used, + run: GlyphRun<'_>, + origin: [f32; 2], + ink: [f32; 4], + ) -> Result<(), RenderError> { + let [top, bottom] = [ + origin[1] + run.line[0], + origin[1] + run.line[0] + run.line[1], + ]; + if self + .rows + .is_some_and(|[first, last]| bottom <= first || top >= last) + { + return Ok(()); + } + let font = self.font(run.font, run.index)?; + let color = run.color.unwrap_or(ink); + let glyphs: Vec<_> = run.glyphs.collect(); + let texts = glyph_text(run.text, run.clusters, glyphs.len()); + content.save_state(); + if self.fonts[font].outlined { + Used::add(&mut used.fonts, font); + self.paint(content, used, color, false); + let face = FontRef::from_index(run.font.as_ref(), run.index as usize) + .ok_or(RenderError::UnreadableFont)?; + let units = f32::from(face.metrics(&[]).units_per_em.max(1)); + let advances = face.glyph_metrics(&[]); + let name = format!("F{font}"); + content + .begin_text() + .set_font(Name(name.as_bytes()), run.size); + if run.embolden { + self.paint(content, used, color, true); + content + .set_text_rendering_mode(TextRenderingMode::FillStroke) + .set_line_width(run.size / 24.0); + } + let shear = run.skew.map_or(0.0, |degrees| degrees.to_radians().tan()); + let mut shown: Vec<(u16, f32)> = Vec::new(); + let mut pen: Option<[f32; 2]> = None; + let flush = |content: &mut Content, shown: &mut Vec<(u16, f32)>| { + if shown.is_empty() { + return; + } + let mut positioned = content.show_positioned(); + let mut items = positioned.items(); + for (glyph, adjust) in shown.drain(..) { + if adjust != 0.0 { + items.adjust(adjust); + } + items.show(Str(&glyph.to_be_bytes())); + } + }; + for (glyph, text) in glyphs.iter().zip(texts) { + let Ok(id) = u16::try_from(glyph.id) else { + continue; + }; + let face = &mut self.fonts[font]; + let cid = face.glyphs.remap(id); + let [x, y] = [origin[0] + glyph.x, origin[1] + glyph.y]; + let conflict = !text.is_empty() + && match face.text.entry(cid) { + Entry::Vacant(entry) => { + entry.insert(text.clone()); + false + } + Entry::Occupied(entry) => *entry.get() != text, + }; + let advance = advances.advance_width(id) / units * run.size; + match pen { + Some([at, line]) if !conflict && (line - y).abs() < 0.001 => { + shown.push((cid, (at - x) * 1000.0 / run.size)); + } + _ => { + flush(content, &mut shown); + content.set_text_matrix([1.0, 0.0, shear, -1.0, x, y]); + if conflict { + // A glyph shared by different text says what it shows here. + content + .begin_marked_content_with_properties(Name(b"Span")) + .properties() + .actual_text(TextStr(&text)); + content.show(Str(&cid.to_be_bytes())); + content.end_marked_content(); + pen = None; + continue; + } + shown.push((cid, 0.0)); + } + } + pen = Some([x + advance, y]); + } + flush(content, &mut shown); + content.end_text(); + } else { + for glyph in &glyphs { + self.glyph_picture(content, used, &run, *glyph, origin, color); + } + } + for decoration in run.decorations { + self.paint(content, used, decoration.color.unwrap_or(color), false); + content + .rect( + origin[0] + decoration.x, + origin[1] + decoration.y, + decoration.width, + decoration.thickness, + ) + .fill_nonzero(); + } + content.restore_state(); + Ok(()) + } + + /// A glyph of a face without outlines the PDF can embed, such as a colour emoji, drawn + /// as a picture four pixels a point. + fn glyph_picture( + &mut self, + content: &mut Content, + used: &mut Used, + run: &GlyphRun<'_>, + glyph: super::Glyph, + origin: [f32; 2], + color: [f32; 4], + ) { + const DENSITY: f32 = 4.0; + let Ok(id) = u16::try_from(glyph.id) else { + return; + }; + let Some(face) = FontRef::from_index(run.font.as_ref(), run.index as usize) else { + return; + }; + let size = run.size * DENSITY; + let mut scaler = self + .scale + .builder(face) + .size(size) + .normalized_coords(run.coords.iter().copied()) + .build(); + let Some(rendered) = Render::new(&[ + Source::ColorOutline(0), + Source::ColorBitmap(StrikeWith::BestFit), + Source::Outline, + ]) + .render(&mut scaler, id) else { + return; + }; + let placement = rendered.placement; + if placement.width == 0 || placement.height == 0 { + return; + } + let key = ImageKey::Glyph { + font: run.font.id(), + index: run.index, + glyph: glyph.id, + size: size.to_bits(), + color: [color[0], color[1], color[2]].map(f32::to_bits), + }; + let image = match self.images.get(&key) { + Some(image) => *image, + None => { + let [red, green, blue] = encoded(color).map(|value| (value * 255.0).round() as u8); + let rgba: Vec = match rendered.content { + Pixels::Mask => rendered + .data + .iter() + .flat_map(|coverage| [red, green, blue, *coverage]) + .collect(), + Pixels::Color | Pixels::SubpixelMask => rendered.data.clone(), + }; + let image = self.image([placement.width, placement.height], &rgba, true); + self.images.insert(key, image); + image + } + }; + let [x, y] = [ + origin[0] + glyph.x + placement.left as f32 / DENSITY, + origin[1] + glyph.y - placement.top as f32 / DENSITY, + ]; + self.place( + content, + used, + image, + [ + x, + y, + x + placement.width as f32 / DENSITY, + y + placement.height as f32 / DENSITY, + ], + ); + } + + /// Embeds `font`'s subset as a CID-keyed Type 0 font with a ToUnicode map. + fn write_font(&mut self, font: Font) -> Result<(), RenderError> { + let data = font.data.as_ref(); + let face = + FontRef::from_index(data, font.index as usize).ok_or(RenderError::UnreadableFont)?; + let subset = subsetter::subset(data, font.index, &font.glyphs) + .map_err(|_| RenderError::UnreadableFont)?; + let cff = face.table(tag_from_bytes(b"CFF ")).is_some(); + let metrics = face.metrics(&[]); + let scale = 1000.0 / f32::from(metrics.units_per_em.max(1)); + let advances = face.glyph_metrics(&[]); + let widths: Vec = font + .glyphs + .remapped_gids() + .map(|old| advances.advance_width(old) * scale) + .collect(); + let postscript: String = face + .localized_strings() + .find_by_id(StringId::PostScript, None) + .map(|name| name.chars().filter(char::is_ascii_alphanumeric).collect()) + .filter(|name: &String| !name.is_empty()) + .unwrap_or_else(|| "Font".to_owned()); + // A subset's name starts with six capitals naming the subset. + let mut hash = font.reference.get() as u32; + let tag: String = (0..6) + .map(|_| { + hash = hash.wrapping_mul(1_103_515_245).wrapping_add(12_345); + char::from(b'A' + (hash >> 16) as u8 % 26) + }) + .collect(); + let base = format!("{tag}+{postscript}"); + let [cid, descriptor, file, map] = [(); 4].map(|_| self.reserve()); + let info = SystemInfo { + registry: Str(b"Adobe"), + ordering: Str(b"Identity"), + supplement: 0, + }; + self.pdf + .type0_font(font.reference) + .base_font(Name(base.as_bytes())) + .encoding_predefined(Name(b"Identity-H")) + .descendant_font(cid) + .to_unicode(map); + let mut writer = self.pdf.cid_font(cid); + writer + .subtype(if cff { + CidFontType::Type0 + } else { + CidFontType::Type2 + }) + .base_font(Name(base.as_bytes())) + .system_info(info) + .font_descriptor(descriptor) + .default_width(0.0); + if !cff { + writer.cid_to_gid_map_predefined(Name(b"Identity")); + } + writer.widths().consecutive(0, widths); + writer.finish(); + let mut writer = self.pdf.font_descriptor(descriptor); + writer + .name(Name(base.as_bytes())) + .flags(FontFlags::SYMBOLIC) + .bbox(Rect::new( + 0.0, + -metrics.descent * scale, + metrics.max_width * scale, + metrics.ascent * scale, + )) + .italic_angle(0.0) + .ascent(metrics.ascent * scale) + .descent(-metrics.descent * scale) + .cap_height(metrics.cap_height * scale) + .stem_v(80.0); + if cff { + writer.font_file3(file); + } else { + writer.font_file2(file); + } + writer.finish(); + let compressed = compress(&subset); + let mut stream = self.pdf.stream(file, &compressed); + stream.filter(Filter::FlateDecode); + if cff { + stream.pair(Name(b"Subtype"), Name(b"OpenType")); + } + stream.finish(); + let mut cmap = UnicodeCmap::new(Name(b"Custom"), info); + for (glyph, text) in &font.text { + cmap.pair_with_multiple(*glyph, text.chars()); + } + let cmap = compress(&cmap.finish()); + self.pdf.stream(map, &cmap).filter(Filter::FlateDecode); + Ok(()) + } +} diff --git a/crates/draw/src/render/text.rs b/crates/draw/src/render/text.rs index 04841e9e772c8ad3b960e41a8e9c5c9980785b78..b748bf3615966ef00c0a8dedeb41d4202ddee9a7 100644 --- a/crates/draw/src/render/text.rs +++ b/crates/draw/src/render/text.rs @@ -1,5 +1,6 @@ use crate::RenderError; use parley::fontique::Blob; +use std::ops::Range; /// Text the renderer can paint, as glyph runs in layer units. Owners recover their /// concrete text from a primitive through `Any`. @@ -32,6 +33,11 @@ pub struct GlyphRun<'a> { pub glyphs: &'a mut dyn Iterator, /// Underline and strikethrough, painted after the glyphs. pub decorations: &'a [Decoration], + /// The source the glyphs show, for a document's text layer; the renderer ignores it. + pub text: &'a str, + /// Each cluster's bytes of `text` and how many glyphs show it, in `glyphs`' order; a + /// ligature's later clusters take none. + pub clusters: &'a mut dyn Iterator, usize)>, } /// A glyph's baseline position. @@ -53,10 +59,12 @@ pub struct Decoration { pub color: Option<[f32; 4]>, } -/// Paints one parley glyph run with its line's baseline moved to `baseline`, its glyphs -/// raised by `rise` and `backdrop` behind them. +/// Paints one parley glyph run of `text` laid out, with its line's baseline moved to +/// `baseline`, its glyphs raised by `rise` and `backdrop` behind them. +#[allow(clippy::too_many_arguments)] pub fn paint_parley_run( run: &parley::GlyphRun<'_, B>, + text: &str, baseline: f32, rise: f32, line: [f32; 2], @@ -104,6 +112,17 @@ pub fn paint_parley_run( x: glyph.x, y: glyph.y + baseline - line_baseline - rise, }); + // The run's clusters are those of its style within its span of the line; walked only + // when a text layer asks. + let [start, end] = [run.offset(), run.offset() + run.advance()]; + let mut at = None; + let mut clusters = run.run().visual_clusters().filter_map(|cluster| { + let x = *at.get_or_insert_with(|| cluster.visual_offset().unwrap_or(start)); + at = Some(x + cluster.advance()); + let within = x > start - 0.01 && x < end - 0.01; + (cluster.style_index() == run.style_index() && within) + .then(|| (cluster.text_range(), cluster.glyphs().count())) + }); paint(GlyphRun { font: &font.data, index: font.index, @@ -116,5 +135,7 @@ pub fn paint_parley_run( line, glyphs: &mut glyphs, decorations: &decorations, + text, + clusters: &mut clusters, }) } diff --git a/crates/mobile/src/lib.rs b/crates/mobile/src/lib.rs index bb7e47b4efd3594c9d93a7759dddc1f7bfe37c84..12a8df2fe9d1e251168758451c7d622345ededca 100644 --- a/crates/mobile/src/lib.rs +++ b/crates/mobile/src/lib.rs @@ -29,7 +29,7 @@ use draw::edit::{Key, NamedKey, SelectionUnit}; use onestore::{ ExGuid, op::{Edit, Op, PageOp}, - page::{Image, Page, ink::ShapeKind}, + page::{Page, ink::ShapeKind}, }; use parley::{Affinity, BoundingBox}; use std::{ @@ -503,25 +503,9 @@ impl Canvas { Ok(moved(self.page.set_paper(color, rules, Some(art))?)) } - /// Puts a picture of `size` points after the caret's paragraph. + /// Puts a picture of `size` points at the caret. fn insert_picture(&mut self, bytes: &[u8], size: [f32; 2]) -> Result { - let image = 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 page = &mut self.page; - page.editor.insert_picture(&mut page.engine, image)?; - Ok(true) + Ok(moved(self.page.insert_picture(bytes.to_vec(), size)?)) } /// Gives the page date `seconds` since 1970 (keeping the stored fraction of a second), @@ -1257,8 +1241,8 @@ pub extern "C" fn sb_view_page_text(view: &View) -> *mut c_char { owned(view.canvas.page_text()) } -/// Puts the picture file `bytes` (JPEG or PNG), `width` × `height` points, after the -/// caret's paragraph. +/// 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. /// /// # Safety /// `bytes` holds `length` bytes. diff --git a/crates/mobile/src/tests.rs b/crates/mobile/src/tests.rs index 8a0acf7c282f2e2158de67ee10c8498a633a4e66..c1c4f60b0e29b04e67442bc69e4eb4d43cddc4f8 100644 --- a/crates/mobile/src/tests.rs +++ b/crates/mobile/src/tests.rs @@ -430,7 +430,7 @@ fn find_selects_the_match_and_formatting_reports_and_toggles() { } #[test] -fn a_picture_goes_after_the_caret_paragraph_and_undoes() { +fn a_picture_goes_in_at_the_caret_and_undoes() { let (_directory, section) = features(); let mut canvas = canvas(§ion, "Paragraph controls"); focus(&mut canvas, "Collapsed parent"); diff --git a/crates/notebook/Cargo.toml b/crates/notebook/Cargo.toml index 9258c4f86855d4698660c154e6e8d3fff601ca21..851557dd3182bb5006faec812b122ceb012b636e 100644 --- a/crates/notebook/Cargo.toml +++ b/crates/notebook/Cargo.toml @@ -23,6 +23,9 @@ zeroize = { version = "1.9.0", optional = true } [target.'cfg(target_vendor = "apple")'.dependencies] nix = { version = "0.31", default-features = false, features = ["fs"] } +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] } + [dev-dependencies] libc = "0.2" serde_json = "1" diff --git a/crates/notebook/src/lib.rs b/crates/notebook/src/lib.rs index daea3066e210c21026b023c8002a90bfd20e2c21..d7db2dc031a1592a5751fe4055be3543fac0bc4f 100644 --- a/crates/notebook/src/lib.rs +++ b/crates/notebook/src/lib.rs @@ -1,4 +1,5 @@ -#![forbid(unsafe_code)] +// One Win32 call, where std has no safe form: `session`'s hidden attribute. +#![deny(unsafe_code)] #![doc = include_str!("../README.md")] pub mod discover; diff --git a/crates/notebook/src/session.rs b/crates/notebook/src/session.rs index c043efed2bc82cbbfd15cd6c8d836e38860e5b6b..309ddf3bafc58fc0833fed69a2392a31ac04eb1e 100644 --- a/crates/notebook/src/session.rs +++ b/crates/notebook/src/session.rs @@ -168,7 +168,9 @@ impl Storage for Directory { Ok(std::fs::create_dir(self.path(path))?) } - /// macOS keeps the attribute as `UF_HIDDEN`, and passes it on to a share it mounted. + /// macOS keeps the attribute as `UF_HIDDEN`, and passes it on to a share it mounted; + /// Linux has no attribute to set, a dot folder being hidden there already. + #[cfg_attr(windows, allow(unsafe_code))] fn hide(&self, path: &str) -> Result<()> { #[cfg(target_vendor = "apple")] { @@ -181,7 +183,29 @@ impl Storage for Directory { .map_err(io::Error::from)?; } } - #[cfg(not(target_vendor = "apple"))] + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_ATTRIBUTE_HIDDEN, GetFileAttributesW, INVALID_FILE_ATTRIBUTES, + SetFileAttributesW, + }; + let path: Vec = self + .path(path) + .as_os_str() + .encode_wide() + .chain([0]) + .collect(); + // SAFETY: `path` is a NUL-terminated UTF-16 string that outlives both calls. + let attributes = unsafe { GetFileAttributesW(path.as_ptr()) }; + if attributes == INVALID_FILE_ATTRIBUTES + || unsafe { SetFileAttributesW(path.as_ptr(), attributes | FILE_ATTRIBUTE_HIDDEN) } + == 0 + { + return Err(io::Error::last_os_error().into()); + } + } + #[cfg(not(any(target_vendor = "apple", windows)))] let _ = path; Ok(()) } diff --git a/crates/onestore/examples/opts29_dump.rs b/crates/onestore/examples/opts29_dump.rs new file mode 100644 index 0000000000000000000000000000000000000000..9fd4bf57f6bce83ca59d4fb7f4742eb7e855f30f --- /dev/null +++ b/crates/onestore/examples/opts29_dump.rs @@ -0,0 +1,29 @@ +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/snowbound/Cargo.toml b/crates/snowbound/Cargo.toml index 708a3a25438b6121fb13e2fdfbb0454afc67d278..a582ecf29cd0fbe27eff2fe1b7e75524912468bb 100644 --- a/crates/snowbound/Cargo.toml +++ b/crates/snowbound/Cargo.toml @@ -10,7 +10,7 @@ default = ["wgpu"] wgpu = ["draw/wgpu", "dep:wgpu"] [dependencies] -canvas = { path = "../canvas", features = ["interaction"] } +canvas = { path = "../canvas", features = ["interaction", "pdf"] } draw = { path = "../draw", default-features = false, features = ["render"] } ui = { path = "../ui" } png = "0.18" @@ -39,6 +39,10 @@ objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAl objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSGeometry", "NSLocale", "NSPathUtilities", "NSTimeZone", "NSURL", "NSArray", "NSRange", "NSError", "NSFileCoordinator", "NSFilePresenter", "NSFileVersion", "NSFileManager", "NSNotification", "NSOperation", "block2"] } block2 = "0.5.1" +[target.'cfg(not(target_os = "macos"))'.dependencies] +# Clipboard bitmaps; macOS reads the pasteboard's PNG and TIFF itself. +arboard = { version = "3.6.1", default-features = false, features = ["image-data"] } + [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = [ "Win32_Foundation", diff --git a/crates/snowbound/assets/icon/Assets.car b/crates/snowbound/assets/icon/Assets.car new file mode 100644 index 0000000000000000000000000000000000000000..782e18079fb131583a46fa6eca0273a9af6b11a2 Binary files /dev/null and b/crates/snowbound/assets/icon/Assets.car differ diff --git a/crates/snowbound/assets/icon/README.md b/crates/snowbound/assets/icon/README.md new file mode 100644 index 0000000000000000000000000000000000000000..e7ea3257bf083751bff0edbf7e72a288b2845441 --- /dev/null +++ b/crates/snowbound/assets/icon/README.md @@ -0,0 +1,20 @@ +# App icon + +`Assets.car` is `sources/Snowbound-Tahoe.icon` compiled by Xcode 26.0.1's `actool`, +which can leave out the flattened renditions later versions always add. macOS 26 draws +the Liquid Glass icon from it; earlier versions find no rendition there and draw +`Snowbound-Sequoia.icns` (`tools/canvas/build_macos.py` bundles both). After changing +the `.icon`, regenerate it with Xcode 26.0.1 (not newer), for the Intel floor: + +```sh +DEVELOPER_DIR=/Applications/Xcode-26.0.1.app/Contents/Developer xcrun actool \ + sources/Snowbound-Tahoe.icon --compile OUT --app-icon Snowbound-Tahoe \ + --enable-on-demand-resources NO --development-region en --target-device mac \ + --platform macosx --enable-icon-stack-fallback-generation=disabled \ + --include-all-app-icons --minimum-deployment-target 10.13 \ + --output-partial-info-plist OUT/partial.plist +cp OUT/Assets.car Assets.car +``` + +It warns "Failed to generate flattened icon stack", which is the point. +`xcrun assetutil --info Assets.car` should list no `Icon Image` renditions. diff --git a/crates/snowbound/linux/README.md b/crates/snowbound/linux/README.md index 66a7640cbacac98e6caab0ef66de4b413390d1dc..17d4235b3d64ce57518173261e6c1af2dd71eafb 100644 --- a/crates/snowbound/linux/README.md +++ b/crates/snowbound/linux/README.md @@ -30,8 +30,8 @@ Snowbound. ## Requirements -- x86_64 or aarch64 Linux with glibc 2.31 or newer (Debian 11, Ubuntu 20.04, - Fedora 32 and later). +- x86_64 or aarch64 Linux with glibc 2.17 or newer (RHEL 7, Debian 8, + Ubuntu 14.04 and later). - A Vulkan driver (Mesa's are standard) or, failing that, OpenGL ES 3 through EGL. `WGPU_BACKEND=gl ./bin/snowbound ...` forces OpenGL. - fontconfig, and X11 or Wayland with libxkbcommon. diff --git a/crates/snowbound/linux/package.sh b/crates/snowbound/linux/package.sh index 6c0c97f20d89b5c3cda59d5c5201856b9d66c75c..fa7b4d5a86b8b209c74e60c0fb5e99ea34fb4beb 100755 --- a/crates/snowbound/linux/package.sh +++ b/crates/snowbound/linux/package.sh @@ -17,15 +17,15 @@ for arch do triple=$arch-unknown-linux-gnu variable=$(echo "$triple" | tr 'a-z-' 'A-Z_') linker="$tools/cc-$arch" - # glibc 2.31 reaches back to Debian 11 and Ubuntu 20.04; zig rejects rustc's - # --target, --no-undefined-version and -O1. + # glibc 2.17, Rust's own floor, reaches back to RHEL 7, Debian 8 and Ubuntu 14.04; zig + # rejects rustc's --target, --no-undefined-version and -O1. cat > "$linker" </dev/null diff --git a/crates/snowbound/src/commands.rs b/crates/snowbound/src/commands.rs index 6843120bdc1ebbb5edab45fbdb0de19ffcd9c757..f4413b09a2c528012ced184620cd63578e6096e0 100644 --- a/crates/snowbound/src/commands.rs +++ b/crates/snowbound/src/commands.rs @@ -23,6 +23,9 @@ pub enum Id { PageVersions, CopyPageLink, ShowNotebook, + ExportPdf, + ExportSectionPdf, + Print, Undo, Redo, Cut, @@ -44,7 +47,7 @@ pub enum Id { ActualSize, Sidebar, PageList, - DarkPages, + PagesMatchTheme, FullPageView, /// OneNote's Hide Spelling Errors, which leaves misspelled words unmarked. HideSpelling, @@ -71,6 +74,7 @@ pub enum Id { Eraser, Lasso, Shape(ShapeKind), + SnapToGrid, Toggle(Toggle), Highlight, FontColor, @@ -235,6 +239,15 @@ pub const COMMANDS: &[Command] = &[ NONE, 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, + "Print…", + &[cmd('p').option().shift()], + &[cmd('p').option().shift()] + ), row!(Id::Undo, "Undo", &[cmd('z')], &[cmd('z')]), row!( Id::Redo, @@ -296,7 +309,7 @@ pub const COMMANDS: &[Command] = &[ ), row!(Id::Sidebar, "Notebook List", &[cmd('s').control()], NONE), row!(Id::PageList, "Page List", NONE, NONE), - row!(Id::DarkPages, "Dark Pages", NONE, NONE), + row!(Id::PagesMatchTheme, "Pages Match UI Theme", NONE, NONE), row!(Id::HideSpelling, "Hide Spelling Errors", NONE, NONE), row!( Id::Spelling, @@ -387,6 +400,7 @@ pub const COMMANDS: &[Command] = &[ row!(Id::Shape(ShapeKind::Arrow), "Arrow", NONE, NONE), row!(Id::Shape(ShapeKind::Rectangle), "Rectangle", NONE, NONE), row!(Id::Shape(ShapeKind::Ellipse), "Oval", NONE, NONE), + row!(Id::SnapToGrid, "Snap To Grid", NONE, NONE), row!(Id::Toggle(Toggle::Bold), "Bold", &[cmd('b')], &[cmd('b')]), row!( Id::Toggle(Toggle::Italic), @@ -773,9 +787,14 @@ impl State { .notebook() .is_some_and(|library| library.catalog().is_some()), ), - Id::NewPage | Id::NewSubpage | Id::CopyPageLink | Id::Find | Id::Search => { - enabled(!modal && session.is_some()) - } + Id::NewPage + | Id::NewSubpage + | Id::CopyPageLink + | Id::Find + | Id::Search + | Id::ExportPdf + | Id::ExportSectionPdf + | Id::Print => enabled(!modal && session.is_some()), Id::PageVersions => session.filter(|_| !modal).map_or( Status { enabled: false, @@ -821,10 +840,14 @@ impl State { Some(crate::pane::Pane::Tags { .. }) )), }, - Id::DarkPages => Status { + Id::PagesMatchTheme => Status { enabled: !modal, checked: Some(!self.light_pages), }, + Id::SnapToGrid => Status { + enabled: !modal, + checked: Some(self.view.snap_to_grid), + }, Id::HideSpelling => Status { enabled: !modal && self.spelling.is_some(), checked: Some(self.hide_spelling), @@ -917,26 +940,6 @@ impl State { Ok(()) } - /// Puts an encoded picture after the caret's paragraph at its size on a 96 dpi screen, - /// as OneNote 2010 inserts one. - pub(crate) fn insert_picture(&mut self, bytes: Vec) -> Result<(), Box> { - let pixels = match draw::RasterImage::measure(&bytes) { - Ok(pixels) => pixels, - Err(_) => { - platform::alert( - "Couldn't insert the picture", - "Choose a PNG, JPEG or GIF picture.", - ); - return Ok(()); - } - }; - let response = self - .view - .insert_picture(bytes, pixels.map(|side| side as f32 * 0.75))?; - self.respond(response); - Ok(()) - } - /// The notebook the open section, or the notebook showing none, belongs to. fn notebook(&self) -> Option<&Arc> { self.session @@ -967,12 +970,10 @@ impl State { return self.paper_page(color, self.view.editor.rule_lines(), None); } Choice::Art(name) => { - let editor = &self.view.editor; - return self.paper_page( - editor.page_color(), - editor.rule_lines(), - Some(name.and_then(canvas::template::find)), - ); + return self.with_art(name.and_then(canvas::template::find), |state, art| { + let editor = &state.view.editor; + state.paper_page(editor.page_color(), editor.rule_lines(), Some(art)) + }); } Choice::RuleLines(lines) => { let lines = lines.map(|index| canvas::template::RULE_LINES[index].1); @@ -1031,6 +1032,14 @@ 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); @@ -1147,7 +1156,12 @@ impl State { self.save_settings(); return Ok(()); } - Id::DarkPages => { + Id::SnapToGrid => { + self.view.snap_to_grid = !self.view.snap_to_grid; + self.save_settings(); + return Ok(()); + } + Id::PagesMatchTheme => { self.light_pages = !self.light_pages; self.follow_color_scheme(); self.save_settings(); diff --git a/crates/snowbound/src/desktop_linux.rs b/crates/snowbound/src/desktop_linux.rs index 76a9c9428cec15144f9c48ab9af7d59fcbaeaf96..0217033fcbb06960ff67e094435de1c7b9e6f53b 100644 --- a/crates/snowbound/src/desktop_linux.rs +++ b/crates/snowbound/src/desktop_linux.rs @@ -291,8 +291,15 @@ pub fn set_toplevel_icon(window: &Window) -> Option<()> { ]); } } - // Safety: memfd_create returns a new descriptor this File then owns. - let fd = unsafe { libc::memfd_create(c"snowbound-icon".as_ptr(), libc::MFD_CLOEXEC) }; + // Safety: memfd_create returns a new descriptor this File then owns. A system call, as + // glibc wraps it only from 2.27. + let fd = unsafe { + libc::syscall( + libc::SYS_memfd_create, + c"snowbound-icon".as_ptr(), + libc::MFD_CLOEXEC, + ) + } as i32; if fd < 0 { return None; } diff --git a/crates/snowbound/src/guide.rs b/crates/snowbound/src/guide.rs new file mode 100644 index 0000000000000000000000000000000000000000..9733a8c958b19f2a3a4d2692c43ae194689a0548 --- /dev/null +++ b/crates/snowbound/src/guide.rs @@ -0,0 +1,106 @@ +//! The Snowbound Guide, a notebook the app carries (`docs/guide-notebook`). It opens from a +//! copy in the user's documents, so the one inside the app is never edited. + +use crate::{State, platform}; +use notebook::session::Notebook; +use std::{error::Error, path::Path}; + +const NAME: &str = "Snowbound Guide"; +/// Whether the welcome screen offers the guide; off until Clover has read it through. +pub(crate) const OFFERED: bool = false; + +macro_rules! file { + ($name:literal) => { + ( + $name, + include_bytes!(concat!( + "../../../docs/guide-notebook/Snowbound Guide/", + $name + )) + .as_slice(), + ) + }; +} + +const FILES: [(&str, &[u8]); 6] = [ + file!("Open Notebook.onetoc2"), + file!("Getting Started.one"), + file!("Writing & Formatting.one"), + file!("Tags & Search.one"), + file!("Drawing & Recording.one"), + file!("Sharing & Sync.one"), +]; + +impl State { + /// Opens the guide in the user's documents, copying it there first unless it is there. + pub(crate) fn open_guide(&mut self) -> Result<(), Box> { + let documents = platform::documents_dir().ok_or("There is no Documents folder")?; + let folder = std::path::absolute(documents.join(NAME))?; + if !folder.exists() { + copy(&folder, &self.cache)?; + } + self.open_notebook(folder.to_string_lossy().into_owned(), None); + Ok(()) + } +} + +/// Writes the guide to `folder` under a temporary name, then gives it its own, so a copy cut +/// short never passes for the guide. +fn copy(folder: &Path, cache: &Path) -> Result<(), Box> { + let partial = folder.with_file_name(format!("{NAME}.partial")); + if partial.exists() { + std::fs::remove_dir_all(&partial)?; + } + std::fs::create_dir_all(&partial)?; + for (name, bytes) in FILES { + std::fs::write(partial.join(name), bytes)?; + } + // The snowflake tag's art, kept in the notebook as Customize Tags keeps it. + Notebook::open(&partial, cache)?.map_tag_art( + "Snowflake", + 34, + include_bytes!("../assets/tags/snowflake.svg"), + "svg", + )?; + std::fs::rename(&partial, folder)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The carried files are the committed guide's, and the copy reads as that notebook with + /// its tag art. + #[test] + fn the_guide_copies_whole() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/guide-notebook"); + let mut committed: Vec = std::fs::read_dir(root.join(NAME)) + .unwrap() + .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) + .filter(|name| !name.starts_with('.')) + .collect(); + committed.sort(); + let mut carried: Vec = FILES.iter().map(|(name, _)| name.to_string()).collect(); + carried.sort(); + assert_eq!(carried, committed); + + let temporary = + std::env::temp_dir().join(format!("snowbound-guide-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&temporary); + let folder = temporary.join(NAME); + copy(&folder, &temporary.join("cache")).unwrap(); + let notebook = Notebook::open(&folder, temporary.join("cache")).unwrap(); + assert_eq!(notebook.catalog().sections.len(), 5); + let art = notebook.tag_art().unwrap(); + assert_eq!( + art.iter() + .map(|tag| (tag.name.as_str(), tag.shape)) + .collect::>(), + [("Snowflake", 34)] + ); + let committed = std::fs::read_to_string(root.join(NAME).join(".snowbound/tags.json")); + assert!(committed.unwrap().contains(&art[0].art)); + std::fs::remove_dir_all(&temporary).unwrap(); + } +} diff --git a/crates/snowbound/src/linux.rs b/crates/snowbound/src/linux.rs index 360cc5a4873837bc1936f5d733adbbf30b1d15cc..feff722e9b0d715eae0446d9d593451cb6820cf6 100644 --- a/crates/snowbound/src/linux.rs +++ b/crates/snowbound/src/linux.rs @@ -75,6 +75,9 @@ pub fn corner_radius(window: &Window) -> f32 { /// Whether the window erases its own bottom corners to transparent pixels: under winit's /// Adwaita frame on GNOME's Wayland, which draws libadwaita's window edge. Mutter's X11 /// frames stay square at the bottom, as libadwaita's own `ssd-frame` style has them. +/// Winit's theme already holds the window to the app's appearance. +pub fn follow_appearance(_: &Window, _: Theme) {} + pub fn cuts_corners() -> bool { sctk_adwaita::LIBADWAITA_EDGE.load(Ordering::Relaxed) } @@ -197,6 +200,26 @@ impl Clipboard { Self::X11(clipboard) => clipboard.get_text()?, }) } + + pub fn get_files(&mut self) -> Vec { + self.through_x11(|clipboard| clipboard.get().file_list().ok()) + .unwrap_or_default() + } + + 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. + fn through_x11( + &mut self, + read: impl FnOnce(&mut arboard::Clipboard) -> Option, + ) -> Option { + match self { + Self::Wayland(_) => read(&mut arboard::Clipboard::new().ok()?), + Self::X11(clipboard) => read(clipboard), + } + } } /// Whether the system draws the title bar: the window manager, or on Wayland the compositor. @@ -254,7 +277,7 @@ fn desktop() -> Desktop { } /// The theme over the window's backdrop, as the kit draws it. -pub fn over_backdrop(theme: ui::Theme) -> ui::Theme { +pub fn over_backdrop(theme: ui::Theme, _: Theme) -> ui::Theme { theme.over_backdrop() } @@ -767,7 +790,7 @@ pub fn cache_dir() -> Option { pub fn smb_mount(path: &std::path::Path) -> Option { let mounts = std::fs::read_to_string("/proc/self/mounts").ok()?; // The mount holding the path is the one at its longest ancestor. - let (source, point, options, within) = mounts + let (source, _, options, within) = mounts .lines() .filter_map(|line| { let mut fields = line.split_whitespace(); @@ -873,6 +896,25 @@ pub fn settings_dir() -> Option { xdg_dir("XDG_CONFIG_HOME", ".config") } +/// The XDG documents folder `user-dirs.dirs` names, or `~/Documents`. +pub fn documents_dir() -> Option { + let home = PathBuf::from(std::env::var_os("HOME")?); + let dirs = xdg_base("XDG_CONFIG_HOME", ".config")?.join("user-dirs.dirs"); + let named = std::fs::read_to_string(dirs).ok().and_then(|text| { + text.lines().find_map(|line| { + let value = line + .strip_prefix("XDG_DOCUMENTS_DIR=")? + .trim() + .trim_matches('"'); + match value.strip_prefix("$HOME") { + Some(rest) => Some(home.join(rest.trim_start_matches('/'))), + None => Some(PathBuf::from(value)).filter(|path| path.is_absolute()), + } + }) + }); + Some(named.unwrap_or_else(|| home.join("Documents"))) +} + /// The app's folder in the XDG base directory `variable` names, or in `fallback` under home. fn xdg_dir(variable: &str, fallback: &str) -> Option { Some(xdg_base(variable, fallback)?.join("snowbound")) diff --git a/crates/snowbound/src/macos.rs b/crates/snowbound/src/macos.rs index 442f85bf7c407779a7e54e45920f8a1b3898372d..ff3297dc8920a5a1807f499e985f74690b1f28ab 100644 --- a/crates/snowbound/src/macos.rs +++ b/crates/snowbound/src/macos.rs @@ -109,6 +109,9 @@ pub fn corner_radius(_: &Window) -> f32 { /// AppKit clips the window's corners itself, except where the OpenGL surface covers /// 10.6's textured window, whose rounded bottom corners the app leaves transparent. +/// Winit's theme already holds the window to the app's appearance. +pub fn follow_appearance(_: &Window, _: winit::window::Theme) {} + pub fn cuts_corners() -> bool { crate::aqua::before_lion() } @@ -185,6 +188,50 @@ impl Clipboard { pub fn get_text(&mut self) -> Result { self.0.get_text() } + + pub fn get_files(&mut self) -> Vec { + self.0.get().file_list().unwrap_or_default() + } + + /// The pasteboard's PNG as copied, keeping its resolution, or else its TIFF as a PNG. + pub fn get_picture(&mut self) -> Option> { + unsafe { + let board: Retained = + msg_send_id![AnyClass::get("NSPasteboard")?, generalPasteboard]; + let data = |kind: &str| -> Option> { + msg_send_id![&board, dataForType: &*NSString::from_str(kind)] + }; + let png = match data("public.png") { + Some(png) => png, + None => tiff_png(&*data("public.tiff")?)?, + }; + Some(ns_data_bytes(&png)) + } + } +} + +/// TIFF `data` as PNG data, keeping its resolution. +unsafe fn tiff_png(data: &AnyObject) -> Option> { + const PNG: usize = 4; // NSBitmapImageFileTypePNG + unsafe { + let rep: Option> = + msg_send_id![AnyClass::get("NSBitmapImageRep")?, imageRepWithData: data]; + let rep = rep?; + let properties: Retained = + msg_send_id![AnyClass::get("NSDictionary")?, dictionary]; + msg_send_id![&rep, representationUsingType: PNG, properties: &*properties] + } +} + +unsafe fn ns_data_bytes(data: &AnyObject) -> Vec { + unsafe { + let length: usize = msg_send![data, length]; + if length == 0 { + return Vec::new(); + } + let bytes: *const u8 = msg_send![data, bytes]; + std::slice::from_raw_parts(bytes, length).to_vec() + } } /// AppKit draws the traffic lights. @@ -246,9 +293,14 @@ pub fn represent(window: &Window, file: Option<&std::path::Path>) { } } -/// The theme over the window's backdrop, as the kit draws it. -pub fn over_backdrop(theme: ui::Theme) -> ui::Theme { - theme.over_backdrop() +/// The theme over the window's backdrop, as the kit draws it. 10.6's textured gradient is +/// light only, so a dark theme paints over it. +pub fn over_backdrop(theme: ui::Theme, appearance: winit::window::Theme) -> ui::Theme { + if crate::aqua::before_lion() && appearance == winit::window::Theme::Dark { + theme + } else { + theme.over_backdrop() + } } /// None: the kit's own menus stand in for AppKit's. @@ -634,6 +686,10 @@ pub fn settings_dir() -> Option { ) } +pub fn documents_dir() -> Option { + Some(std::path::PathBuf::from(std::env::var_os("HOME")?).join("Documents")) +} + /// Asks for a notebook folder or a notebook file with the system's open panel, titled /// `title`. pub fn pick_notebook(title: &str) -> Option { @@ -899,7 +955,10 @@ pub fn edit_date( DateField::Date => NSDatePickerElementFlags::NSDatePickerElementFlagYearMonthDay, DateField::Time => NSDatePickerElementFlags::NSDatePickerElementFlagHourMinute, }); - picker.setPresentsCalendarOverlay(true); + // macOS 10.15's. + if msg_send![&picker, respondsToSelector: sel!(setPresentsCalendarOverlay:)] { + picker.setPresentsCalendarOverlay(true); + } picker.setCalendar(Some(&calendar)); picker.setTimeZone(Some(&calendar.timeZone())); picker.setDateValue(&NSDate::dateWithTimeIntervalSince1970( @@ -1106,10 +1165,21 @@ pub fn text_colors(window: &Window) -> [[f32; 4]; 3] { let previous: Option> = msg_send_id![class, currentAppearance]; let _: () = msg_send![class, setCurrentAppearance: &*appearance]; let space = NSColorSpace::sRGBColorSpace(); + let responds = + |selector: Sel| -> bool { msg_send![NSColor::class(), respondsToSelector: selector] }; + // macOS 14 and 10.14 introduced the first and last. let colors = [ - NSColor::textInsertionPointColor(), + if responds(sel!(textInsertionPointColor)) { + NSColor::textInsertionPointColor() + } else { + NSColor::textColor() + }, NSColor::selectedTextBackgroundColor(), - NSColor::unemphasizedSelectedTextBackgroundColor(), + if responds(sel!(unemphasizedSelectedTextBackgroundColor)) { + NSColor::unemphasizedSelectedTextBackgroundColor() + } else { + msg_send_id![NSColor::class(), secondarySelectedControlColor] + }, ] .map(|color| { let color = color diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 684f1eafae212644f3c8891d1378c8e3885a5a98..3de2a1c0ccc224f4170877300e062c5a8e27da92 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -10,6 +10,7 @@ mod conflict_render; #[cfg(target_os = "linux")] #[path = "desktop_linux.rs"] mod desktop; +mod guide; mod history; #[cfg_attr(not(target_os = "macos"), path = "icloud_linux.rs")] #[cfg_attr(target_os = "macos", path = "icloud_macos.rs")] @@ -29,10 +30,16 @@ mod navigation; mod options; mod palette; mod pane; +mod paste; #[cfg_attr(target_os = "linux", path = "linux.rs")] #[cfg_attr(target_os = "macos", path = "macos.rs")] #[cfg_attr(windows, path = "windows.rs")] mod platform; +mod print; +#[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")] +mod printer; mod recording; mod rename; mod screenshot; @@ -96,6 +103,9 @@ const TAB_ROW: f32 = 28.0; /// Width of the section colour around the page, and the rows' margin at the window's sides. const FRAME: f32 = 6.0; const PAGE_LIST: f32 = 240.0; +/// The least window: the page list and a page beside it. The window widens past it to the +/// toolbar's narrowest row, the window's controls beside it. +const MIN_SIZE: [f32; 2] = [600.0, 400.0]; /// Extensions of the pictures Insert, Picture offers: those the page both stores and draws. const PICTURE_TYPES: [&str; 4] = ["png", "jpg", "jpeg", "gif"]; /// Font sizes the size box offers, OneNote's list in points. @@ -163,7 +173,8 @@ const PILL_MARGIN: f32 = 3.0; const DATE_UNCHOSEN: &str = "Choose another date or time."; const DATE_OUT_OF_RANGE: &str = "This date is outside the notebook's supported range."; -#[derive(Debug)] +type Continuation = Box Result<(), Box> + Send>; + enum UserEvent { Quit, /// Text AppKit inserts outside key events, such as the character palette's. @@ -187,6 +198,8 @@ enum UserEvent { /// The desktop's colours changed, where the window system doesn't say so itself. #[cfg_attr(target_os = "macos", allow(dead_code))] Appearance, + /// What follows work a thread of its own finished, run on the event loop's. + Then(Continuation), /// 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, @@ -209,11 +222,21 @@ impl Clipboard { Ok(()) } - fn get_text(&mut self) -> Result> { - Ok(match self { - Self::System(clipboard) => clipboard.get_text()?, - Self::Memory(held) => held.clone(), - }) + fn pasted(&mut self) -> Option { + match self { + Self::System(clipboard) => { + let files = clipboard.get_files(); + if !files.is_empty() { + Some(paste::Pasted::Files(files)) + } else if let Some(text) = clipboard.get_text().ok().filter(|text| !text.is_empty()) + { + Some(paste::Pasted::Text(text)) + } else { + clipboard.get_picture().map(paste::Pasted::Picture) + } + } + Self::Memory(held) => Some(paste::Pasted::Text(held.clone())), + } } } @@ -226,6 +249,8 @@ impl std::task::Wake for Redraw { #[derive(Debug)] enum Replay { Input(ui::Event), + /// A trackpad pinch scaling the page by the factor. + Pinch(f32), /// Paints the next frame into a PNG as well as the window. Snapshot(PathBuf), /// Writes the window's accessibility tree as text. @@ -496,6 +521,8 @@ enum Command { NewNotebook, /// Opens the notebooks in the app's iCloud Drive folder, making one there if it has none. UseICloud, + /// Opens the Snowbound Guide from the user's documents, copying it there first. + OpenGuide, /// Adds Snowbound to the app menu. #[cfg(target_os = "linux")] Install, @@ -622,6 +649,14 @@ 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. + navigation_bar_right: bool, + /// Servers Open Notebook from Server signed in to, latest first. + servers: Vec, /// The word the Spelling pane shows. correction: Option, /// The strip's fill with the window focused and not, continuing the system's title bar. @@ -681,6 +716,8 @@ struct State { initial_layouts: Vec<(onestore::ExGuid, onestore::document::Layout)>, initial_date: Option, occluded: bool, + /// The least width last given the window. + min_width: f32, ime_allowed: bool, clipboard: Clipboard, access_adapter: accesskit_winit::Adapter, @@ -746,8 +783,7 @@ impl State { Input::Notebooks { .. } => "Snowbound".into(), }) .with_inner_size(LogicalSize::new(1180.0, 760.0)) - // The page list and a page beside it, under the toolbar's text group. - .with_min_inner_size(LogicalSize::new(600.0, 400.0)), + .with_min_inner_size(LogicalSize::new(MIN_SIZE[0], MIN_SIZE[1])), )?, ); platform::install_title_bar(&window); @@ -872,6 +908,7 @@ impl State { .color_scheme .theme() .unwrap_or_else(|| platform::appearance(&window)); + platform::follow_appearance(&window, appearance); let mut ui = Ui::new( theme(appearance, stored.light_pages, backdrop), platform::double_click_interval(), @@ -975,6 +1012,7 @@ impl State { initial_date, initial_layouts, occluded: false, + min_width: MIN_SIZE[0], ime_allowed: true, clipboard, loads: mpsc::channel(), @@ -991,8 +1029,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; // 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() @@ -1119,6 +1162,7 @@ impl State { /// Takes `appearance`'s colours. fn set_appearance(&mut self, appearance: winit::window::Theme) { + platform::follow_appearance(&self.window, appearance); self.ui.theme = theme(appearance, self.light_pages, self.surface.translucent()); self.titlebar = platform::titlebar(appearance) .filter(|_| !self.surface.translucent()) @@ -1619,11 +1663,11 @@ impl State { role: Some(accesskit::Role::Toolbar), ..Spec::default() }; - if strip_row { - self.ui.open_as(strip(), spec); + let row = if strip_row { + self.ui.open_as(strip(), spec) } else { - self.ui.open("toolbar", spec); - } + self.ui.open("toolbar", spec) + }; let lead = if title { platform::LEADING } else { FRAME }; self.ui.leaf( "lead", @@ -1633,7 +1677,22 @@ impl State { }, ); if tools { + // The groups fold in the room the window's controls leave, and whatever still + // overflows is cut there, so the controls always show. + let edge = (TITLE - ui::shell::TOOL) / 2.0; + self.ui.open( + "tools", + Spec { + flags: Flags::CLIP, + size: [fill(), px(TITLE)], + pad: [0.0, edge], + offset: [0.0, -edge], + gap: GAP, + ..Spec::default() + }, + ); self.tools(theme); + self.ui.close(); } else { self.ui.leaf( "space", @@ -1654,6 +1713,15 @@ impl State { }, ); self.ui.close(); + let narrowest = self + .ui + .narrowest(row) + .map_or(MIN_SIZE[0], |row| row.ceil().max(MIN_SIZE[0])); + if narrowest != self.min_width { + self.min_width = narrowest; + self.window + .set_min_inner_size(Some(LogicalSize::new(narrowest, MIN_SIZE[1]))); + } Ok(()) } @@ -2146,13 +2214,17 @@ impl State { ) .or(choice); // The Draw tab's tools, which Insert's menus list for when they fold, before Insert. + // OneNote's shape gallery ends with Snap To Grid. let shapes = [ ShapeKind::Line, ShapeKind::Arrow, ShapeKind::Rectangle, ShapeKind::Ellipse, ] - .map(|kind| Run(Cmd::Shape(kind))); + .map(|kind| Run(Cmd::Shape(kind))) + .into_iter() + .chain([Rule, Run(Cmd::SnapToGrid)]) + .collect::>(); let drawing = [ Run(Cmd::SelectType), Open("pen", Cmd::Pen), @@ -2161,7 +2233,7 @@ impl State { Rule, ] .into_iter() - .chain(shapes); + .chain(shapes.iter().copied()); let inserted = [Cmd::Picture, Cmd::Link, Cmd::Date, Cmd::Equation]; let more = [ Cmd::ScreenClipping, @@ -2320,7 +2392,7 @@ impl State { let views = [ Run(Cmd::Sidebar), Run(Cmd::PageList), - Run(Cmd::DarkPages), + Run(Cmd::PagesMatchTheme), Run(Cmd::FullPageView), Rule, Run(Cmd::HideSpelling), @@ -2702,6 +2774,7 @@ impl State { Command::OpenFromServer(location) => self.open_server(location.as_deref()), Command::NewNotebook => self.new_notebook()?, Command::UseICloud => self.use_icloud()?, + Command::OpenGuide => self.open_guide()?, #[cfg(target_os = "linux")] Command::Install => desktop::install(), Command::CloseNotebook(library) => self.close_notebook(&library), @@ -2723,12 +2796,7 @@ impl State { Command::Template(choice) => self.apply_template(choice)?, Command::Page(Request::EditDate(field)) => self.edit_date(field)?, Command::Page(Request::Copy(text)) => self.clipboard.set_text(text)?, - Command::Page(Request::Paste) => { - let text = self.clipboard.get_text()?; - let language = canvas::language::lcid(&platform::input_language()); - let response = self.view.paste(&text, language)?; - self.respond(response); - } + Command::Page(Request::Paste) => self.paste()?, Command::Page(Request::OpenLink(address)) => self.open_link(&address)?, Command::Page(Request::OpenAttachment(file)) => self.open_attachment(&file)?, Command::Page(Request::Play { file, at_ms }) => self.play(&file, at_ms)?, @@ -3608,7 +3676,14 @@ impl State { /// 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() { - let response = self.view.pinch(factor)?; + // 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)?; self.respond(response); } Ok(()) @@ -3706,7 +3781,7 @@ fn theme(appearance: winit::window::Theme, light_pages: bool, backdrop: bool) -> [theme.paper, theme.paper_ink] = [light.paper, light.paper_ink]; } if backdrop { - platform::over_backdrop(theme) + platform::over_backdrop(theme, appearance) } else { theme } @@ -4921,6 +4996,15 @@ impl ApplicationHandler for App { self.close(event_loop); return; } + UserEvent::Then(then) => { + if let Some(state) = &mut self.state { + if let Err(error) = then(state) { + eprintln!("{error}"); + } + state.window.request_redraw(); + } + return; + } UserEvent::ICloudAccount => { if let Some(state) = &mut self.state { state.icloud_account_changed(); @@ -4966,6 +5050,11 @@ impl ApplicationHandler for App { if let Some(state) = &mut self.state { match replay { Replay::Input(event) => state.input(event), + Replay::Pinch(factor) => { + if let Err(error) = state.pinch(factor) { + eprintln!("{error}"); + } + } Replay::Snapshot(path) => state.snapshot = Some(path), Replay::Accessibility(path) => { if let Err(error) = state.write_accessibility(&path) { @@ -5202,16 +5291,13 @@ impl ApplicationHandler for App { fn write_png(path: &Path, size: [u32; 2], pixels: &[u8]) -> Result<(), Box> { let partial = path.with_extension("partial"); - let mut encoder = png::Encoder::new(std::fs::File::create(&partial)?, size[0], size[1]); - encoder.set_color(png::ColorType::Rgba); - encoder.set_depth(png::BitDepth::Eight); - encoder.write_header()?.write_image_data(pixels)?; + std::fs::write(&partial, paste::png(size, pixels)?)?; std::fs::rename(partial, path)?; Ok(()) } /// Feeds a development script to the window from another thread, one command per line -/// in logical pixels: `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `key NAME`, `type +/// in logical pixels: `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `pinch FACTOR`, `key NAME`, `type /// TEXT`, `modifiers [shift] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`, /// `accessibility TEXT_PATH`, `appearance light|dark`, `resize WIDTH HEIGHT` and `quit`. fn replay(script: String, proxy: EventLoopProxy) -> Result<(), Box> { @@ -5241,6 +5327,7 @@ fn replay(script: String, proxy: EventLoopProxy) -> Result<(), Box None, level => Some(level.parse()?), }))), + "pinch" => Ok(Replay::Pinch(rest.parse()?)), "press" => Ok(Replay::Input(button(true))), "release" => Ok(Replay::Input(button(false))), "key" => Ok(Replay::Input(ui::Event::Key { diff --git a/crates/snowbound/src/manage.rs b/crates/snowbound/src/manage.rs index 0b074803640a74fd5b1ecdf5a2456659b43fc7b6..eedc6bb5272acdc0401b01f9bfe1298a8ebe988a 100644 --- a/crates/snowbound/src/manage.rs +++ b/crates/snowbound/src/manage.rs @@ -7,7 +7,7 @@ use notebook::session::Notebook; use onestore::{ ExGuid, PageCreation, PageEdit, op::{Edit, Op, PageOp, SectionOp}, - page::{Page, PageObject}, + page::{Image, Page, PageObject}, }; use std::{error::Error, sync::Arc}; @@ -124,24 +124,59 @@ impl State { &mut self, choice: crate::templates::Choice, ) -> Result<(), Box> { - self.persist()?; - let session = self.session.as_ref().ok_or("No section is open")?; - let ops = template_ops(&session.section.page(session.space)?, choice)?; - self.edit_page(ops) + use crate::templates::Choice; + let template = match choice { + Choice::Template(name) => { + Some(canvas::template::find(name).ok_or("That template is not available")?) + } + _ => None, + }; + self.with_art(template, move |state, art| { + state.persist()?; + let session = state.session.as_ref().ok_or("No section is open")?; + let ops = template_ops(&session.section.page(session.space)?, choice, art)?; + state.edit_page(ops) + }) + } + + /// Calls `then` with `template`'s pictures, or none without one. Rasterizing and encoding + /// them takes seconds on a slow machine, so they are made on a thread of their own, and + /// `then` runs on this one after if the page they were chosen for is still open. + pub(crate) fn with_art( + &mut self, + template: Option<&'static Template>, + then: impl FnOnce(&mut State, Vec) -> Result<(), Box> + Send + 'static, + ) -> Result<(), Box> { + let Some(template) = template else { + return then(self, Vec::new()); + }; + let space = self.session.as_ref().ok_or("No section is open")?.space; + let proxy = self.proxy.clone(); + std::thread::spawn(move || { + let art = template.pictures().map_err(|error| error.to_string()); + let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| { + if state + .session + .as_ref() + .is_some_and(|open| open.space == space) + { + then(state, art?) + } else { + Ok(()) + } + }))); + }); + Ok(()) } /// View, Page Color and Rule Lines: gives the open page `color`, a COLORREF, - /// `rule_lines` and, when given, `template`'s art or none in place of the art it had, as - /// one undo step. + /// `rule_lines` and, when given, `art` in place of the art it had, as one undo step. pub(crate) fn paper_page( &mut self, color: Option, rule_lines: Option, - art: Option>, + art: Option>, ) -> Result<(), Box> { - let art = art - .map(|template| template.map_or(Ok(Vec::new()), Template::pictures)) - .transpose()?; let response = self.view.set_paper(color, rule_lines, art)?; self.respond(response); Ok(()) @@ -507,14 +542,11 @@ impl State { pub fn template_ops( page: &Page, choice: crate::templates::Choice, + art: Vec, ) -> Result, Box> { use crate::templates::Choice; let mut ops = match choice { - Choice::Template(name) => art_ops( - page, - Some(canvas::template::find(name).ok_or("That template is not available")?), - )?, - Choice::Color(_) => art_ops(page, None)?, + Choice::Template(_) | Choice::Color(_) => art_ops(page, art), Choice::More | Choice::Dismiss | Choice::Colors => return Ok(Vec::new()), }; match choice { @@ -538,9 +570,9 @@ pub fn template_ops( Ok(ops) } -/// The ops giving `page` `template`'s art (OneNote's pictures' places, our recreations' -/// bytes), or none, in place of the background pictures it had. -pub fn art_ops(page: &Page, template: Option<&Template>) -> Result, Box> { +/// The ops giving `page` `art`, a template's pictures, in place of the background pictures +/// it had. +fn art_ops(page: &Page, art: Vec) -> Vec { let mut ops: Vec = page .objects .iter() @@ -560,13 +592,11 @@ pub fn art_ops(page: &Page, template: Option<&Template>) -> Result, _ => true, }) .map(PageObject::id); - for image in template.map_or(Ok(Vec::new()), Template::pictures)? { - ops.push(PageOp::Add { - object: PageObject::Image(image), - before: under, - }); - } - Ok(ops) + ops.extend(art.into_iter().map(|image| PageOp::Add { + object: PageObject::Image(image), + before: under, + })); + ops } #[cfg(test)] @@ -695,7 +725,11 @@ mod tests { unreachable!() }; let background = |space: ExGuid, choice: Choice| { - template_ops(&page(&file, space), choice) + let art = match choice { + Choice::Template(name) => canvas::template::find(name).unwrap().pictures().unwrap(), + _ => Vec::new(), + }; + template_ops(&page(&file, space), choice, art) .unwrap() .into_iter() .map(|op| Op::Page { space, op }) diff --git a/crates/snowbound/src/media_macos.rs b/crates/snowbound/src/media_macos.rs index 8d824fbe7ce28b4b0ce3ce50ab741829bd754de8..6ac39e6f8f33f91cf68bc02282c02abc61cb3d18 100644 --- a/crates/snowbound/src/media_macos.rs +++ b/crates/snowbound/src/media_macos.rs @@ -232,9 +232,14 @@ fn refused(device: &str, pane: &str) -> String { } /// Whether the system refused media of `kind`: AVAuthorizationStatusRestricted or Denied. +/// Before macOS 10.14 it never asks, and nothing is refused. fn denied(kind: &NSString) -> bool { - let status: isize = - unsafe { msg_send![class("AVCaptureDevice"), authorizationStatusForMediaType: kind] }; + let device = class("AVCaptureDevice"); + let selector = sel!(authorizationStatusForMediaType:); + if !unsafe { msg_send![device, respondsToSelector: selector] } { + return false; + } + let status: isize = unsafe { msg_send![device, authorizationStatusForMediaType: kind] }; matches!(status, 1 | 2) } diff --git a/crates/snowbound/src/menubar.rs b/crates/snowbound/src/menubar.rs index 2333437b46bc17de1baf47f1ff31e9ec1382c712..bb0175d6178053e1b92b132c05a20f3fae8efd1f 100644 --- a/crates/snowbound/src/menubar.rs +++ b/crates/snowbound/src/menubar.rs @@ -66,6 +66,11 @@ const MENUS: &[Item] = &[ C(Id::PageVersions), C(Id::CopyPageLink), C(Id::ShowNotebook), + S, + C(Id::ExportPdf), + C(Id::ExportSectionPdf), + S, + C(Id::Print), ], ), Item::Menu( @@ -111,7 +116,7 @@ const MENUS: &[Item] = &[ C(Id::PageList), S, C(Id::PageColor), - C(Id::DarkPages), + C(Id::PagesMatchTheme), C(Id::HideSpelling), C(Id::Spelling), ], diff --git a/crates/snowbound/src/options.rs b/crates/snowbound/src/options.rs index d23763de50f3993487ab351805672e2a9f923251..2b53675799cc90a9c48d51a517c5d2abff774825 100644 --- a/crates/snowbound/src/options.rs +++ b/crates/snowbound/src/options.rs @@ -164,13 +164,13 @@ impl State { match options.page { Page::General => { heading(ui, &theme, "User Interface Options"); - field(ui, "Color scheme:", |ui| { + 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", "Color scheme", current, 140.0, schemes(), true); + 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), @@ -244,16 +244,10 @@ impl State { } Page::Display => { heading(ui, &theme, "Display"); - let dark = !options.light_pages; - if ui::check_box( - ui, - "dark-pages", - "Pages appear dark in the Dark color scheme", - dark, - ) - .clicked + let matching = !options.light_pages; + if ui::check_box(ui, "pages-match-theme", "Pages match UI theme", matching).clicked { - options.light_pages = dark; + options.light_pages = matching; } } Page::Advanced => { diff --git a/crates/snowbound/src/paste.rs b/crates/snowbound/src/paste.rs new file mode 100644 index 0000000000000000000000000000000000000000..b459308c324e58a39f05f70b08acb0046855e5d2 --- /dev/null +++ b/crates/snowbound/src/paste.rs @@ -0,0 +1,184 @@ +//! Pictures and files from the clipboard, and pictures inserted, as OneNote 2010 places them. + +use crate::{State, platform}; +use std::{error::Error, path::PathBuf}; + +/// 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. +pub(crate) enum Pasted { + Files(Vec), + Text(String), + Picture(Vec), +} + +impl State { + pub(crate) fn paste(&mut self) -> Result<(), Box> { + 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)?, + } + } + } + Some(Pasted::Text(text)) => { + let language = canvas::language::lcid(&platform::input_language()); + let response = self.view.paste(&text, language)?; + self.respond(response); + } + Some(Pasted::Picture(bytes)) => self.insert_picture(bytes)?, + 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> { + let Ok(pixels) = draw::RasterImage::measure(&bytes) else { + platform::alert( + "Couldn't insert the picture", + "Choose a PNG, JPEG or GIF picture.", + ); + return Ok(()); + }; + let size = picture_size(&bytes, pixels); + let response = self.view.insert_picture(bytes, size)?; + self.respond(response); + Ok(()) + } +} + +/// 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). +fn picture_size(encoded: &[u8], pixels: [u32; 2]) -> [f32; 2] { + let dpi = resolution(encoded).unwrap_or([96.0; 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. +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, + } + }; + dpi.iter().all(|dpi| *dpi > 0.0).then_some(dpi) +} + +/// 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"))] +pub(crate) fn bitmap(image: arboard::ImageData) -> Option> { + png( + [image.width, image.height].map(|side| side as u32), + &image.bytes, + ) + .ok() +} + +/// Straight RGBA pixels as a PNG without a resolution. +pub(crate) fn png(size: [u32; 2], rgba: &[u8]) -> Result, png::EncodingError> { + let mut bytes = Vec::new(); + let mut encoder = png::Encoder::new(&mut bytes, size[0], size[1]); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder.write_header()?.write_image_data(rgba)?; + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn png_at(pixels_per_metre: Option) -> Vec { + let mut bytes = Vec::new(); + let mut encoder = png::Encoder::new(&mut bytes, 200, 100); + encoder.set_color(png::ColorType::Rgba); + encoder.set_pixel_dims(pixels_per_metre.map(|dots| png::PixelDimensions { + xppu: dots, + yppu: dots, + unit: png::Unit::Meter, + })); + encoder + .write_header() + .unwrap() + .write_image_data(&[0; 200 * 100 * 4]) + .unwrap(); + bytes + } + + fn jfif(units: u8, density: u16) -> Vec { + let mut bytes = b"\xff\xd8\xff\xe0\x00\x10JFIF\0\x01\x01".to_vec(); + bytes.push(units); + bytes.extend(density.to_be_bytes()); + bytes.extend(density.to_be_bytes()); + bytes.extend([0, 0]); + bytes + } + + fn close(size: [f32; 2], expected: [f32; 2]) -> bool { + (0..2).all(|axis| (size[axis] - expected[axis]).abs() < 0.01) + } + + /// OneNote 2010 read these back from a 200 by 100 pixel picture that GDI+ saved at 72 + /// and 48 dpi (lab, 2026-09-30). + #[test] + fn pictures_take_the_size_their_resolution_gives() { + let pixels = [200, 100]; + let size = |bytes: &[u8]| picture_size(bytes, pixels); + assert!(close(size(&png_at(Some(2834))), [200.0456, 100.0228])); + assert!(close(size(&png_at(Some(1889))), [300.1212, 150.0606])); + assert!(close(size(&png_at(None)), [150.0, 75.0])); + assert!(close(size(&jfif(1, 48)), [300.0, 150.0])); + assert!(close(size(&jfif(2, 20)), [283.4646, 141.7323])); + 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])); + } + + #[test] + fn a_pasted_bitmap_is_stored_as_a_png_at_96_dpi() { + let rgba: Vec = (0..3 * 2 * 4).map(|byte| byte as u8).collect(); + let encoded = png([3, 2], &rgba).unwrap(); + assert_eq!(draw::RasterImage::measure(&encoded).unwrap(), [3, 2]); + assert!(close(picture_size(&encoded, [3, 2]), [2.25, 1.5])); + let mut reader = png::Decoder::new(std::io::Cursor::new(&encoded)) + .read_info() + .unwrap(); + let mut decoded = vec![0; reader.output_buffer_size().unwrap()]; + reader.next_frame(&mut decoded).unwrap(); + assert_eq!(decoded, rgba); + } +} diff --git a/crates/snowbound/src/print.rs b/crates/snowbound/src/print.rs new file mode 100644 index 0000000000000000000000000000000000000000..870e426823796a9887dbce08aa03198711ed5539 --- /dev/null +++ b/crates/snowbound/src/print.rs @@ -0,0 +1,118 @@ +//! 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. + +use crate::{State, UserEvent, platform, printer}; +use onestore::page::Page; +use std::{error::Error, path::PathBuf, sync::Arc}; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Scope { + Page, + Section, +} + +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> { + 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 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 title = match scope { + Scope::Page => open.1.title.clone(), + Scope::Section => section.clone(), + }; + let destination = if export { + let name = format!("{}.pdf", file_name(&title)); + let Some(mut path) = platform::pick_new("Export as PDF", &name, "Export", None) else { + return Ok(()); + }; + if path.extension().is_none() { + path.set_extension("pdf"); + } + Some(path) + } else { + None + }; + let replica = Arc::clone(session.section.replica()); + let layouts = Arc::clone(&self.layouts); + let proxy = self.proxy.clone(); + let paper = printer::paper(); + std::thread::spawn(move || { + let made = (|| -> Result, Box> { + let mut open = Some(open); + let pages = 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 engine = layouts.lock().map_err(|_| "Page layout failed")?; + Ok(canvas::print::pdf(pages, &mut engine, paper, §ion)?) + })(); + let done = made.and_then(|pdf| match &destination { + Some(path) => std::fs::write(path, pdf).map(|()| None).map_err(Into::into), + None => Ok(Some(pdf)), + }); + let failed = if export { + "Couldn't export the PDF" + } else { + "Couldn't print" + }; + let done = done.map_err(|error| error.to_string()); + let _ = proxy.send_event(UserEvent::Then(Box::new(move |state| { + match done { + Ok(Some(pdf)) => printer::print(&state.window, &pdf, &title), + Ok(None) => Ok(()), + Err(error) => Err(error.into()), + } + .inspect_err(|error| platform::alert(failed, &error.to_string())) + }))); + }); + Ok(()) + } +} + +/// `title` as a file name: without the characters Windows and macOS refuse in one. +fn file_name(title: &str) -> String { + let name: String = title + .chars() + .map(|c| match c { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-', + c if c.is_control() => ' ', + c => c, + }) + .collect(); + match name.trim() { + "" => "Untitled".to_owned(), + name => name.to_owned(), + } +} + +/// A PDF waiting for a system viewer or print service, in the cache folder under `title`. +pub(crate) fn spooled(pdf: &[u8], title: &str) -> Result> { + let folder = platform::cache_dir() + .unwrap_or_else(std::env::temp_dir) + .join("Printing"); + std::fs::create_dir_all(&folder)?; + let path = folder.join(format!("{}.pdf", file_name(title))); + std::fs::write(&path, pdf)?; + Ok(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn titles_become_file_names() { + assert_eq!(file_name("Notes: 3/4"), "Notes- 3-4"); + assert_eq!(file_name(" "), "Untitled"); + } +} diff --git a/crates/snowbound/src/print_linux.rs b/crates/snowbound/src/print_linux.rs new file mode 100644 index 0000000000000000000000000000000000000000..a65fa959afc4601a017a87a8689bfdc3df37f677 --- /dev/null +++ b/crates/snowbound/src/print_linux.rs @@ -0,0 +1,110 @@ +//! Printing on Linux: the desktop's print dialog through the XDG print portal, which prints +//! the PDF itself; without a portal the PDF opens in the desktop's viewer to print from. + +use std::{collections::HashMap, error::Error, time::Duration}; +use winit::window::Window; +use zbus::zvariant::{Fd, OwnedValue, Value}; + +/// Countries whose paper is US Letter; everywhere else prints on A4. +const LETTER_COUNTRIES: [&str; 16] = [ + "US", "CA", "MX", "PR", "PH", "CL", "CO", "VE", "GT", "CR", "PA", "DO", "SV", "NI", "HN", "BZ", +]; + +/// The paper of the locale's country, as `LC_PAPER` names it. +pub fn paper() -> [f32; 2] { + let locale = ["LC_ALL", "LC_PAPER", "LANG"] + .into_iter() + .find_map(|name| std::env::var(name).ok().filter(|value| !value.is_empty())) + .unwrap_or_default(); + paper_of(&locale) +} + +/// The paper of `locale`'s country, such as `en_GB.UTF-8`'s. +fn paper_of(locale: &str) -> [f32; 2] { + let country = locale + .split(['.', '@']) + .next() + .and_then(|language| language.split('_').nth(1)) + .unwrap_or("US"); + if LETTER_COUNTRIES.contains(&country) { + canvas::print::LETTER + } else { + canvas::print::A4 + } +} + +/// Asks the print portal for the print dialog, then prints `pdf` as it was set up; the +/// dialog belongs to the portal, so a thread of its own waits on it. +pub fn print(_window: &Window, pdf: &[u8], title: &str) -> Result<(), Box> { + let path = crate::print::spooled(pdf, title)?; + let title = title.to_owned(); + std::thread::spawn(move || { + if let Err(error) = portal(&path, &title) { + eprintln!("The print portal failed, opening the PDF instead: {error}"); + crate::platform::reveal(&path); + } + }); + Ok(()) +} + +fn portal(path: &std::path::Path, title: &str) -> Result<(), Box> { + let connection = zbus::blocking::connection::Builder::session()? + .method_timeout(Duration::from_secs(5)) + .build()?; + let portal = zbus::blocking::Proxy::new( + &connection, + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.Print", + )?; + // A request's path follows from the sender and the token, so its answer can be awaited + // before it is asked for. + let sender = connection + .unique_name() + .ok_or("No D-Bus name")? + .trim_start_matches(':') + .replace('.', "_"); + let request = |token: &str| -> Result<_, Box> { + let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}"); + let request = zbus::blocking::Proxy::new( + &connection, + "org.freedesktop.portal.Desktop", + path, + "org.freedesktop.portal.Request", + )?; + Ok(request.receive_signal("Response")?) + }; + let token = format!("snowbound{}", std::process::id()); + let mut answers = request(&token)?; + let options = HashMap::from([("handle_token", Value::from(token.as_str()))]); + let empty: HashMap<&str, Value> = HashMap::new(); + let _: zbus::zvariant::OwnedObjectPath = + portal.call("PreparePrint", &("", title, &empty, &empty, &options))?; + let answer = answers.next().ok_or("The print dialog did not answer")?; + let (response, results): (u32, HashMap) = answer.body().deserialize()?; + // 1 is cancelled. + if response != 0 { + return Ok(()); + } + let prepared = results + .get("token") + .and_then(|token| u32::try_from(token).ok()) + .ok_or("The print dialog gave no token")?; + let file = std::fs::File::open(path)?; + let options = HashMap::from([("token", Value::from(prepared))]); + let _: zbus::zvariant::OwnedObjectPath = + portal.call("Print", &("", title, Fd::from(&file), &options))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn countries_pick_their_paper() { + assert_eq!(paper_of("en_GB.UTF-8"), canvas::print::A4); + assert_eq!(paper_of("es_MX.UTF-8"), canvas::print::LETTER); + assert_eq!(paper_of("C"), canvas::print::LETTER); + } +} diff --git a/crates/snowbound/src/print_macos.rs b/crates/snowbound/src/print_macos.rs new file mode 100644 index 0000000000000000000000000000000000000000..56e88031505bc70ff3576eee13c2daed3d6fad05 --- /dev/null +++ b/crates/snowbound/src/print_macos.rs @@ -0,0 +1,153 @@ +//! Printing on macOS: AppKit's print panel as a sheet on the window, printing the PDF +//! through PDFKit, which fits each sheet to the paper chosen. Mac OS X 10.6's PDFKit +//! cannot print a document, so there the PDF opens in Preview. + +use objc2::{ + msg_send, msg_send_id, + rc::Retained, + runtime::{AnyClass, AnyObject, Bool, Sel}, + sel, +}; +use objc2_foundation::{MainThreadMarker, NSSize, NSString}; +use std::error::Error; +use winit::{ + raw_window_handle::{HasWindowHandle, RawWindowHandle}, + window::Window, +}; + +// PDFKit, inside Quartz on every macOS. +#[link(name = "Quartz", kind = "framework")] +unsafe extern "C" {} + +fn class(name: &str) -> &'static AnyClass { + AnyClass::get(name).expect("AppKit and PDFKit are linked") +} + +/// The paper Page Setup defaults to, in points. +pub fn paper() -> [f32; 2] { + MainThreadMarker::new().expect("Printing belongs to the main thread"); + unsafe { + let info: Retained = msg_send_id![class("NSPrintInfo"), sharedPrintInfo]; + let size: NSSize = msg_send![&info, paperSize]; + if size.width > 0.0 && size.height > 0.0 { + [size.width as f32, size.height as f32] + } else { + canvas::print::LETTER + } + } +} + +/// PDFKit's print operation for `pdf` fitted to `info`'s paper, where PDFKit prints. +fn operation(pdf: &[u8], info: &AnyObject) -> Option> { + unsafe { + let data: Retained = msg_send_id![ + class("NSData"), + dataWithBytes: pdf.as_ptr().cast::(), + length: pdf.len() + ]; + let document: Option> = + msg_send_id![msg_send_id![class("PDFDocument"), alloc], initWithData: &*data]; + let document = document?; + let prints: bool = msg_send![ + &document, + respondsToSelector: sel!(printOperationForPrintInfo:scalingMode:autoRotate:) + ]; + if !prints { + return None; + } + // kPDFPrintPageScaleToFit. + msg_send_id![ + &document, + printOperationForPrintInfo: info, + scalingMode: 1_isize, + autoRotate: Bool::YES + ] + } +} + +/// Shows the print panel for `pdf` as a sheet on `window`. +pub fn print(window: &Window, pdf: &[u8], title: &str) -> Result<(), Box> { + MainThreadMarker::new().expect("Printing belongs to the main thread"); + unsafe { + let shared: Retained = msg_send_id![class("NSPrintInfo"), sharedPrintInfo]; + let info: Retained = msg_send_id![&shared, copy]; + let Some(operation) = operation(pdf, &info) else { + crate::platform::reveal(crate::print::spooled(pdf, title)?); + return Ok(()); + }; + let _: () = msg_send![&operation, setJobTitle: &*NSString::from_str(title)]; + let RawWindowHandle::AppKit(handle) = window.window_handle()?.as_raw() else { + unreachable!("A macOS window is AppKit's") + }; + let view = &*handle.ns_view.as_ptr().cast::(); + let window: Retained = msg_send_id![view, window]; + let _: () = msg_send![ + &operation, + runOperationModalForWindow: &*window, + delegate: std::ptr::null::(), + didRunSelector: None::, + contextInfo: std::ptr::null_mut::() + ]; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The print operation `print` runs, saving to a file instead of showing the panel. + #[test] + fn pdfkit_prints_the_pages() { + let folder = std::env::temp_dir().join(format!("snowbound-print-{}", std::process::id())); + std::fs::create_dir_all(&folder).unwrap(); + let printed = folder.join("printed.pdf"); + let sheet = draw::Sheet { + size: canvas::print::LETTER, + layers: Vec::new(), + }; + let pdf = draw::pdf( + "Two sheets", + &[ + sheet, + draw::Sheet { + size: canvas::print::LETTER, + layers: Vec::new(), + }, + ], + ) + .unwrap(); + unsafe { + let info: Retained = msg_send_id![class("NSPrintInfo"), new]; + let url: Retained = msg_send_id![ + class("NSURL"), + fileURLWithPath: &*NSString::from_str(printed.to_str().unwrap()) + ]; + let settings: Retained = msg_send_id![&info, dictionary]; + let _: () = msg_send![ + &settings, + setObject: &*url, + forKey: &*NSString::from_str("NSJobSavingURL") + ]; + let _: () = msg_send![&info, setJobDisposition: &*NSString::from_str("NSPrintSaveJob")]; + let operation = operation(&pdf, &info).expect("PDFKit prints documents"); + let _: () = msg_send![&operation, setShowsPrintPanel: false]; + let _: () = msg_send![&operation, setShowsProgressPanel: false]; + let ran: bool = msg_send![&operation, runOperation]; + assert!(ran); + } + let bytes = std::fs::read(&printed).unwrap(); + unsafe { + let data: Retained = msg_send_id![ + class("NSData"), + dataWithBytes: bytes.as_ptr().cast::(), + length: bytes.len() + ]; + let document: Retained = + msg_send_id![msg_send_id![class("PDFDocument"), alloc], initWithData: &*data]; + let pages: usize = msg_send![&document, pageCount]; + assert_eq!(pages, 2); + } + std::fs::remove_dir_all(&folder).unwrap(); + } +} diff --git a/crates/snowbound/src/print_windows.rs b/crates/snowbound/src/print_windows.rs new file mode 100644 index 0000000000000000000000000000000000000000..539dcd52e2bb80fb72d1fb313e6ee585e3a1f992 --- /dev/null +++ b/crates/snowbound/src/print_windows.rs @@ -0,0 +1,61 @@ +//! Printing on Windows: the PDF goes to the shell's print verb for PDFs, whose handler +//! shows the print dialog; where none prints PDFs, it opens in the viewer to print from. + +use std::{error::Error, os::windows::ffi::OsStrExt}; +use windows_sys::Win32::{ + Globalization::{GetLocaleInfoEx, LOCALE_IPAPERSIZE}, + UI::{Shell::ShellExecuteW, WindowsAndMessaging::SW_SHOWNORMAL}, +}; +use winit::{ + raw_window_handle::{HasWindowHandle, RawWindowHandle}, + window::Window, +}; + +fn wide(text: impl AsRef) -> Vec { + text.as_ref().encode_wide().chain([0]).collect() +} + +/// The paper the user's locale prints on: 1 is US Letter, 5 Legal, 9 A4. +pub fn paper() -> [f32; 2] { + let mut value = [0_u16; 4]; + // SAFETY: the buffer's length is passed with it; a null name is the user's locale. + let length = unsafe { + GetLocaleInfoEx( + std::ptr::null(), + LOCALE_IPAPERSIZE, + value.as_mut_ptr(), + value.len() as i32, + ) + }; + let size = String::from_utf16_lossy(&value[..(length.max(1) - 1) as usize]); + match size.as_str() { + "9" => canvas::print::A4, + _ => canvas::print::LETTER, + } +} + +pub fn print(window: &Window, pdf: &[u8], title: &str) -> Result<(), Box> { + let path = crate::print::spooled(pdf, title)?; + let owner = match window.window_handle()?.as_raw() { + RawWindowHandle::Win32(handle) => handle.hwnd.get() as _, + _ => std::ptr::null_mut(), + }; + for verb in ["print", "open"] { + // SAFETY: the strings are NUL-terminated and outlive the call. + let done = unsafe { + ShellExecuteW( + owner, + wide(verb).as_ptr(), + wide(&path).as_ptr(), + std::ptr::null(), + std::ptr::null(), + SW_SHOWNORMAL, + ) + }; + // ShellExecute's success is a pseudo-handle above 32. + if done as isize > 32 { + return Ok(()); + } + } + Err("No application on this computer prints or opens PDFs.".into()) +} diff --git a/crates/snowbound/src/screenshot.rs b/crates/snowbound/src/screenshot.rs index cee28477cea62966a275ae7fcb4a8af09a048fed..2ffe981c7e654bafa0ed303f28460fa817de502a 100644 --- a/crates/snowbound/src/screenshot.rs +++ b/crates/snowbound/src/screenshot.rs @@ -40,8 +40,8 @@ pub(crate) fn prepare() { impl State { /// Writes `PREFIX-light.png` and `PREFIX-dark.png`, the page unfocused so no caret shows; - /// as a Snow Leopard window, `PREFIX-snow-leopard.png` and, not key, - /// `PREFIX-snow-leopard-other.png`, at 10.6's one pixel per point. + /// as a Snow Leopard window, at 10.6's one pixel per point, also `PREFIX-light-other.png` + /// and `PREFIX-dark-other.png` as the window looks when not key. pub(crate) fn screenshot(&mut self, prefix: &Path) -> Result<(), Box> { let size = match std::env::var("SNOWBOUND_SCREENSHOT_SIZE") { Ok(size) => size diff --git a/crates/snowbound/src/screenshot/snow_leopard.rs b/crates/snowbound/src/screenshot/snow_leopard.rs index 25783b21afbbcd77ce30587087e429e6ec84627a..d6d9595a44ed811a4caa2288bdc964f822a16828 100644 --- a/crates/snowbound/src/screenshot/snow_leopard.rs +++ b/crates/snowbound/src/screenshot/snow_leopard.rs @@ -30,21 +30,23 @@ pub(super) fn screenshot( size: [f32; 2], ) -> Result<(), Box> { let title = state.window.title(); - for (name, key) in [("snow-leopard", true), ("snow-leopard-other", false)] { - state.window.set_theme(Some(Appearance::Light)); - state.set_appearance(Appearance::Light); - // Over the textured window's gradient, as `install_backdrop` leaves it on 10.6. - state.ui.theme = theme(Appearance::Light, state.light_pages, true); - state.titlebar = [state.ui.theme.strip; 2]; - state.ui.window_focused = key; - state.settle_frame(size, 1.0)?; - let content = state.capture()?; - let bar = title_bar(state, &title, size[0], key)?; - let [width, height] = [size[0] as usize, size[1] as usize]; - let window = window(&content, &bar, [width, height], key)?; - let (canvas, shadowed) = shadow(&window, [width, height + TITLE_LINE], key); - let path = PathBuf::from(format!("{}-{name}.png", prefix.display())); - write_png(&path, canvas.map(|side| side as u32), &shadowed)?; + for (name, appearance) in [("light", Appearance::Light), ("dark", Appearance::Dark)] { + for (suffix, key) in [("", true), ("-other", false)] { + state.window.set_theme(Some(appearance)); + state.set_appearance(appearance); + // Over the textured window's gradient, as `install_backdrop` leaves it on 10.6. + state.ui.theme = theme(appearance, state.light_pages, true); + state.titlebar = [state.ui.theme.strip; 2]; + state.ui.window_focused = key; + state.settle_frame(size, 1.0)?; + let content = state.capture()?; + let bar = title_bar(state, &title, size[0], key)?; + let [width, height] = [size[0] as usize, size[1] as usize]; + let window = window(&content, &bar, [width, height], key)?; + let (canvas, shadowed) = shadow(&window, [width, height + TITLE_LINE], key); + let path = PathBuf::from(format!("{}-{name}{suffix}.png", prefix.display())); + write_png(&path, canvas.map(|side| side as u32), &shadowed)?; + } } Ok(()) } diff --git a/crates/snowbound/src/settings.rs b/crates/snowbound/src/settings.rs index 7b96095c7844d128949faaa962f0911f8836c18a..2e08625bb54e4a5c5e81676f4b0efddb4ef439be 100644 --- a/crates/snowbound/src/settings.rs +++ b/crates/snowbound/src/settings.rs @@ -20,7 +20,7 @@ pub struct Settings { /// first saved. pub user_name: Option, pub color_scheme: ColorScheme, - /// Keeps pages white in the Dark color scheme. + /// Keeps pages white in a dark appearance: Pages Match UI Theme off. pub light_pages: bool, /// Leaves misspelled words unmarked, as OneNote's Hide Spelling Errors. pub hide_spelling: bool, @@ -33,6 +33,35 @@ pub struct Settings { /// Draws a tablet pen's strokes at its width, as OneNote 2010 with "Use pen pressure /// sensitivity" off. pub ignore_pen_pressure: bool, + /// 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, + /// 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. + pub navigation_bar_right: bool, + /// 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, + } + } } /// What the toolbar's buttons apply from their menus' last picks. @@ -144,6 +173,11 @@ impl crate::State { tags: (self.tags != canvas::editor::NoteTag::defaults()).then(|| self.tags.clone()), manual_updates: !self.updates.automatic(), ignore_pen_pressure: !self.pen_pressure, + ignore_grid: !self.view.snap_to_grid, + default_font: self.default_font.clone(), + page_tabs_left: self.page_tabs_left, + navigation_bar_right: self.navigation_bar_right, + servers: self.servers.clone(), }; if let Err(error) = settings.save(path) { eprintln!("Cannot save the settings in {}: {error}", path.display()); @@ -204,6 +238,14 @@ mod tests { }]), manual_updates: true, ignore_pen_pressure: true, + ignore_grid: true, + default_font: DefaultFont { + face: "Georgia".into(), + size: 14.0, + }, + page_tabs_left: true, + navigation_bar_right: true, + servers: vec!["smb://clover@nas/Notes".into()], }; 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 10584d7bf1a77f1b80533ff7a793f0120e7c1a60..55ec410bbed04f9839ce9fe59234368d905822b8 100644 --- a/crates/snowbound/src/sidebar.rs +++ b/crates/snowbound/src/sidebar.rs @@ -1074,7 +1074,7 @@ impl crate::State { } /// The window below the title bar on the first run, or once every notebook is closed: - /// what is missing, and the two ways to start. + /// what is missing, and the ways to start. pub(crate) fn welcome(&mut self, theme: &Theme) { let id = self.ui.id("welcome"); #[cfg(target_os = "linux")] @@ -1121,6 +1121,12 @@ impl crate::State { crate::Command::OpenFromServer(None), ), ]) + .chain(crate::guide::OFFERED.then_some(( + "guide", + art::PAGE, + "Open the Snowbound Guide", + crate::Command::OpenGuide, + ))) .chain(install) .collect(), ); diff --git a/crates/snowbound/src/update.rs b/crates/snowbound/src/update.rs index fceaa6392ebef81163c99268a14be316167653ed..36a056908bfa734a0ab008b825b66dffd7d371c4 100644 --- a/crates/snowbound/src/update.rs +++ b/crates/snowbound/src/update.rs @@ -31,15 +31,39 @@ fn running() -> Option { Version::parse(option_env!("SNOWBOUND_BUILD")?) } -/// This build's platform, as `latest.json` and `build.json` key their entries. +/// This build's platform, as `latest.json` and `build.json` key their entries; Apple +/// silicon's for an Intel build Rosetta runs. fn platform() -> String { - if cfg!(target_os = "macos") && !cfg!(feature = "wgpu") { + if translated() { + "macos-aarch64".to_owned() + } else if cfg!(target_os = "macos") && !cfg!(feature = "wgpu") { "macos-10.6".to_owned() } else { format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH) } } +#[cfg(all(target_os = "macos", target_arch = "x86_64"))] +fn translated() -> bool { + let mut translated: libc::c_int = 0; + let mut size = std::mem::size_of_val(&translated); + let found = unsafe { + libc::sysctlbyname( + c"sysctl.proc_translated".as_ptr(), + (&raw mut translated).cast(), + &mut size, + std::ptr::null_mut(), + 0, + ) + }; + found == 0 && translated == 1 +} + +#[cfg(not(all(target_os = "macos", target_arch = "x86_64")))] +fn translated() -> bool { + false +} + /// A published build: the day of its commit on `main`, in Los Angeles, and how many of that /// day's commits on `main` lead up to and include it. `2026-09-29-r10` names it in manifests. #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] @@ -302,6 +326,8 @@ fn check( progress: &dyn Fn(Status), ) -> Status { let platform = platform(); + // Under Rosetta the native build of the same version is still the update. + let running = running.filter(|_| !translated()); let fetch = |path: &str, limit| { get(path, limit).map_err(|error| { eprintln!("Cannot fetch {BASE}{path}: {error}"); diff --git a/crates/snowbound/src/windows.rs b/crates/snowbound/src/windows.rs index d5ea3965edb7bd32ff1ed0018289df6645702a47..ab1718368c00f50fc259b4de77f309d3271cb0e2 100644 --- a/crates/snowbound/src/windows.rs +++ b/crates/snowbound/src/windows.rs @@ -175,6 +175,9 @@ pub fn window_attributes() -> WindowAttributes { .with_taskbar_icon(icon(32).ok()) .with_decorations(caption() != Caption::Drawn) .with_undecorated_shadow(true) + // What GDI paints under the material would show through the row; every 10 draws + // with Direct3D 12, which needs no such bitmap. + .with_no_redirection_bitmap(caption() == Caption::Drawn && version().0 >= 10) } /// Takes the window's frame messages ahead of winit's: over glass or Mica, the caption @@ -236,9 +239,6 @@ unsafe extern "system" fn frame_procedure( wm::WM_SETTINGCHANGE | wm::WM_DWMCOLORIZATIONCOLORCHANGED => { if message == wm::WM_SETTINGCHANGE && setting(lparam) == "ImmersiveColorSet" { refresh_color_policy(); - if ACRYLIC.load(Ordering::Relaxed) { - accent(hwnd, Accent::Acrylic); - } } if let Some(proxy) = QUIT.get() { let _ = proxy.send_event(crate::UserEvent::Appearance); @@ -445,6 +445,26 @@ pub fn cuts_corners() -> bool { /// Whether Windows 10 shows acrylic under the window, which a drag swaps for plain blur. static ACRYLIC: AtomicBool = AtomicBool::new(false); +/// Whether the app's appearance is dark, which Options can set apart from the system's. +static DARK: AtomicBool = AtomicBool::new(false); + +/// Holds the frame, its caption buttons and its material to the app's `appearance`. +pub fn follow_appearance(window: &Window, appearance: Theme) { + let dark = appearance == Theme::Dark; + DARK.store(dark, Ordering::Relaxed); + let (major, _, build) = version(); + if major < 10 { + return; + } + let hwnd = hwnd(window); + // DWMWA_USE_IMMERSIVE_DARK_MODE, 19 before build 18985. + let attribute = if build >= 18985 { 20 } else { 19 }; + set_attribute(hwnd, attribute, &BOOL::from(dark)); + if ACRYLIC.load(Ordering::Relaxed) { + accent(hwnd, Accent::Acrylic); + } +} + enum Accent { Blur, /// Acrylic in the shell's own tints: Windows 10's light and dark flyouts. @@ -482,8 +502,8 @@ fn accent(hwnd: HWND, accent: Accent) -> bool { Accent::Acrylic => Policy { state: 4, flags: 2, - // 0xAABBGGRR, after the apps' colour mode. - tint: if registry_dword(PERSONALIZE, "AppsUseLightTheme") == Some(0) { + // 0xAABBGGRR, after the app's appearance. + tint: if DARK.load(Ordering::Relaxed) { 0xcc20_2020 } else { 0xccf3_f3f3 @@ -577,7 +597,7 @@ pub fn titlebar(appearance: Theme) -> Option<[[f32; 4]; 2]> { /// The theme over the window's material. Over Windows 7's glass, fields and tool buttons /// take white faces, as Internet Explorer's do there, so their text and icons stay legible, /// and the sidebar stays opaque. -pub fn over_backdrop(theme: ui::Theme) -> ui::Theme { +pub fn over_backdrop(theme: ui::Theme, _: Theme) -> ui::Theme { let sidebar = theme.sidebar; let theme = theme.over_backdrop(); if caption() != Caption::Glass || eleven() { @@ -795,6 +815,15 @@ impl Clipboard { pub fn get_text(&mut self) -> Result { self.0.get_text() } + + pub fn get_files(&mut self) -> Vec { + self.0.get().file_list().unwrap_or_default() + } + + /// 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()?) + } } /// The DWORD `value` under `key` in the user's registry hive. @@ -884,6 +913,32 @@ pub fn settings_dir() -> Option { Some(PathBuf::from(std::env::var_os("APPDATA")?).join("Snowbound")) } +/// The user's Documents known folder, wherever it was moved. +pub fn documents_dir() -> Option { + use std::os::windows::ffi::OsStringExt; + use windows_sys::Win32::{ + System::Com::CoTaskMemFree, + UI::Shell::{FOLDERID_Documents, KF_FLAG_DEFAULT, SHGetKnownFolderPath}, + }; + let mut path = std::ptr::null_mut(); + let found = unsafe { + SHGetKnownFolderPath( + &FOLDERID_Documents, + KF_FLAG_DEFAULT as u32, + std::ptr::null_mut(), + &mut path, + ) + } == 0; + let folder = found.then(|| unsafe { + let length = (0..).take_while(|&at| *path.add(at) != 0).count(); + PathBuf::from(std::ffi::OsString::from_wide(std::slice::from_raw_parts( + path, length, + ))) + }); + unsafe { CoTaskMemFree(path as _) }; + folder +} + /// None: Windows reaches a share by its UNC path with the share modes and byte-range /// locks OneNote takes, so a notebook there opens as a folder, as OneNote opens it. /// Snowbound's own client serves Open Notebook from Server…. diff --git a/crates/ui/src/layout.rs b/crates/ui/src/layout.rs index c1aa77ebd8ad56b68022d766b1d61818628d0ded..4ecaac97da187948705fc7723765b022d04e04e9 100644 --- a/crates/ui/src/layout.rs +++ b/crates/ui/src/layout.rs @@ -265,6 +265,40 @@ fn flow(nodes: &[Built], index: usize, axis: usize) -> f32 { } } +/// The least width `index`'s children fit in, with its padding: each row's groups folded, and +/// each box sized on its own yielding all its strictness lets it. +pub(crate) fn narrowest(nodes: &[Built], index: usize) -> f32 { + let node = &nodes[index]; + let sizes = node + .children + .iter() + .filter(|child| { + !nodes[**child].flags.contains(Flags::FLOAT) && nodes[**child].anchor.is_none() + }) + .map(|child| least(nodes, *child)); + let content = if along(node, 0) { + let (sum, count) = sizes.fold((0.0, 0), |(sum, count), size| (sum + size, count + 1)); + sum + node.gap * (count.max(1) - 1) as f32 + } else { + sizes.fold(0.0, f32::max) + }; + content + 2.0 * node.pad[0] +} + +/// The least width `index` takes without clipping its children. +fn least(nodes: &[Built], index: usize) -> f32 { + let node = &nodes[index]; + if node.fold.is_some() { + return least(nodes, node.children[1]) + 2.0 * node.pad[0]; + } + let own = match node.size[0].size { + Size::Pixels(pixels) => pixels, + Size::Text => node.content_width() + 2.0 * node.pad[0], + Size::Fraction(_) | Size::Children => return narrowest(nodes, index), + }; + own * node.size[0].strictness.clamp(0.0, 1.0) +} + fn fit_labels(nodes: &mut [Built], texts: &mut Texts, frame: u64) { for node in nodes.iter_mut() { let Some(label) = node.label.clone() else { diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 7587f2a7a17a9e2af37eb2c790c79fa0788bf5c6..1395106f7de45167b5738ab99de16ecf81fb149e 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -827,6 +827,13 @@ impl Ui { self.hit(point, Flags::CLICKABLE | Flags::CUSTOM) } + /// The least width the box's children fit in, with its padding, as built this frame or + /// last: each row's groups folded, and each box yielding all its strictness lets it. + pub fn narrowest(&self, id: Id) -> Option { + let index = self.nodes.iter().position(|node| node.id == id)?; + Some(layout::narrowest(&self.nodes, index)) + } + /// The box's rectangle from the latest layout. pub fn rect(&self, id: Id) -> Option<[f32; 4]> { self.states.get(&id).map(|state| state.rect) diff --git a/crates/ui/src/tests.rs b/crates/ui/src/tests.rs index c3328d75b795b481c6c9b2dd63268a0713f1e7d0..10702a332fe96faa17abb25aff2a3e0675ed01ea 100644 --- a/crates/ui/src/tests.rs +++ b/crates/ui/src/tests.rs @@ -216,6 +216,17 @@ fn a_row_squeezes_its_boxes_only_once_every_group_is_folded() { assert_eq!(rects[4], Some([80.0, 0.0, 100.0, 20.0])); } +#[test] +fn a_row_is_narrowest_with_every_group_folded_and_every_box_squeezed() { + let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); + folding_row(&mut ui, 300.0, &[0, 1]); + let row = Id::ROOT.child("row"); + // The space gives all, the loose box 60 of 80, each group 80 of 100. + assert_eq!(ui.narrowest(row), Some(60.0)); + let rects = folding_row(&mut ui, 60.0, &[0, 1]); + assert_eq!(rects[4], Some([40.0, 0.0, 60.0, 20.0])); +} + #[test] fn a_box_sized_by_its_children_yields_what_they_yield() { let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); diff --git a/crates/ui/src/text.rs b/crates/ui/src/text.rs index 5b31bdec53e6c39a1227e233bc4fd9a4fd44b9ce..d091cd818971b224f8cac8c9e5c58a32c9adbc24 100644 --- a/crates/ui/src/text.rs +++ b/crates/ui/src/text.rs @@ -149,6 +149,7 @@ impl Glyphs for Label { if let PositionedLayoutItem::GlyphRun(run) = item { draw::paint_parley_run( &run, + "", metrics.baseline, 0.0, [metrics.block_min_coord, metrics.line_height], diff --git a/docs/guide-notebook/Snowbound Guide/.snowbound/tags.json b/docs/guide-notebook/Snowbound Guide/.snowbound/tags.json new file mode 100644 index 0000000000000000000000000000000000000000..ecba716aef0706fcca587394ef348cde0185ccbd --- /dev/null +++ b/docs/guide-notebook/Snowbound Guide/.snowbound/tags.json @@ -0,0 +1,8 @@ +[ + { + "name": "Snowflake", + "shape": 34, + "art": "308029094b76e765ca4025c6e8978ff3f1e5f696601ca46fed3803a81c37792c.svg", + "mapped": 134352774263488320 + } +] \ No newline at end of file diff --git a/docs/guide-notebook/Snowbound Guide/.snowbound/tags/308029094b76e765ca4025c6e8978ff3f1e5f696601ca46fed3803a81c37792c.svg b/docs/guide-notebook/Snowbound Guide/.snowbound/tags/308029094b76e765ca4025c6e8978ff3f1e5f696601ca46fed3803a81c37792c.svg new file mode 100644 index 0000000000000000000000000000000000000000..053c11e5abb0890352658594633d2dc5f66e0c39 --- /dev/null +++ b/docs/guide-notebook/Snowbound Guide/.snowbound/tags/308029094b76e765ca4025c6e8978ff3f1e5f696601ca46fed3803a81c37792c.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/docs/guide-notebook/Snowbound Guide/Drawing & Recording.one b/docs/guide-notebook/Snowbound Guide/Drawing & Recording.one new file mode 100644 index 0000000000000000000000000000000000000000..4dea2f9759ef2455094ff9466c7cc7344f7497f6 Binary files /dev/null and b/docs/guide-notebook/Snowbound Guide/Drawing & Recording.one differ diff --git a/docs/guide-notebook/Snowbound Guide/Getting Started.one b/docs/guide-notebook/Snowbound Guide/Getting Started.one new file mode 100644 index 0000000000000000000000000000000000000000..309eaa937ce4ffd45afc622bfcfee0a8808b2b31 Binary files /dev/null and b/docs/guide-notebook/Snowbound Guide/Getting Started.one differ diff --git a/docs/guide-notebook/Snowbound Guide/Open Notebook.onetoc2 b/docs/guide-notebook/Snowbound Guide/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..6da28a3a19cf28a297736704ea4fc74672bf1be7 Binary files /dev/null and b/docs/guide-notebook/Snowbound Guide/Open Notebook.onetoc2 differ diff --git a/docs/guide-notebook/Snowbound Guide/Sharing & Sync.one b/docs/guide-notebook/Snowbound Guide/Sharing & Sync.one new file mode 100644 index 0000000000000000000000000000000000000000..852a9c422a1a640013f623473e563c6d441e9ef0 Binary files /dev/null and b/docs/guide-notebook/Snowbound Guide/Sharing & Sync.one differ diff --git a/docs/guide-notebook/Snowbound Guide/Tags & Search.one b/docs/guide-notebook/Snowbound Guide/Tags & Search.one new file mode 100644 index 0000000000000000000000000000000000000000..63da6190cabe3472019bafc8fb0bb8d7e5c197e6 Binary files /dev/null and b/docs/guide-notebook/Snowbound Guide/Tags & Search.one differ diff --git a/docs/guide-notebook/Snowbound Guide/Writing & Formatting.one b/docs/guide-notebook/Snowbound Guide/Writing & Formatting.one new file mode 100644 index 0000000000000000000000000000000000000000..0acefb94af0fe9b143da7db292bbe327c84064c0 Binary files /dev/null and b/docs/guide-notebook/Snowbound Guide/Writing & Formatting.one differ diff --git a/platform/snow-leopard/rt/appkit.c b/platform/snow-leopard/rt/appkit.c index 9df6250f0beb2decec8f1fe00eb0bc9b54dfa25a..8022bbfd3a837b865e755c75103f6569e035189d 100644 --- a/platform/snow-leopard/rt/appkit.c +++ b/platform/snow-leopard/rt/appkit.c @@ -127,11 +127,6 @@ static CGFloat red(id self, SEL _cmd) { return component(self, 0); } static CGFloat green(id self, SEL _cmd) { return component(self, 1); } static CGFloat blue(id self, SEL _cmd) { return component(self, 2); } -static id black(id self, SEL _cmd) { return SEND(objc_getClass("NSColor"), "blackColor"); } -static id secondary_selection(id self, SEL _cmd) { - return SEND(objc_getClass("NSColor"), "secondarySelectedControlColor"); -} - #define RECT "{CGRect={CGPoint=dd}{CGSize=dd}}" static void add(const char *class_name, int meta, const char *selector, void *imp, const char *types) { @@ -153,7 +148,6 @@ __attribute__((constructor)) static void polyfill(void) { add("NSWindow", 0, "setTitlebarAppearsTransparent:", ignore, "v@:c"); add("NSView", 0, "setWantsBestResolutionOpenGLSurface:", ignore, "v@:c"); add("NSView", 0, "setAccessibilityLabel:", set_accessibility_label, "v@:@"); - add("NSDatePicker", 0, "setPresentsCalendarOverlay:", ignore, "v@:c"); add("NSEvent", 0, "hasPreciseScrollingDeltas", no, "c@:"); add("NSEvent", 0, "scrollingDeltaX", delta_x, "d@:"); add("NSEvent", 0, "scrollingDeltaY", delta_y, "d@:"); @@ -163,8 +157,6 @@ __attribute__((constructor)) static void polyfill(void) { add("NSColorSpaceColor", 0, "greenComponent", green, "d@:"); add("NSColorSpaceColor", 0, "blueComponent", blue, "d@:"); add("NSColor", 1, "colorWithSRGBRed:green:blue:alpha:", srgb_color, "@@:dddd"); - add("NSColor", 1, "textInsertionPointColor", black, "@@:"); - add("NSColor", 1, "unemphasizedSelectedTextBackgroundColor", secondary_selection, "@@:"); // One appearance, Aqua, which windows have and nothing changes. appearance_class = (id)objc_getClass("NSAppearance"); diff --git a/tools/RELEASE.md b/tools/RELEASE.md index f4fd2a1639ee71eca6b9ea24be493f0a2d1c881b..f810c5ecd1cd549a94bf9bfa7b240b6c815b03bd 100644 --- a/tools/RELEASE.md +++ b/tools/RELEASE.md @@ -18,16 +18,17 @@ development builds, which never update themselves. ## The published folder ```text -latest.json {"macos-aarch64": "2026-09-29-r10", "macos-10.6": ..., "linux-x86_64": ..., "windows-x86_64": ..., ...} +latest.json {"macos-aarch64": "2026-09-29-r10", "macos-x86_64": ..., "macos-10.6": ..., "linux-x86_64": ..., ...} 2026-09-29.r10/ build.json version, commit, and per platform: file, size, sha256, signature build.json.sig ed25519 signature of build.json, hex Snowbound-2026-09-29-r10-macos-aarch64.zip + Snowbound-2026-09-29-r10-macos-x86_64.zip Snowbound-2026-09-29-r10-macos-10.6.zip snowbound-2026-09-29-r10-linux-x86_64 the executable itself snowbound-2026-09-29-r10-linux-aarch64 - snowbound-2026-09-29-r10-windows-x86_64.exe Windows 7 SP1 to 11 - snowbound-2026-09-29-r10-windows-aarch64.exe Windows 11 on Arm + snowbound-2026-09-29-r10-windows-x86_64.exe + snowbound-2026-09-29-r10-windows-aarch64.exe ``` A build folder is written once, under a hidden `.2026-09-29.r10.partial` name renamed into @@ -82,7 +83,7 @@ quarantine and open directly. ## Publishing ```sh -python3 tools/release.py # all six platforms +python3 tools/release.py # all seven platforms python3 tools/release.py --ad-hoc # the macOS app without Developer ID python3 tools/release.py --dry-run # the same into a new temporary folder python3 tools/release.py --platforms macos-aarch64 linux-x86_64 @@ -99,9 +100,30 @@ publishes as above. Run again for the same commit, it only brings `latest.json` up to date; a different commit that derives the same version is refused. The 10.6 build needs the SDK and nightly toolchain `platform/snow-leopard/cargo.sh` names; the Linux builds need `zig`, as the -cross linker against glibc 2.31; the Windows builds need llvm-mingw, which +cross linker against glibc 2.17; the Windows builds need llvm-mingw, which `platform/windows/toolchain.sh` fetches, and nightly with `rust-src` for -x86_64. All six build from an Apple silicon Mac. +x86_64. All seven build from an Apple silicon Mac. + +## Minimum systems + +Each build runs as far back as its dependencies allow, and newer calls are +checked at run time (`respondsToSelector:`, `available!`, `dlsym`). + +| Platform | Oldest system | Set by | +| --- | --- | --- | +| `macos-aarch64` | macOS 11 | Apple silicon's first; rustc's default | +| `macos-x86_64` | macOS 10.13 | wgpu's Metal floor; `MACOSX_DEPLOYMENT_TARGET` | +| `macos-10.6` | Mac OS X 10.6 | its own SDK, runtime shims and OpenGL renderer | +| `linux-*` | glibc 2.17 (RHEL 7, Debian 8, Ubuntu 14.04) | Rust's floor; zig's glibc target | +| `windows-x86_64` | Windows 7 SP1 | nightly's `x86_64-win7-windows-gnu` | +| `windows-aarch64` | Windows 11 on Arm | `aarch64-pc-windows-gnullvm` | + +`build_macos.py` writes the minimum as `LSMinimumSystemVersion`; the binary +carries it as `LC_BUILD_VERSION` `minos` (`LC_VERSION_MIN_MACOSX` below 10.14), +which `otool -l` shows. `objdump -T` of a Linux executable names no `GLIBC_` +version above 2.17. The Linux executable links only libc, libm, libpthread and +libdl; Wayland, X11, xkbcommon, EGL, Vulkan, fontconfig, GStreamer and Enchant +are loaded at run time. ## In the app @@ -111,7 +133,8 @@ Work Offline is on; Check for Updates… (the app menu on macOS, the command palette elsewhere) checks at once and reports what it found. A check reads `latest.json`, then the named build's `build.json` and signature, and downloads the archive for this platform, verifying size, SHA-256 and -signature. It stages the update beside the install, so the swap is a rename: +signature. An Intel build that Rosetta runs takes `macos-aarch64`'s, even at +its own version. It stages the update beside the install, so the swap is a rename: the app unpacked into `.Snowbound.app.update` next to the bundle on macOS, the executable into `.snowbound.update` next to it on Linux (`.snowbound.exe.update` on Windows). The sync status icon gets a dot, and its popup offers diff --git a/tools/canvas/README.md b/tools/canvas/README.md index e1fabbec57a201a96f3880df9e060057ce498eba..12aa3f7c00c2d6364424146fc23f5ad7c99c70c8 100644 --- a/tools/canvas/README.md +++ b/tools/canvas/README.md @@ -54,7 +54,7 @@ Launched without a file, the app reopens the notebooks open when it last closed, The `ui` crate builds every frame from the application's state. A cache keyed by stable box ids keeps hover, press, focus, scroll and animation values, and the previous frame's layout routes the frame's input before building, so a frame answers input one layout late and paints with its own. Sizes are solved per axis after building: fixed, label-sized, a fraction of an ancestor, or the sum of children, with overflow shared out by each box's strictness. The page is a custom box: `ui` routes it the pointer, wheel, key and input-method events that land on it, in order, and the host hands them to `PageView` and paints the page in a clipped layer of the same frame. Page scrollbars are `ui` widgets over that box; the canvas reports only its scroll bounds. Text fields edit through `draw::edit`, as the page does, so keys, clicks and drags select and move the same way in both. Work the frame asks for (page requests, saving) runs after the frame is painted and requests the frame that shows it. Opening a section or page reads and lays it out on a thread of its own while the current page stays live; the newest replaces the page once the pictures it shows first are drawn, or after 200 ms. -A covered window receives no redraws, so interaction can be scripted: `SNOWBOUND_REPLAY` names a file of `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `key NAME`, `type TEXT`, `modifiers [shift] [command]`, `wait MS`, `snapshot PNG_PATH`, `accessibility TEXT_PATH` (the window's accessibility tree, a node a line, as the platform shows it), `appearance light|dark`, `resize WIDTH HEIGHT` and `quit` lines in logical pixels, and each step and every 16 ms of a wait draws a frame. With `--screenshot` as well, the window stays hidden and only the replay's snapshots capture it. Snapshots wait for the page's pictures, file icons and background art to finish rasterizing; `SNOWBOUND_FRAMES` frames never wait, so art appears in the first frame drawn after it is ready. Snapshots omit the window's traffic lights, which AppKit draws. Replays edit and save like a user, so point them at a copy of a notebook. Their edits carry the settings' user name, so give fixture runs a `--settings` file holding `{"user_name": "Snowbound Test"}`. +A covered window receives no redraws, so interaction can be scripted: `SNOWBOUND_REPLAY` names a file of `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `pinch FACTOR`, `key NAME`, `type TEXT`, `modifiers [shift] [command]`, `wait MS`, `snapshot PNG_PATH`, `accessibility TEXT_PATH` (the window's accessibility tree, a node a line, as the platform shows it), `appearance light|dark`, `resize WIDTH HEIGHT` and `quit` lines in logical pixels, and each step and every 16 ms of a wait draws a frame. With `--screenshot` as well, the window stays hidden and only the replay's snapshots capture it. Snapshots wait for the page's pictures, file icons and background art to finish rasterizing; `SNOWBOUND_FRAMES` frames never wait, so art appears in the first frame drawn after it is ready. Snapshots omit the window's traffic lights, which AppKit draws. Replays edit and save like a user, so point them at a copy of a notebook. Their edits carry the settings' user name, so give fixture runs a `--settings` file holding `{"user_name": "Snowbound Test"}`. ```sh cp -RL SOURCE_NOTEBOOK /tmp/notebook-copy && chmod u+w /tmp/notebook-copy/*.one diff --git a/tools/canvas/build_macos.py b/tools/canvas/build_macos.py index c5a929946d4634ecfb0c96206144f9be46157839..bd6d6b01478eafddf7f4fbde5be3e12536a43f68 100644 --- a/tools/canvas/build_macos.py +++ b/tools/canvas/build_macos.py @@ -16,6 +16,8 @@ parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--release', action='store_true') parser.add_argument('--output', type=Path, help='Create a separate bundle at a new .app path') parser.add_argument('--bundle-id', help="Bundle identifier for the separate app; the app's own otherwise") +host = 'aarch64' if platform.machine() == 'arm64' else 'x86_64' +parser.add_argument('--arch', choices=['aarch64', 'x86_64'], default=host, help="The app's architecture; this Mac's by default") parser.add_argument('--snow-leopard', action='store_true', help='Build with platform/snow-leopard/cargo.sh into target/snow-leopard') parser.add_argument('--sign-identity', metavar='SHA1', @@ -29,6 +31,9 @@ if args.bundle_id and not args.output: parser.error('Use --bundle-id with --output.') if args.sign and args.snow_leopard: parser.error('The 10.6 bundle stays unsigned.') +# The oldest macOS each build runs on: 10.6's own build, wgpu's floor on Intel, and Apple +# silicon's first, which rustc also defaults to. +minimum = '10.6' if args.snow_leopard else {'x86_64': '10.13', 'aarch64': '11.0'}[args.arch] if args.output and (args.output.suffix != '.app' or args.output.exists()): parser.error('Choose a new output path ending in .app.') root = Path(__file__).resolve().parents[2] @@ -83,9 +88,13 @@ if args.snow_leopard: target = root / 'target/snow-leopard' built = target / 'x86_64-apple-macosx10.6' else: - subprocess.run(['cargo', 'build', '-p', 'snowbound'] + profile, cwd=root, check=True) metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--format-version=1', '--no-deps'], cwd=root)) target = built = Path(metadata['target_directory']) + cross = [] if args.arch == host else ['--target', f'{args.arch}-apple-darwin'] + if cross: + built = target / cross[1] + subprocess.run(['cargo', 'build', '-p', 'snowbound'] + profile + cross, cwd=root, check=True, + env={**os.environ, 'MACOSX_DEPLOYMENT_TARGET': minimum} if args.arch == 'x86_64' else None) bundle = args.output.resolve() if args.output else target / 'Snowbound.app' if args.output: bundle.mkdir(parents=True, exist_ok=False) @@ -107,8 +116,10 @@ if args.snow_leopard: '--out', iconset / f'icon_{side}x{side}.png'], check=True, capture_output=True) subprocess.run(['iconutil', '-c', 'icns', iconset, '-o', resources / 'Snowbound.icns'], check=True) else: - icon = 'Snowbound-Tahoe' if int(platform.mac_ver()[0].split('.')[0] or 0) >= 26 else 'Snowbound-Sequoia' - shutil.copy2(icons / f'{icon}.icns', resources / 'Snowbound.icns') + # macOS 26 draws the Liquid Glass icon from the catalog; it holds no flattened renditions + # (see the icon folder's README), so earlier versions fall back to the icns. + shutil.copy2(icons / 'Snowbound-Sequoia.icns', resources / 'Snowbound.icns') + shutil.copy2(icons / 'Assets.car', resources) # tools/release.py names the build, as 2026-09-29-r10, which About shows. build = os.environ.get('SNOWBOUND_BUILD') versions = {} @@ -126,6 +137,7 @@ if build: 'NSHighResolutionCapable': True, 'NSPrincipalClass': 'NSApplication', 'CFBundleIconFile': 'Snowbound', + 'CFBundleIconName': 'Snowbound-Tahoe', 'NSLocalNetworkUsageDescription': 'Snowbound connects to file servers on your network to open and sync shared notebooks.', 'NSMicrophoneUsageDescription':'Snowbound records audio into your notes when you choose Record Audio or Record Video.', 'NSCameraUsageDescription': 'Snowbound records video into your notes when you choose Record Video.', @@ -134,7 +146,8 @@ if build: 'NSUbiquitousContainerName': 'Snowbound', 'NSUbiquitousContainerSupportedFolderLevels': 'Any', }}, -} | versions | ({'LSMinimumSystemVersion': '10.6'} if args.snow_leopard else {}))) + 'LSMinimumSystemVersion': minimum, +} | versions)) # 10.6 runs the bundle unsigned. if not args.snow_leopard: identity = args.sign != 'ad-hoc' and (args.sign_identity or developer_id()) diff --git a/tools/native/picture-objects.ps1 b/tools/native/picture-objects.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..c5c6bd498e29ad9acb82a7731320c5c1542a1688 --- /dev/null +++ b/tools/native/picture-objects.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/tools/native_runner.py b/tools/native_runner.py index 5ca947d7bb6b90c09842a6aefeb215fe4aedf81f..1b2b86aa2eea5279cc28022dd8b51662fc46efe0 100644 --- a/tools/native_runner.py +++ b/tools/native_runner.py @@ -151,7 +151,7 @@ def capture(notebook, output, expected_pages=-1, author=None, screenshots=False, 'author_timeout_seconds': author_timeout, 'inspect': inspect, 'collect_notebook': collect_notebook, - 'base': json.loads(vm.BASE_MANIFEST.read_text()), + 'base': json.loads(vm.base_manifest('win7').read_text()), 'scripts': {name: hashlib.sha256((scripts / name).read_bytes()).hexdigest() for name in sorted(p.name for p in scripts.iterdir())}, }, indent=2) + '\n') diff --git a/tools/release.py b/tools/release.py index 2b52a42e84d1a58d69f6390a288e2792357efb19..02c9dbdc6693e93a3c554eb6bfe917de69399f4f 100755 --- a/tools/release.py +++ b/tools/release.py @@ -17,8 +17,8 @@ PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound') URL = 'https://file.paperclover.net/shr/snowbound/' KEY = Path.home() / '.config/snowbound/release-key' ZONE = ZoneInfo('America/Los_Angeles') -PLATFORMS = ['macos-aarch64', 'macos-10.6', 'linux-x86_64', 'linux-aarch64', 'windows-x86_64', - 'windows-aarch64'] +PLATFORMS = ['macos-aarch64', 'macos-x86_64', 'macos-10.6', 'linux-x86_64', 'linux-aarch64', + 'windows-x86_64', 'windows-aarch64'] # Windows 7 to 11 on x86_64 (nightly's tier-3 win7 target), and Windows 11 on Arm. WINDOWS = {'x86_64': 'x86_64-win7-windows-gnu', 'aarch64': 'aarch64-pc-windows-gnullvm'} CHECKS = [ @@ -114,6 +114,8 @@ def build_mac(platform, folder, developer_id, notarize): signing = ['--sign', 'developer-id', '--sign-identity', IDENTITY] else: signing = ['--sign', 'ad-hoc'] + if platform != 'macos-10.6': + signing += ['--arch', platform.removeprefix('macos-')] run([sys.executable, ROOT / 'tools/canvas/build_macos.py', '--release', '--output', bundle, *signing]) archive = folder / 'archive.zip' if notarize and platform != 'macos-10.6': @@ -150,7 +152,7 @@ def main(): parser.add_argument('--ad-hoc', action='store_true', help='Sign the macOS app ad hoc instead of with Developer ID, unnotarized') args = parser.parse_args() - developer_id = not args.ad_hoc and any(platform == 'macos-aarch64' for platform in args.platforms) + developer_id = not args.ad_hoc and any(platform in ('macos-aarch64', 'macos-x86_64') for platform in args.platforms) identities = subprocess.run(['security', 'find-identity', '-v', '-p', 'codesigning'], capture_output=True, text=True).stdout if developer_id and IDENTITY not in identities: diff --git a/tools/test_equation_select.py b/tools/test_equation_select.py new file mode 100644 index 0000000000000000000000000000000000000000..2cd4751bcb4ea86985d7b4133d76e42364c34aa6 --- /dev/null +++ b/tools/test_equation_select.py @@ -0,0 +1,40 @@ +from pathlib import Path +import re +import runpy +import shutil +from tempfile import TemporaryDirectory +import unittest + +ROOT = Path(__file__).resolve().parent.parent +FIXTURE = ROOT / 'corpus/equation-select' +compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] + + +def mathml(path): + """Each equation's MathML in a COM read, trailing spaces dropped.""" + xml = path.read_text(encoding='utf-8-sig') + return [re.sub(r'( )+', '', m).replace(' display="block"', '') + for m in re.findall(r'', xml)] + + +class EquationSelectTest(unittest.TestCase): + def test_edits_into_equation_objects_reopen_as_onenote_made_them(self): + with TemporaryDirectory() as temporary: + read = Path(temporary) / 'read' + shutil.copytree(FIXTURE / 'cold/read', read) + compare(FIXTURE / 'candidate', read) + onenote = mathml(FIXTURE / 'lab/after.xml') + cold = mathml(FIXTURE / 'cold/read/page-000.xml') + # The lab page ends with "x" typed over a placeholder, which the candidate has no + # placeholder left for. + self.assertEqual(onenote[-1], 'x') + self.assertEqual(cold, onenote[:-1]) + before = mathml(FIXTURE / 'lab/before.xml') + self.assertIn('Type equation here.', ''.join(before)) + self.assertTrue(any('' in m for m in before)) + self.assertFalse(any('' in m or 'Type equation' in m for m in cold)) + self.assertTrue(any('' in m and 'lim' in m for m in cold)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/test_release.py b/tools/test_release.py index 607dcfe1e59996d1c54388b67cf73a1c1f503eaa..5f1f5a85d59e134c7f69daf3f4c573b927c041db 100644 --- a/tools/test_release.py +++ b/tools/test_release.py @@ -50,9 +50,12 @@ class ReleaseTest(unittest.TestCase): [command[1] for command in commands[1:]]) self.assertEqual(signing('macos-aarch64', True, ['--key-id', 'K']), - (['--sign', 'developer-id', '--sign-identity', release['IDENTITY']], + (['--sign', 'developer-id', '--sign-identity', release['IDENTITY'], '--arch', 'aarch64'], ['-c', 'notarytool', 'stapler', '-c'])) - self.assertEqual(signing('macos-aarch64', False, None), (['--sign', 'ad-hoc'], ['-c'])) + self.assertEqual(signing('macos-x86_64', True, ['--key-id', 'K']), + (['--sign', 'developer-id', '--sign-identity', release['IDENTITY'], '--arch', 'x86_64'], + ['-c', 'notarytool', 'stapler', '-c'])) + self.assertEqual(signing('macos-aarch64', False, None), (['--sign', 'ad-hoc', '--arch', 'aarch64'], ['-c'])) self.assertEqual(signing('macos-10.6', True, ['--key-id', 'K']), (['--snow-leopard'], ['-c'])) finally: scope['run'] = run