authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-30 14:43:26-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-30 15:17:48-07:00
log9da8af01dee9dec5df91b1f0832d6298c8f11e4d
tree4b4dddf90ef3933835ad3805f880b25e2683ccef
parent115236d842d3395805dd661eb0fe01e5e626c78d
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: hybrid macOS icon, Intel Mac build, older systems, appearance setting, image paste, pinch zoom, guide draft

- macOS ships a prebuilt Assets.car (Xcode 26.0.1) so Tahoe and later show the Liquid Glass icon and earlier systems the Sequoia icon; iOS uses the same Icon Composer icon. - An Intel Mac build (10.13+), the Apple silicon build on macOS 11+ with the macOS 14-only calls gated, and Linux builds against glibc 2.17. - Appearance: System, Light, or Dark independent of the OS, on every platform; Dark Pages is now Pages Match UI Theme. - Pasting pictures (#51) as OneNote 2010 places and sizes them; pinch zoom keeps the point under the cursor (#52). - The Snowbound Guide notebook draft, not offered in the app until reviewed. - Windows polish, options and content work in progress that compiles and passes. Assisted-by: claude-opus-5.5

110 files changed, 5106 insertions(+), 310 deletions(-)

Cargo.lock+44-3
...@@ -228,9 +228,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"...@@ -228,9 +228,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
228checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"228checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
229dependencies = [229dependencies = [
230 "clipboard-win",230 "clipboard-win",
231 "image",
231 "log",232 "log",
232 "objc2 0.6.4",233 "objc2 0.6.4",
233 "objc2-app-kit 0.3.2",234 "objc2-app-kit 0.3.2",
235 "objc2-core-foundation",
236 "objc2-core-graphics",
234 "objc2-foundation 0.3.2",237 "objc2-foundation 0.3.2",
235 "parking_lot",238 "parking_lot",
236 "percent-encoding",239 "percent-encoding",
...@@ -633,6 +636,7 @@ dependencies = [...@@ -633,6 +636,7 @@ dependencies = [
633 "draw",636 "draw",
634 "icu_normalizer",637 "icu_normalizer",
635 "image",638 "image",
639 "miniz_oxide 0.8.9",
636 "onestore",640 "onestore",
637 "parley",641 "parley",
638 "png",642 "png",
...@@ -974,10 +978,13 @@ dependencies = [...@@ -974,10 +978,13 @@ dependencies = [
974 "bytemuck",978 "bytemuck",
975 "image",979 "image",
976 "linebender_resource_handle",980 "linebender_resource_handle",
981 "miniz_oxide 0.8.9",
977 "parley",982 "parley",
983 "pdf-writer",
978 "png",984 "png",
979 "pollster",985 "pollster",
980 "roxmltree",986 "roxmltree",
987 "subsetter",
981 "swash",988 "swash",
982 "wgpu",989 "wgpu",
983]990]
...@@ -1971,7 +1978,7 @@ dependencies = [...@@ -1971,7 +1978,7 @@ dependencies = [
1971 "naga-types",1978 "naga-types",
1972 "num-traits",1979 "num-traits",
1973 "once_cell",1980 "once_cell",
1974 "rustc-hash",1981 "rustc-hash 1.1.0",
1975 "spirv",1982 "spirv",
1976 "thiserror 2.0.20",1983 "thiserror 2.0.20",
1977 "unicode-ident",1984 "unicode-ident",
...@@ -1985,7 +1992,7 @@ checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49"...@@ -1985,7 +1992,7 @@ checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49"
1985dependencies = [1992dependencies = [
1986 "hashbrown 0.17.1",1993 "hashbrown 0.17.1",
1987 "indexmap",1994 "indexmap",
1988 "rustc-hash",1995 "rustc-hash 1.1.0",
1989 "thiserror 2.0.20",1996 "thiserror 2.0.20",
1990]1997]
19911998
...@@ -2046,6 +2053,7 @@ dependencies = [...@@ -2046,6 +2053,7 @@ dependencies = [
2046 "tempfile",2053 "tempfile",
2047 "thiserror 2.0.20",2054 "thiserror 2.0.20",
2048 "tokio",2055 "tokio",
2056 "windows-sys 0.61.2",
2049 "zeroize",2057 "zeroize",
2050]2058]
20512059
...@@ -2544,6 +2552,18 @@ dependencies = [...@@ -2544,6 +2552,18 @@ dependencies = [
2544 "hmac",2552 "hmac",
2545]2553]
25462554
2555[[package]]
2556name = "pdf-writer"
2557version = "0.15.0"
2558source = "registry+https://github.com/rust-lang/crates.io-index"
2559checksum = "f5e456864a7a304047bff84977dc6fb162bd956475d40ba50b2dcecaada7f753"
2560dependencies = [
2561 "bitflags 2.13.1",
2562 "itoa",
2563 "memchr",
2564 "ryu",
2565]
2566
2547[[package]]2567[[package]]
2548name = "percent-encoding"2568name = "percent-encoding"
2549version = "2.3.2"2569version = "2.3.2"
...@@ -2951,6 +2971,12 @@ version = "1.1.0"...@@ -2951,6 +2971,12 @@ version = "1.1.0"
2951source = "registry+https://github.com/rust-lang/crates.io-index"2971source = "registry+https://github.com/rust-lang/crates.io-index"
2952checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"2972checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
29532973
2974[[package]]
2975name = "rustc-hash"
2976version = "2.1.3"
2977source = "registry+https://github.com/rust-lang/crates.io-index"
2978checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
2979
2954[[package]]2980[[package]]
2955name = "rustc_version"2981name = "rustc_version"
2956version = "0.4.1"2982version = "0.4.1"
...@@ -3027,6 +3053,12 @@ version = "1.0.23"...@@ -3027,6 +3053,12 @@ version = "1.0.23"
3027source = "registry+https://github.com/rust-lang/crates.io-index"3053source = "registry+https://github.com/rust-lang/crates.io-index"
3028checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"3054checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
30293055
3056[[package]]
3057name = "ryu"
3058version = "1.0.23"
3059source = "registry+https://github.com/rust-lang/crates.io-index"
3060checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
3061
3030[[package]]3062[[package]]
3031name = "same-file"3063name = "same-file"
3032version = "1.0.6"3064version = "1.0.6"
...@@ -3414,6 +3446,15 @@ dependencies = [...@@ -3414,6 +3446,15 @@ dependencies = [
3414 "float-cmp",3446 "float-cmp",
3415]3447]
34163448
3449[[package]]
3450name = "subsetter"
3451version = "0.2.6"
3452source = "registry+https://github.com/rust-lang/crates.io-index"
3453checksum = "38803281d1c23166c5ebcb455439a5d2afe711cc909cf88af72448c297756ad6"
3454dependencies = [
3455 "rustc-hash 2.1.3",
3456]
3457
3417[[package]]3458[[package]]
3418name = "subtle"3459name = "subtle"
3419version = "2.6.1"3460version = "2.6.1"
...@@ -4157,7 +4198,7 @@ dependencies = [...@@ -4157,7 +4198,7 @@ dependencies = [
4157 "portable-atomic",4198 "portable-atomic",
4158 "profiling",4199 "profiling",
4159 "raw-window-handle",4200 "raw-window-handle",
4160 "rustc-hash",4201 "rustc-hash 1.1.0",
4161 "smallvec",4202 "smallvec",
4162 "thiserror 2.0.20",4203 "thiserror 2.0.20",
4163 "wgpu-core-deps-apple",4204 "wgpu-core-deps-apple",
apps/ios/Snowbound.xcodeproj/project.pbxproj+10-2
...@@ -8,14 +8,18 @@...@@ -8,14 +8,18 @@
88
9/* Begin PBXBuildFile section */9/* Begin PBXBuildFile section */
10 5B0000000000000000000101 /* Sample in Resources */ = {isa = PBXBuildFile; fileRef = 5B0000000000000000000201 /* Sample */; };10 5B0000000000000000000101 /* Sample in Resources */ = {isa = PBXBuildFile; fileRef = 5B0000000000000000000201 /* Sample */; };
11 5B0000000000000000000126 /* Snowbound Guide in Resources */ = {isa = PBXBuildFile; fileRef = 5B0000000000000000000226 /* Snowbound Guide */; };
12 5B0000000000000000000102 /* Snowbound-Tahoe.icon in Resources */ = {isa = PBXBuildFile; fileRef = 5B0000000000000000000205 /* Snowbound-Tahoe.icon */; };
11/* End PBXBuildFile section */13/* End PBXBuildFile section */
1214
13/* Begin PBXFileReference section */15/* Begin PBXFileReference section */
14 5B0000000000000000000200 /* Snowbound.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Snowbound.app; sourceTree = BUILT_PRODUCTS_DIR; };16 5B0000000000000000000200 /* Snowbound.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Snowbound.app; sourceTree = BUILT_PRODUCTS_DIR; };
15 5B0000000000000000000201 /* Sample */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Sample; path = "../../corpus/m6/native-features-01/notebook"; sourceTree = SOURCE_ROOT; };17 5B0000000000000000000201 /* Sample */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Sample; path = "../../corpus/m6/native-features-01/notebook"; sourceTree = SOURCE_ROOT; };
18 5B0000000000000000000226 /* Snowbound Guide */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "Snowbound Guide"; path = "../../docs/guide-notebook/Snowbound Guide"; sourceTree = SOURCE_ROOT; };
16 5B0000000000000000000202 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };19 5B0000000000000000000202 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
17 5B0000000000000000000203 /* Snowbound-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Snowbound-Bridging-Header.h"; sourceTree = "<group>"; };20 5B0000000000000000000203 /* Snowbound-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Snowbound-Bridging-Header.h"; sourceTree = "<group>"; };
18 5B0000000000000000000204 /* build-rust.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "build-rust.sh"; sourceTree = "<group>"; };21 5B0000000000000000000204 /* build-rust.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "build-rust.sh"; sourceTree = "<group>"; };
22 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; };
19/* End PBXFileReference section */23/* End PBXFileReference section */
2024
21/* Begin PBXFileSystemSynchronizedRootGroup section */25/* Begin PBXFileSystemSynchronizedRootGroup section */
...@@ -42,6 +46,8 @@...@@ -42,6 +46,8 @@
42 children = (46 children = (
43 5B0000000000000000000300 /* Snowbound */,47 5B0000000000000000000300 /* Snowbound */,
44 5B0000000000000000000201 /* Sample */,48 5B0000000000000000000201 /* Sample */,
49 5B0000000000000000000226 /* Snowbound Guide */,
50 5B0000000000000000000205 /* Snowbound-Tahoe.icon */,
45 5B0000000000000000000202 /* Info.plist */,51 5B0000000000000000000202 /* Info.plist */,
46 5B0000000000000000000203 /* Snowbound-Bridging-Header.h */,52 5B0000000000000000000203 /* Snowbound-Bridging-Header.h */,
47 5B0000000000000000000204 /* build-rust.sh */,53 5B0000000000000000000204 /* build-rust.sh */,
...@@ -121,6 +127,8 @@...@@ -121,6 +127,8 @@
121 buildActionMask = 2147483647;127 buildActionMask = 2147483647;
122 files = (128 files = (
123 5B0000000000000000000101 /* Sample in Resources */,129 5B0000000000000000000101 /* Sample in Resources */,
130 5B0000000000000000000126 /* Snowbound Guide in Resources */,
131 5B0000000000000000000102 /* Snowbound-Tahoe.icon in Resources */,
124 );132 );
125 runOnlyForDeploymentPostprocessing = 0;133 runOnlyForDeploymentPostprocessing = 0;
126 };134 };
...@@ -198,7 +206,7 @@...@@ -198,7 +206,7 @@
198 isa = XCBuildConfiguration;206 isa = XCBuildConfiguration;
199 buildSettings = {207 buildSettings = {
200 ARCHS = arm64;208 ARCHS = arm64;
201 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;209 ASSETCATALOG_COMPILER_APPICON_NAME = "Snowbound-Tahoe";
202 CODE_SIGN_ENTITLEMENTS = Snowbound.entitlements;210 CODE_SIGN_ENTITLEMENTS = Snowbound.entitlements;
203 CURRENT_PROJECT_VERSION = 1;211 CURRENT_PROJECT_VERSION = 1;
204 GENERATE_INFOPLIST_FILE = YES;212 GENERATE_INFOPLIST_FILE = YES;
...@@ -235,7 +243,7 @@...@@ -235,7 +243,7 @@
235 isa = XCBuildConfiguration;243 isa = XCBuildConfiguration;
236 buildSettings = {244 buildSettings = {
237 ARCHS = arm64;245 ARCHS = arm64;
238 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;246 ASSETCATALOG_COMPILER_APPICON_NAME = "Snowbound-Tahoe";
239 CODE_SIGN_ENTITLEMENTS = Snowbound.entitlements;247 CODE_SIGN_ENTITLEMENTS = Snowbound.entitlements;
240 CURRENT_PROJECT_VERSION = 1;248 CURRENT_PROJECT_VERSION = 1;
241 GENERATE_INFOPLIST_FILE = YES;249 GENERATE_INFOPLIST_FILE = YES;
apps/ios/Snowbound/App.swift+27
...@@ -44,6 +44,32 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {...@@ -44,6 +44,32 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
44 }44 }
45}45}
4646
47/// The desktop's Appearance option: the system's, or light or dark regardless of it.
48enum Appearance {
49 static let key = "appearance"
50
51 static var style: UIUserInterfaceStyle {
52 get { UIUserInterfaceStyle(rawValue: UserDefaults.standard.integer(forKey: key)) ?? .unspecified }
53 set {
54 UserDefaults.standard.set(newValue.rawValue, forKey: key)
55 for case let scene as UIWindowScene in UIApplication.shared.connectedScenes {
56 for window in scene.windows { window.overrideUserInterfaceStyle = newValue }
57 }
58 }
59 }
60
61 static func menu() -> UIMenu {
62 let choices: [(String, UIUserInterfaceStyle)] = [("System", .unspecified), ("Light", .light), ("Dark", .dark)]
63 return UIMenu(
64 title: "Appearance", image: UIImage(systemName: "circle.lefthalf.filled"),
65 children: choices.map { title, style in
66 let action = UIAction(title: title) { _ in Appearance.style = style }
67 action.state = Appearance.style == style ? .on : .off
68 return action
69 })
70 }
71}
72
47/// Where the reader was, reopened on the next launch as Notes reopens its last note.73/// Where the reader was, reopened on the next launch as Notes reopens its last note.
48private struct Place: Codable {74private struct Place: Codable {
49 let notebook: String75 let notebook: String
...@@ -71,6 +97,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro...@@ -71,6 +97,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro
71 func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {97 func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {
72 guard let scene = scene as? UIWindowScene else { return }98 guard let scene = scene as? UIWindowScene else { return }
73 let window = UIWindow(windowScene: scene)99 let window = UIWindow(windowScene: scene)
100 window.overrideUserInterfaceStyle = Appearance.style
74 split.delegate = self101 split.delegate = self
75 split.scene = self102 split.scene = self
76 split.preferredDisplayMode = .oneBesideSecondary103 split.preferredDisplayMode = .oneBesideSecondary
apps/ios/Snowbound/CanvasView.swift+30-5
...@@ -771,7 +771,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -771,7 +771,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
771 case #selector(copy(_:)), #selector(cut(_:)), #selector(delete(_:)),771 case #selector(copy(_:)), #selector(cut(_:)), #selector(delete(_:)),
772 #selector(toggleBoldface(_:)), #selector(toggleItalics(_:)), #selector(toggleUnderline(_:)):772 #selector(toggleBoldface(_:)), #selector(toggleItalics(_:)), #selector(toggleUnderline(_:)):
773 return lo < hi773 return lo < hi
774 case #selector(paste(_:)): return UIPasteboard.general.hasStrings774 case #selector(paste(_:)): return UIPasteboard.general.hasStrings || UIPasteboard.general.hasImages
775 case #selector(select(_:)): return lo == hi && hasText775 case #selector(select(_:)): return lo == hi && hasText
776 case #selector(selectAll(_:)): return hasText && (lo > 0 || hi < endOfDocument.offset)776 case #selector(selectAll(_:)): return hasText && (lo > 0 || hi < endOfDocument.offset)
777 default: return super.canPerformAction(action, withSender: sender)777 default: return super.canPerformAction(action, withSender: sender)
...@@ -799,10 +799,15 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -799,10 +799,15 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
799 edit(external: true) { replacing(range, with: "") }799 edit(external: true) { replacing(range, with: "") }
800 }800 }
801801
802 /// Pasted text takes the keyboard's language, as Windows gives the clipboard.802 /// Pasted text takes the keyboard's language, as Windows gives the clipboard. Text wins
803 /// over a picture, which apps put beside copied text.
803 override func paste(_ sender: Any?) {804 override func paste(_ sender: Any?) {
804 guard let handle, let text = UIPasteboard.general.string else { return }805 guard let handle else { return }
805 edit(external: true) { sb_paste(handle, text, textInputMode?.primaryLanguage ?? "") }806 if let text = UIPasteboard.general.string {
807 edit(external: true) { sb_paste(handle, text, textInputMode?.primaryLanguage ?? "") }
808 } else if let image = UIPasteboard.general.image {
809 insertPicture(image)
810 }
806 }811 }
807812
808 override func select(_ sender: Any?) {813 override func select(_ sender: Any?) {
...@@ -958,7 +963,27 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -958,7 +963,27 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
958 return focused963 return focused
959 }964 }
960965
961 /// Puts a JPEG or PNG after the caret's paragraph at `size` points.966 /// A photo as OneNote 2010 can read it: JPEG, at most 2048 pixels across, shown at most
967 /// 220 points wide.
968 func insertPicture(_ image: UIImage) {
969 let longest: CGFloat = 2048
970 let scale = min(1, longest / max(image.size.width * image.scale, image.size.height * image.scale))
971 let pixels = CGSize(
972 width: (image.size.width * image.scale * scale).rounded(),
973 height: (image.size.height * image.scale * scale).rounded())
974 let format = UIGraphicsImageRendererFormat()
975 format.scale = 1
976 let drawn = UIGraphicsImageRenderer(size: pixels, format: format).image { _ in
977 image.draw(in: CGRect(origin: .zero, size: pixels))
978 }
979 guard let data = drawn.jpegData(compressionQuality: 0.85) else { return }
980 // OneNote shows a picture's pixels at 96 per inch.
981 var size = CGSize(width: pixels.width * 0.75, height: pixels.height * 0.75)
982 if size.width > 220 { size = CGSize(width: 220, height: size.height * 220 / size.width) }
983 insertPicture(data, size: size)
984 }
985
986 /// Puts a JPEG or PNG at the caret at `size` points.
962 func insertPicture(_ data: Data, size: CGSize) {987 func insertPicture(_ data: Data, size: CGSize) {
963 guard let handle else { return }988 guard let handle else { return }
964 edit(external: true) {989 edit(external: true) {
apps/ios/Snowbound/Lists.swift+35-1
...@@ -22,6 +22,8 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -22,6 +22,8 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
22 /// Opening, or why the notebook cannot be.22 /// Opening, or why the notebook cannot be.
23 case status(notebook: String)23 case status(notebook: String)
24 case newNotebook, openFolder, connect24 case newNotebook, openFolder, connect
25 /// Copies the Snowbound Guide to On My iPhone and opens it, until it is there.
26 case guide
25 /// New Notebook in iCloud Drive, and the way to it while iCloud Drive is off.27 /// New Notebook in iCloud Drive, and the way to it while iCloud Drive is off.
26 case newInCloud, turnOnICloud28 case newInCloud, turnOnICloud
27 }29 }
...@@ -101,6 +103,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -101,6 +103,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
101 guard let self else { return }103 guard let self else { return }
102 Author.ask(from: self) {}104 Author.ask(from: self) {}
103 })105 })
106 more.append(Appearance.menu())
104 provide(actions + [UIMenu(options: .displayInline, children: more)])107 provide(actions + [UIMenu(options: .displayInline, children: more)])
105 }108 }
106 ])109 ])
...@@ -128,13 +131,15 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -128,13 +131,15 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
128131
129 func reload() {132 func reload() {
130 guard isViewLoaded else { return }133 guard isViewLoaded else { return }
134 let copied = Notebooks.onDevice.contains { $0.source == .documents(path: Notebooks.guide) }
135 let guide: [Item] = Notebooks.guideOffered && !copied ? [.guide] : []
131 let cloud: [(Location, [Notebook], [Item])] =136 let cloud: [(Location, [Notebook], [Item])] =
132 ICloud.documents != nil137 ICloud.documents != nil
133 ? [(.icloud, Notebooks.inCloud, [.newInCloud])]138 ? [(.icloud, Notebooks.inCloud, [.newInCloud])]
134 : ICloud.signedIn ? [] : [(.icloud, [], [.turnOnICloud])]139 : ICloud.signedIn ? [] : [(.icloud, [], [.turnOnICloud])]
135 let locations: [(Location, [Notebook], [Item])] =140 let locations: [(Location, [Notebook], [Item])] =
136 cloud + (Notebooks.showsOnDevice ? [(.onDevice, Notebooks.onDevice, [.newNotebook])] : [])141 cloud + (Notebooks.showsOnDevice ? [(.onDevice, Notebooks.onDevice, [.newNotebook])] : [])
137 + [(.elsewhere, Notebooks.elsewhere, [.openFolder, .connect])]142 + [(.elsewhere, Notebooks.elsewhere, [.openFolder, .connect] + guide)]
138 var sections = NSDiffableDataSourceSnapshot<Location, Item>()143 var sections = NSDiffableDataSourceSnapshot<Location, Item>()
139 sections.appendSections(locations.map(\.0))144 sections.appendSections(locations.map(\.0))
140 dataSource.apply(sections, animatingDifferences: false)145 dataSource.apply(sections, animatingDifferences: false)
...@@ -260,6 +265,10 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -260,6 +265,10 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
260 content.text = "New Notebook…"265 content.text = "New Notebook…"
261 content.image = UIImage(systemName: "plus")266 content.image = UIImage(systemName: "plus")
262 content.textProperties.color = .tintColor267 content.textProperties.color = .tintColor
268 case .guide:
269 content.text = "Open the Snowbound Guide"
270 content.image = UIImage(systemName: "book")
271 content.textProperties.color = .tintColor
263 case .newNotebook, .openFolder, .connect:272 case .newNotebook, .openFolder, .connect:
264 content.text =273 content.text =
265 item == .newNotebook ? "New Notebook…" : item == .openFolder ? "Open Folder…" : "Connect to Server…"274 item == .newNotebook ? "New Notebook…" : item == .openFolder ? "Open Folder…" : "Connect to Server…"
...@@ -511,6 +520,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -511,6 +520,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
511 if let settings = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(settings) }520 if let settings = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(settings) }
512 case .openFolder: openFolder()521 case .openFolder: openFolder()
513 case .connect: connect()522 case .connect: connect()
523 case .guide: openGuide()
514 case .location: break524 case .location: break
515 }525 }
516 return false526 return false
...@@ -586,6 +596,30 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke...@@ -586,6 +596,30 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
586 }596 }
587 }597 }
588598
599 /// Copies the guide the app carries to On My iPhone and opens the copy, so the carried one
600 /// stays as shipped.
601 private func openGuide() {
602 let copy = documentsDirectory.appendingPathComponent(Notebooks.guide)
603 background({ () -> String? in
604 guard !FileManager.default.fileExists(atPath: copy.path) else { return nil }
605 guard let carried = Bundle.main.url(forResource: Notebooks.guide, withExtension: nil) else {
606 return "The guide is missing from this copy of Snowbound."
607 }
608 do { try FileManager.default.copyItem(at: carried, to: copy) } catch { return error.localizedDescription }
609 return nil
610 }) { [weak self] problem in
611 guard let self else { return }
612 if let problem { return refuse("Can’t Open the Guide", problem) }
613 if !Notebooks.showsOnDevice { Notebooks.showsOnDevice = true }
614 rescan { [weak self] notebook in
615 guard notebook.source == .documents(path: Notebooks.guide),
616 let tab = notebook.tabs.first(where: \.readable)
617 else { return }
618 self?.onOpen?(tab, notebook)
619 }
620 }
621 }
622
589 private func refuse(_ title: String, _ message: String) {623 private func refuse(_ title: String, _ message: String) {
590 let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)624 let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
591 alert.addAction(UIAlertAction(title: "OK", style: .default))625 alert.addAction(UIAlertAction(title: "OK", style: .default))
apps/ios/Snowbound/Notebooks.swift+4
...@@ -391,6 +391,10 @@ enum Notebooks {...@@ -391,6 +391,10 @@ enum Notebooks {
391391
392 private static let key = "notebooks"392 private static let key = "notebooks"
393 private static let hidden = "hidesOnDevice"393 private static let hidden = "hidesOnDevice"
394 /// The Snowbound Guide's folder, in the app and in Documents once copied there.
395 static let guide = "Snowbound Guide"
396 /// Whether the list offers the guide; off until Clover has read it through.
397 static let guideOffered = false
394 private static let scripted = ProcessInfo.processInfo.environment["SNOWBOUND_NOTEBOOK"]398 private static let scripted = ProcessInfo.processInfo.environment["SNOWBOUND_NOTEBOOK"]
395 /// The folders and sections in Documents, as On My iPhone lists them.399 /// The folders and sections in Documents, as On My iPhone lists them.
396 private(set) static var onDevice: [Notebook] = []400 private(set) static var onDevice: [Notebook] = []
apps/ios/Snowbound/PageViewController.swift+2-22
...@@ -330,7 +330,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate...@@ -330,7 +330,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
330 guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return }330 guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return }
331 provider.loadObject(ofClass: UIImage.self) { [weak self] image, _ in331 provider.loadObject(ofClass: UIImage.self) { [weak self] image, _ in
332 guard let image = image as? UIImage else { return }332 guard let image = image as? UIImage else { return }
333 DispatchQueue.main.async { self?.insert(image) }333 DispatchQueue.main.async { self?.canvas.insertPicture(image) }
334 }334 }
335 }335 }
336336
...@@ -338,27 +338,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate...@@ -338,27 +338,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
338 _ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]338 _ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
339 ) {339 ) {
340 picker.dismiss(animated: true)340 picker.dismiss(animated: true)
341 if let image = info[.originalImage] as? UIImage { insert(image) }341 if let image = info[.originalImage] as? UIImage { canvas.insertPicture(image) }
342 }
343
344 /// A photo as OneNote 2010 can read it: JPEG, at most 2048 pixels across, shown at most
345 /// 220 points wide.
346 private func insert(_ image: UIImage) {
347 let longest: CGFloat = 2048
348 let scale = min(1, longest / max(image.size.width * image.scale, image.size.height * image.scale))
349 let pixels = CGSize(
350 width: (image.size.width * image.scale * scale).rounded(),
351 height: (image.size.height * image.scale * scale).rounded())
352 let format = UIGraphicsImageRendererFormat()
353 format.scale = 1
354 let drawn = UIGraphicsImageRenderer(size: pixels, format: format).image { _ in
355 image.draw(in: CGRect(origin: .zero, size: pixels))
356 }
357 guard let data = drawn.jpegData(compressionQuality: 0.85) else { return }
358 // OneNote shows a picture's pixels at 96 per inch.
359 var size = CGSize(width: pixels.width * 0.75, height: pixels.height * 0.75)
360 if size.width > 220 { size = CGSize(width: 220, height: size.height * 220 / size.width) }
361 canvas.insertPicture(data, size: size)
362 }342 }
363}343}
364344
arc/canvas.md+21
...@@ -117,6 +117,14 @@ package. The page draws the PNG OneNote rendered of each...@@ -117,6 +117,14 @@ package. The page draws the PNG OneNote rendered of each
117pictures. Snowbound does not write XPS, so a printout page deleted and brought117pictures. Snowbound does not write XPS, so a printout page deleted and brought
118back by undo returns as that picture (`corpus/printout`).118back by undo returns as that picture (`corpus/printout`).
119119
120Pictures go in as OneNote 2010 pastes and inserts them. At a caret in text the
121paragraph splits around the picture, as around a file. On blank page the picture
122lies on the page at the caret, and the caret moves to the grid row below it. A
123picture takes the size its resolution gives it, or 96 dpi without one. Paste
124takes files first (a picture file as its picture), then text, then a picture:
125Finder offers a copied file's name and icon beside it, and other apps a picture
126of copied text.
127
120## Recordings128## Recordings
121129
122Record Audio and Record Video work as OneNote 2010's do. The caret's paragraph130Record 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...@@ -222,6 +230,19 @@ iOS) offers the dictionary's corrections, Ignore and Add to Dictionary, and the
222Spelling pane (F7) walks the page's marked words. A correction is an ordinary230Spelling pane (F7) walks the page's marked words. A correction is an ordinary
223edit, one revision. Nothing about spelling is stored in the page.231edit, one revision. Nothing about spelling is stored in the page.
224232
233## Printing and PDF
234
235`canvas::print` lays pages on paper as OneNote 2010 prints them (`corpus/print`): between
236half-inch top and bottom margins with the margin origin an inch in, the whole page shrunk
237when its content reaches past the paper's right edge, and each sheet after the first
238starting at the line of text or picture the one before would have cut. Rule lines and
239template art print across the paper; the page colour does not. The footer names the
240section and numbers the sheets. `draw::pdf` writes the same primitives the screen paints
241as PDF: text in subset fonts whose ToUnicode maps come from the laid-out text, so it
242selects and searches, ligatures included; ink and shapes as paths; pictures and tag
243icons as images. The desktop's Print hands that PDF to AppKit's print panel, the XDG print
244portal or the shell's print verb for PDFs; Export as PDF saves it.
245
225## Search, dates, conflicts246## Search, dates, conflicts
226247
227Smaller modules follow the same pattern of reproducing OneNote's behaviour248Smaller modules follow the same pattern of reproducing OneNote's behaviour
corpus/equation-select/README.md created+26
...@@ -0,0 +1,26 @@
1# Edits reaching into equation objects
2
3`lab/` holds COM reads of a disposable OneNote 2010 clone editing a copy of
4`corpus/math-edit/native-editor-4` by hand. `before.xml` is the page as it opened and
5`after.xml` as it ended; the rest are reads between steps.
6
7- A mouse selection from inside `√x+1`'s root into `∛x`'s shows both objects selected
8 whole, and Backspace removes both paragraphs (`roots-deleted.xml`).
9- From after `a₁` in `a₁+b₂` into the body of a sum ending its paragraph: the sum is taken
10 whole with the paragraph's end, so Backspace joins `a₁` to the integral below.
11- From a fraction's numerator in `One a/b+c two` into the root of `Three √x+y four`:
12 both objects whole, leaving `One +y four`.
13- Enter between `→` and `∞` in `lim_(n→∞) a_n` makes the limit an equation array
14 (`limit-enter.xml`).
15- A click in "Type equation here." selects the placeholder whole; Enter leaves its
16 paragraph empty above a new one (`placeholder-enter.xml`), and typing `x` replaces it.
17- A drag past a line's end also takes the paragraph's end: "Alpha beta", "Gamma delta"
18 selected from `Alp` to past `delta` delete to `Alpepsilon`.
19
20`candidate/` is the output of `selections_into_equation_objects_take_them_whole` in
21`crates/canvas/tests/links_equations.rs`: the same edits through the editor on the same
22page, one revision each. `cold/` is a fresh OneNote 2010 read of it, whose MathML matches
23the lab's equation for equation. `tools/test_equation_select.py` checks this without a VM.
24Regenerate with `CANVAS_EQUATION_SELECT_EXPORT` set to a new absolute directory while
25running the test, then cold-open it with
26`tools/native_runner.py OUTPUT COLD --expected-pages 1 --collect-notebook --screenshots`.
corpus/equation-select/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/equation-select/candidate/Open Notebook.onetoc2 differ
corpus/equation-select/candidate/links.one created
Binary files /dev/null and b/corpus/equation-select/candidate/links.one differ
corpus/equation-select/cold/commands.jsonl created+3
...@@ -0,0 +1,3 @@
1{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
2{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-D9D5B6CC -ExpectedPages 1 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null}
3{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
corpus/equation-select/cold/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-d9d5b6cc", "hostname": "ONE-M6-D9D5B6CC"}
corpus/equation-select/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/equation-select/cold/notebook/Open Notebook.onetoc2 differ
corpus/equation-select/cold/notebook/links.one created
Binary files /dev/null and b/corpus/equation-select/cold/notebook/links.one differ
corpus/equation-select/cold/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-D9D5B6CC",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/equation-select/cold/read/hierarchy.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{8EC042B5-53A9-4A5F-977C-BDE79F595742}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T21:53:10.000Z" color="#B49EDE"><one:Section name="links" ID="{F66241EF-4C34-059A-0225-FD5266E1C43D}{1}{B0}" path="C:\one-tests\runs\capture\notebook\links.one" lastModifiedTime="2026-09-30T21:53:10.000Z" color="#8AA8E4"><one:Page ID="{576F8766-2423-0AA7-148E-DD329E024DC6}{1}{B0}" name="Read about Rust the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-28T01:50:43.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/equation-select/cold/read/page-000.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Read about Rust the Rust site - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/equation-select/cold/read/page-000.png created
Binary files /dev/null and b/corpus/equation-select/cold/read/page-000.png differ
corpus/equation-select/cold/read/page-000.xml created+5
...@@ -0,0 +1,5 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{576F8766-2423-0AA7-148E-DD329E024DC6}{1}{B0}" name="Read about Rust the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-28T01:50:43.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" lastModifiedTime="2026-09-28T01:50:43.000Z" objectID="{F66241EF-4C34-059A-0225-FD5266E1C43D}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="198.3904571533203" height="448.1435241699218"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-28T01:49:44.000Z" objectID="{F66241EF-4C34-059A-0225-FD5266E1C43D}{24}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:T><![CDATA[Read about Rust <a
3href="https://example.invalid/rust">the Rust site</a>]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:49:49.000Z" lastModifiedTime="2026-09-28T01:49:50.000Z" objectID="{C327A1EA-0FFC-4376-B9C4-E7EDEB9CAA7B}{12}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:msub><mml:mi>a</mml:mi><mml:mn>1</mml:mn></mml:msub><mml:msubsup><mml:mo stretchy="false">∫</mml:mo><mml:mn>0</mml:mn><mml:mn>1</mml:mn></mml:msubsup><mml:mrow><mml:mi>x</mml:mi><mml:mo>&nbsp;</mml:mo><mml:mi>d</mml:mi><mml:mi>x</mml:mi><mml:mo>&nbsp;</mml:mo></mml:mrow></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:49:58.000Z" lastModifiedTime="2026-09-28T01:49:59.000Z" objectID="{C327A1EA-0FFC-4376-B9C4-E7EDEB9CAA7B}{28}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mroot><mml:mi>x</mml:mi><mml:mi>n</mml:mi></mml:mroot></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:04.000Z" lastModifiedTime="2026-09-28T01:50:05.000Z" objectID="{C327A1EA-0FFC-4376-B9C4-E7EDEB9CAA7B}{37}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfrac><mml:mi>a</mml:mi><mml:mi>b</mml:mi></mml:mfrac></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:06.000Z" lastModifiedTime="2026-09-28T01:50:07.000Z" objectID="{C327A1EA-0FFC-4376-B9C4-E7EDEB9CAA7B}{40}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:munderover><mml:mo stretchy="false">∏</mml:mo><mml:mrow><mml:mi>k</mml:mi><mml:mo>=</mml:mo><mml:mn>1</mml:mn></mml:mrow><mml:mi>n</mml:mi></mml:munderover><mml:mrow><mml:mi>k</mml:mi><mml:mo>&nbsp;</mml:mo></mml:mrow></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:08.000Z" lastModifiedTime="2026-09-28T01:50:09.000Z" objectID="{C327A1EA-0FFC-4376-B9C4-E7EDEB9CAA7B}{43}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfenced open="[" close="]"><mml:mrow><mml:mi>a</mml:mi><mml:mo>+</mml:mo><mml:mi>b</mml:mi></mml:mrow></mml:mfenced></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:11.000Z" lastModifiedTime="2026-09-28T01:50:11.000Z" objectID="{C327A1EA-0FFC-4376-B9C4-E7EDEB9CAA7B}{46}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mover accent="false"><mml:mi>x</mml:mi><mml:mo stretchy="true">¯</mml:mo></mml:mover></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:12.000Z" lastModifiedTime="2026-09-28T01:50:14.000Z" objectID="{C327A1EA-0FFC-4376-B9C4-E7EDEB9CAA7B}{49}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mover accent="true"><mml:mi>x</mml:mi><mml:mo>^</mml:mo></mml:mover></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:15.000Z" lastModifiedTime="2026-09-28T01:50:16.000Z" objectID="{C327A1EA-0FFC-4376-B9C4-E7EDEB9CAA7B}{52}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mtable><mml:mtr><mml:mtd><mml:mn>1</mml:mn></mml:mtd><mml:mtd><mml:mn>2</mml:mn></mml:mtd></mml:mtr><mml:mtr><mml:mtd><mml:mn>3</mml:mn></mml:mtd><mml:mtd><mml:mn>4</mml:mn></mml:mtd></mml:mtr></mml:mtable></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:17.000Z" lastModifiedTime="2026-09-28T01:50:18.000Z" objectID="{C327A1EA-0FFC-4376-B9C4-E7EDEB9CAA7B}{55}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mtable><mml:mtr><mml:mtd><mml:maligngroup/><mml:mi>x</mml:mi><mml:malignmark/><mml:mo>=</mml:mo><mml:mn>1</mml:mn></mml:mtd></mml:mtr><mml:mtr><mml:mtd><mml:maligngroup/><mml:mi>y</mml:mi><mml:malignmark/><mml:mo>=</mml:mo><mml:mn>2</mml:mn></mml:mtd></mml:mtr></mml:mtable></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:18.000Z" lastModifiedTime="2026-09-28T01:50:20.000Z" objectID="{C327A1EA-0FFC-4376-B9C4-E7EDEB9CAA7B}{58}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mover><mml:mi>x</mml:mi><mml:mn>2</mml:mn></mml:mover></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:21.000Z" lastModifiedTime="2026-09-28T01:50:22.000Z" objectID="{C327A1EA-0FFC-4376-B9C4-E7EDEB9CAA7B}{61}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:munder><mml:mi>x</mml:mi><mml:mn>2</mml:mn></mml:munder></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:23.000Z" lastModifiedTime="2026-09-28T01:50:24.000Z" objectID="{C327A1EA-0FFC-4376-B9C4-E7EDEB9CAA7B}{64}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mpadded><mml:mi>x</mml:mi></mml:mpadded></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:26.000Z" lastModifiedTime="2026-09-28T01:50:26.000Z" objectID="{C327A1EA-0FFC-4376-B9C4-E7EDEB9CAA7B}{67}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:menclose notation="box"><mml:mi>x</mml:mi></mml:menclose></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:27.000Z" lastModifiedTime="2026-09-28T01:50:29.000Z" objectID="{C327A1EA-0FFC-4376-B9C4-E7EDEB9CAA7B}{70}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfrac><mml:mrow><mml:mi>f</mml:mi><mml:mfenced><mml:mi>x</mml:mi></mml:mfenced></mml:mrow><mml:mrow><mml:msup><mml:mi>x</mml:mi><mml:mn>2</mml:mn></mml:msup><mml:mo>+</mml:mo><mml:mn>1</mml:mn></mml:mrow></mml:mfrac></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:30.000Z" lastModifiedTime="2026-09-28T01:50:31.000Z" objectID="{C327A1EA-0FFC-4376-B9C4-E7EDEB9CAA7B}{73}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mover><mml:mo stretchy="false">∑</mml:mo><mml:mi>n</mml:mi></mml:mover><mml:mrow><mml:mi>x</mml:mi><mml:mo>&nbsp;</mml:mo></mml:mrow></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:31.000Z" lastModifiedTime="2026-09-28T01:50:33.000Z" objectID="{C327A1EA-0FFC-4376-B9C4-E7EDEB9CAA7B}{76}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:msup><mml:mi>e</mml:mi><mml:mrow><mml:mi>x</mml:mi><mml:mo>+</mml:mo><mml:mn>1</mml:mn></mml:mrow></mml:msup></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:34.000Z" lastModifiedTime="2026-09-28T01:50:35.000Z" objectID="{C327A1EA-0FFC-4376-B9C4-E7EDEB9CAA7B}{79}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mi>&#120572;</mml:mi><mml:mo>+</mml:mo><mml:mi>&#120573;</mml:mi></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:35.000Z" lastModifiedTime="2026-09-28T01:50:37.000Z" objectID="{C327A1EA-0FFC-4376-B9C4-E7EDEB9CAA7B}{82}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfrac><mml:mrow><mml:mi>a</mml:mi><mml:mo>+</mml:mo><mml:mi>b</mml:mi></mml:mrow><mml:mrow><mml:mi>c</mml:mi><mml:mo>+</mml:mo><mml:mi>d</mml:mi></mml:mrow></mml:mfrac></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:38.000Z" lastModifiedTime="2026-09-28T01:50:39.000Z" objectID="{C327A1EA-0FFC-4376-B9C4-E7EDEB9CAA7B}{85}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:msup><mml:mi>x</mml:mi><mml:mn>2</mml:mn></mml:msup><mml:mo>+</mml:mo><mml:msup><mml:mi>y</mml:mi><mml:mn>2</mml:mn></mml:msup><mml:mo>=</mml:mo><mml:msup><mml:mi>z</mml:mi><mml:mn>2</mml:mn></mml:msup></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:40.000Z" lastModifiedTime="2026-09-28T01:50:42.000Z" objectID="{C327A1EA-0FFC-4376-B9C4-E7EDEB9CAA7B}{88}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mrow><mml:mrow><mml:munder><mml:mrow><mml:mi>lim</mml:mi></mml:mrow><mml:mrow><mml:mtable><mml:mtr><mml:mtd><mml:maligngroup/><mml:mi>n</mml:mi><mml:mo>→</mml:mo></mml:mtd></mml:mtr><mml:mtr><mml:mtd><mml:maligngroup/><mml:mo>∞</mml:mo></mml:mtd></mml:mtr></mml:mtable></mml:mrow></mml:munder></mml:mrow><mml:mo>⁡</mml:mo><mml:mrow><mml:msub><mml:mi>a</mml:mi><mml:mi>n</mml:mi></mml:msub></mml:mrow></mml:mrow></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:42.000Z" lastModifiedTime="2026-09-28T01:50:43.000Z" objectID="{C327A1EA-0FFC-4376-B9C4-E7EDEB9CAA7B}{92}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfenced open="{" close="}"><mml:mrow><mml:mi>a</mml:mi><mml:mo>+</mml:mo><mml:mi>b</mml:mi></mml:mrow></mml:mfenced></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:43.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{C327A1EA-0FFC-4376-B9C4-E7EDEB9CAA7B}{95}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[<span
4style='font-family:Calibri' lang=en-US>One </span><!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML"><mml:mo>+</mml:mo><mml:mi>y</mml:mi><mml:mo>&nbsp;</mml:mo></mml:math><![endif]--><span
5style='font-family:Calibri' lang=en-US> four</span>]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:20.000Z" lastModifiedTime="2025-08-18T14:13:20.000Z" objectID="{1615449D-59CE-01B5-3905-12C6106409CF}{5}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/equation-select/cold/read/payloads.json created+3
...@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/equation-select/cold/run.json created+18
...@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/eqlab/candidate",
3 "expected_pages": 1,
4 "author": null,
5 "author_timeout_seconds": 600,
6 "inspect": false,
7 "collect_notebook": true,
8 "base": {
9 "file": "win7-office-base.qcow2",
10 "format": "qcow2",
11 "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
12 "virtual_size": 68719476736
13 },
14 "scripts": {
15 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
16 "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
17 }
18}
corpus/equation-select/cold/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/equation-select/cold/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/equation-select/cold/source.json created+14
...@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1604,
5 "sha256": "90206bfbbba99bcebfd2d4896c458dbca5d83120f6649b11abb885730fdb69b9",
6 "mtime_ns": 1790805000764803414
7 },
8 {
9 "path": "links.one",
10 "bytes": 46824,
11 "sha256": "2e6bfeb5f7eded840d97512230cf18bb67924d4e6d28484cf957e9882eaedae1",
12 "mtime_ns": 1790805000764567872
13 }
14]
corpus/equation-select/cold/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
corpus/equation-select/lab/after.xml created+5
...@@ -0,0 +1,5 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{74DAA177-309A-0F3A-0778-74DBF1E66004}{1}{B0}" name="Read about Rust the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-30T21:46:18.000Z" pageLevel="1" isCurrentlyViewed="true" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-30T21:43:34.000Z" objectID="{D5D767FE-588D-0007-11D3-54BB0905E9FF}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="198.3904571533203" height="514.7504272460937"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-28T01:49:44.000Z" objectID="{D5D767FE-588D-0007-11D3-54BB0905E9FF}{24}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:T><![CDATA[Read about Rust <a
3href="https://example.invalid/rust">the Rust site</a>]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:49:49.000Z" lastModifiedTime="2026-09-30T21:18:26.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{12}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:msub><mml:mi>a</mml:mi><mml:mn>1</mml:mn></mml:msub><mml:msubsup><mml:mo stretchy="false">∫</mml:mo><mml:mn>0</mml:mn><mml:mn>1</mml:mn></mml:msubsup><mml:mrow><mml:mi>x</mml:mi><mml:mo>&nbsp;</mml:mo><mml:mi>d</mml:mi><mml:mi>x</mml:mi><mml:mo>&nbsp;</mml:mo></mml:mrow></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:02.000Z" lastModifiedTime="2026-09-28T01:50:03.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{34}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mroot><mml:mi>x</mml:mi><mml:mi>n</mml:mi></mml:mroot></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:04.000Z" lastModifiedTime="2026-09-28T01:50:05.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{37}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfrac><mml:mi>a</mml:mi><mml:mi>b</mml:mi></mml:mfrac></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:06.000Z" lastModifiedTime="2026-09-28T01:50:07.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{40}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:munderover><mml:mo stretchy="false">∏</mml:mo><mml:mrow><mml:mi>k</mml:mi><mml:mo>=</mml:mo><mml:mn>1</mml:mn></mml:mrow><mml:mi>n</mml:mi></mml:munderover><mml:mrow><mml:mi>k</mml:mi><mml:mo>&nbsp;</mml:mo></mml:mrow></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:08.000Z" lastModifiedTime="2026-09-28T01:50:09.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{43}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfenced open="[" close="]"><mml:mrow><mml:mi>a</mml:mi><mml:mo>+</mml:mo><mml:mi>b</mml:mi></mml:mrow></mml:mfenced></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:11.000Z" lastModifiedTime="2026-09-28T01:50:11.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{46}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mover accent="false"><mml:mi>x</mml:mi><mml:mo stretchy="true">¯</mml:mo></mml:mover></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:12.000Z" lastModifiedTime="2026-09-28T01:50:14.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{49}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mover accent="true"><mml:mi>x</mml:mi><mml:mo>^</mml:mo></mml:mover></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:15.000Z" lastModifiedTime="2026-09-28T01:50:16.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{52}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mtable><mml:mtr><mml:mtd><mml:mn>1</mml:mn></mml:mtd><mml:mtd><mml:mn>2</mml:mn></mml:mtd></mml:mtr><mml:mtr><mml:mtd><mml:mn>3</mml:mn></mml:mtd><mml:mtd><mml:mn>4</mml:mn></mml:mtd></mml:mtr></mml:mtable></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:17.000Z" lastModifiedTime="2026-09-28T01:50:18.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{55}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mtable><mml:mtr><mml:mtd><mml:maligngroup/><mml:mi>x</mml:mi><mml:malignmark/><mml:mo>=</mml:mo><mml:mn>1</mml:mn></mml:mtd></mml:mtr><mml:mtr><mml:mtd><mml:maligngroup/><mml:mi>y</mml:mi><mml:malignmark/><mml:mo>=</mml:mo><mml:mn>2</mml:mn></mml:mtd></mml:mtr></mml:mtable></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:18.000Z" lastModifiedTime="2026-09-28T01:50:20.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{58}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mover><mml:mi>x</mml:mi><mml:mn>2</mml:mn></mml:mover></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:21.000Z" lastModifiedTime="2026-09-28T01:50:22.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{61}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:munder><mml:mi>x</mml:mi><mml:mn>2</mml:mn></mml:munder></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:23.000Z" lastModifiedTime="2026-09-28T01:50:24.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{64}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mpadded><mml:mi>x</mml:mi></mml:mpadded></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:26.000Z" lastModifiedTime="2026-09-28T01:50:26.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{67}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:menclose notation="box"><mml:mi>x</mml:mi></mml:menclose></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:27.000Z" lastModifiedTime="2026-09-28T01:50:29.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{70}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfrac><mml:mrow><mml:mi>f</mml:mi><mml:mfenced><mml:mi>x</mml:mi></mml:mfenced></mml:mrow><mml:mrow><mml:msup><mml:mi>x</mml:mi><mml:mn>2</mml:mn></mml:msup><mml:mo>+</mml:mo><mml:mn>1</mml:mn></mml:mrow></mml:mfrac></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:30.000Z" lastModifiedTime="2026-09-28T01:50:31.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{73}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mover><mml:mo stretchy="false">∑</mml:mo><mml:mi>n</mml:mi></mml:mover><mml:mrow><mml:mi>x</mml:mi><mml:mo>&nbsp;</mml:mo></mml:mrow></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:31.000Z" lastModifiedTime="2026-09-28T01:50:33.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{76}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:msup><mml:mi>e</mml:mi><mml:mrow><mml:mi>x</mml:mi><mml:mo>+</mml:mo><mml:mn>1</mml:mn></mml:mrow></mml:msup></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:34.000Z" lastModifiedTime="2026-09-28T01:50:35.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{79}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mi>&#120572;</mml:mi><mml:mo>+</mml:mo><mml:mi>&#120573;</mml:mi></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:35.000Z" lastModifiedTime="2026-09-28T01:50:37.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{82}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfrac><mml:mrow><mml:mi>a</mml:mi><mml:mo>+</mml:mo><mml:mi>b</mml:mi></mml:mrow><mml:mrow><mml:mi>c</mml:mi><mml:mo>+</mml:mo><mml:mi>d</mml:mi></mml:mrow></mml:mfrac></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:38.000Z" lastModifiedTime="2026-09-28T01:50:39.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{85}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:msup><mml:mi>x</mml:mi><mml:mn>2</mml:mn></mml:msup><mml:mo>+</mml:mo><mml:msup><mml:mi>y</mml:mi><mml:mn>2</mml:mn></mml:msup><mml:mo>=</mml:mo><mml:msup><mml:mi>z</mml:mi><mml:mn>2</mml:mn></mml:msup></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:40.000Z" lastModifiedTime="2026-09-30T21:33:58.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{88}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mrow><mml:mrow><mml:munder><mml:mrow><mml:mi>lim</mml:mi></mml:mrow><mml:mrow><mml:mtable><mml:mtr><mml:mtd><mml:maligngroup/><mml:mi>n</mml:mi><mml:mo>→</mml:mo></mml:mtd></mml:mtr><mml:mtr><mml:mtd><mml:maligngroup/><mml:mo>∞</mml:mo></mml:mtd></mml:mtr></mml:mtable></mml:mrow></mml:munder></mml:mrow><mml:mo>⁡</mml:mo><mml:mrow><mml:msub><mml:mi>a</mml:mi><mml:mi>n</mml:mi></mml:msub></mml:mrow></mml:mrow></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:42.000Z" lastModifiedTime="2026-09-28T01:50:43.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{92}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfenced open="{" close="}"><mml:mrow><mml:mi>a</mml:mi><mml:mo>+</mml:mo><mml:mi>b</mml:mi></mml:mrow></mml:mfenced></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:43.000Z" lastModifiedTime="2026-09-30T21:16:44.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{95}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T21:19:08.000Z" lastModifiedTime="2026-09-30T21:21:20.000Z" objectID="{874D6C63-6972-4307-808D-05317B186F28}{12}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[<span
4style='font-family:Calibri' lang=en-US>One </span><!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML"><mml:mo>+</mml:mo><mml:mi>y</mml:mi><mml:mo>&nbsp;</mml:mo></mml:math><![endif]--><span
5style='font-family:Calibri' lang=en-US> four</span>]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T21:19:12.000Z" lastModifiedTime="2026-09-30T21:19:12.000Z" objectID="{874D6C63-6972-4307-808D-05317B186F28}{17}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Five]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T21:27:55.000Z" lastModifiedTime="2026-09-30T21:27:56.000Z" objectID="{874D6C63-6972-4307-808D-05317B186F28}{25}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Six]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T21:28:00.000Z" lastModifiedTime="2026-09-30T21:28:00.000Z" objectID="{874D6C63-6972-4307-808D-05317B186F28}{29}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Alpepsilon]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T21:34:44.000Z" lastModifiedTime="2026-09-30T21:37:41.000Z" objectID="{874D6C63-6972-4307-808D-05317B186F28}{36}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T21:43:32.000Z" lastModifiedTime="2026-09-30T21:43:34.000Z" objectID="{874D6C63-6972-4307-808D-05317B186F28}{39}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mi>x</mml:mi></mml:math><![endif]-->]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/equation-select/lab/before.xml created+3
...@@ -0,0 +1,3 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{74DAA177-309A-0F3A-0778-74DBF1E66004}{1}{B0}" name="Read about Rust the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-28T01:50:43.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-28T01:50:43.000Z" objectID="{D5D767FE-588D-0007-11D3-54BB0905E9FF}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="198.3904571533203" height="518.656005859375"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-28T01:49:44.000Z" objectID="{D5D767FE-588D-0007-11D3-54BB0905E9FF}{24}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:T><![CDATA[Read about Rust <a
3href="https://example.invalid/rust">the Rust site</a>]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:49:49.000Z" lastModifiedTime="2026-09-28T01:49:50.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{12}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:msub><mml:mi>a</mml:mi><mml:mn>1</mml:mn></mml:msub><mml:mo>+</mml:mo><mml:msub><mml:mi>b</mml:mi><mml:mn>2</mml:mn></mml:msub></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:49:51.000Z" lastModifiedTime="2026-09-28T01:49:52.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{18}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mpadded><mml:mrow><mml:mtext>Type&nbsp;equation&nbsp;here.</mml:mtext></mml:mrow></mml:mpadded></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:49:54.000Z" lastModifiedTime="2026-09-28T01:49:55.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{22}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:munderover><mml:mo stretchy="false">∑</mml:mo><mml:mrow><mml:mi>i</mml:mi><mml:mo>=</mml:mo><mml:mn>1</mml:mn></mml:mrow><mml:mi>n</mml:mi></mml:munderover><mml:mrow><mml:mi>i</mml:mi><mml:mo>&nbsp;</mml:mo></mml:mrow></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:49:56.000Z" lastModifiedTime="2026-09-28T01:49:57.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{25}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:msubsup><mml:mo stretchy="false">∫</mml:mo><mml:mn>0</mml:mn><mml:mn>1</mml:mn></mml:msubsup><mml:mrow><mml:mi>x</mml:mi><mml:mo>&nbsp;</mml:mo><mml:mi>d</mml:mi><mml:mi>x</mml:mi><mml:mo>&nbsp;</mml:mo></mml:mrow></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:49:58.000Z" lastModifiedTime="2026-09-28T01:49:59.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{28}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:msqrt><mml:mi>x</mml:mi></mml:msqrt><mml:mo>+</mml:mo><mml:mn>1</mml:mn><mml:mo>&nbsp;</mml:mo></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:00.000Z" lastModifiedTime="2026-09-28T01:50:01.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{31}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mroot><mml:mi>x</mml:mi><mml:mn>3</mml:mn></mml:mroot></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:02.000Z" lastModifiedTime="2026-09-28T01:50:03.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{34}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mroot><mml:mi>x</mml:mi><mml:mi>n</mml:mi></mml:mroot></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:04.000Z" lastModifiedTime="2026-09-28T01:50:05.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{37}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfrac><mml:mi>a</mml:mi><mml:mi>b</mml:mi></mml:mfrac></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:06.000Z" lastModifiedTime="2026-09-28T01:50:07.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{40}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:munderover><mml:mo stretchy="false">∏</mml:mo><mml:mrow><mml:mi>k</mml:mi><mml:mo>=</mml:mo><mml:mn>1</mml:mn></mml:mrow><mml:mi>n</mml:mi></mml:munderover><mml:mrow><mml:mi>k</mml:mi><mml:mo>&nbsp;</mml:mo></mml:mrow></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:08.000Z" lastModifiedTime="2026-09-28T01:50:09.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{43}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfenced open="[" close="]"><mml:mrow><mml:mi>a</mml:mi><mml:mo>+</mml:mo><mml:mi>b</mml:mi></mml:mrow></mml:mfenced></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:11.000Z" lastModifiedTime="2026-09-28T01:50:11.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{46}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mover accent="false"><mml:mi>x</mml:mi><mml:mo stretchy="true">¯</mml:mo></mml:mover></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:12.000Z" lastModifiedTime="2026-09-28T01:50:14.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{49}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mover accent="true"><mml:mi>x</mml:mi><mml:mo>^</mml:mo></mml:mover></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:15.000Z" lastModifiedTime="2026-09-28T01:50:16.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{52}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mtable><mml:mtr><mml:mtd><mml:mn>1</mml:mn></mml:mtd><mml:mtd><mml:mn>2</mml:mn></mml:mtd></mml:mtr><mml:mtr><mml:mtd><mml:mn>3</mml:mn></mml:mtd><mml:mtd><mml:mn>4</mml:mn></mml:mtd></mml:mtr></mml:mtable></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:17.000Z" lastModifiedTime="2026-09-28T01:50:18.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{55}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mtable><mml:mtr><mml:mtd><mml:maligngroup/><mml:mi>x</mml:mi><mml:malignmark/><mml:mo>=</mml:mo><mml:mn>1</mml:mn></mml:mtd></mml:mtr><mml:mtr><mml:mtd><mml:maligngroup/><mml:mi>y</mml:mi><mml:malignmark/><mml:mo>=</mml:mo><mml:mn>2</mml:mn></mml:mtd></mml:mtr></mml:mtable></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:18.000Z" lastModifiedTime="2026-09-28T01:50:20.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{58}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mover><mml:mi>x</mml:mi><mml:mn>2</mml:mn></mml:mover></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:21.000Z" lastModifiedTime="2026-09-28T01:50:22.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{61}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:munder><mml:mi>x</mml:mi><mml:mn>2</mml:mn></mml:munder></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:23.000Z" lastModifiedTime="2026-09-28T01:50:24.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{64}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mpadded><mml:mi>x</mml:mi></mml:mpadded></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:26.000Z" lastModifiedTime="2026-09-28T01:50:26.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{67}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:menclose notation="box"><mml:mi>x</mml:mi></mml:menclose></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:27.000Z" lastModifiedTime="2026-09-28T01:50:29.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{70}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfrac><mml:mrow><mml:mi>f</mml:mi><mml:mfenced><mml:mi>x</mml:mi></mml:mfenced></mml:mrow><mml:mrow><mml:msup><mml:mi>x</mml:mi><mml:mn>2</mml:mn></mml:msup><mml:mo>+</mml:mo><mml:mn>1</mml:mn></mml:mrow></mml:mfrac></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:30.000Z" lastModifiedTime="2026-09-28T01:50:31.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{73}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mover><mml:mo stretchy="false">∑</mml:mo><mml:mi>n</mml:mi></mml:mover><mml:mrow><mml:mi>x</mml:mi><mml:mo>&nbsp;</mml:mo></mml:mrow></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:31.000Z" lastModifiedTime="2026-09-28T01:50:33.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{76}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:msup><mml:mi>e</mml:mi><mml:mrow><mml:mi>x</mml:mi><mml:mo>+</mml:mo><mml:mn>1</mml:mn></mml:mrow></mml:msup></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:34.000Z" lastModifiedTime="2026-09-28T01:50:35.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{79}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mi>&#120572;</mml:mi><mml:mo>+</mml:mo><mml:mi>&#120573;</mml:mi></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:35.000Z" lastModifiedTime="2026-09-28T01:50:37.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{82}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfrac><mml:mrow><mml:mi>a</mml:mi><mml:mo>+</mml:mo><mml:mi>b</mml:mi></mml:mrow><mml:mrow><mml:mi>c</mml:mi><mml:mo>+</mml:mo><mml:mi>d</mml:mi></mml:mrow></mml:mfrac></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:38.000Z" lastModifiedTime="2026-09-28T01:50:39.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{85}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:msup><mml:mi>x</mml:mi><mml:mn>2</mml:mn></mml:msup><mml:mo>+</mml:mo><mml:msup><mml:mi>y</mml:mi><mml:mn>2</mml:mn></mml:msup><mml:mo>=</mml:mo><mml:msup><mml:mi>z</mml:mi><mml:mn>2</mml:mn></mml:msup></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:40.000Z" lastModifiedTime="2026-09-28T01:50:42.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{88}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mrow><mml:mrow><mml:munder><mml:mrow><mml:mi>lim</mml:mi></mml:mrow><mml:mrow><mml:mi>n</mml:mi><mml:mo>→</mml:mo><mml:mo>∞</mml:mo></mml:mrow></mml:munder></mml:mrow><mml:mo>⁡</mml:mo><mml:mrow><mml:msub><mml:mi>a</mml:mi><mml:mi>n</mml:mi></mml:msub></mml:mrow></mml:mrow></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:42.000Z" lastModifiedTime="2026-09-28T01:50:43.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{92}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfenced open="{" close="}"><mml:mrow><mml:mi>a</mml:mi><mml:mo>+</mml:mo><mml:mi>b</mml:mi></mml:mrow></mml:mfenced></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:43.000Z" lastModifiedTime="2026-09-28T01:50:43.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{95}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/equation-select/lab/limit-enter.xml created+5
...@@ -0,0 +1,5 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{74DAA177-309A-0F3A-0778-74DBF1E66004}{1}{B0}" name="Read about Rust the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-30T21:33:58.000Z" pageLevel="1" isCurrentlyViewed="true" selected="partial" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline selected="partial" author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-30T21:33:58.000Z" objectID="{D5D767FE-588D-0007-11D3-54BB0905E9FF}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="198.3904571533203" height="488.4266967773437"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren selected="partial"><one:OE creationTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-28T01:49:44.000Z" objectID="{D5D767FE-588D-0007-11D3-54BB0905E9FF}{24}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:T><![CDATA[Read about Rust <a
3href="https://example.invalid/rust">the Rust site</a>]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:49:49.000Z" lastModifiedTime="2026-09-30T21:18:26.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{12}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:msub><mml:mi>a</mml:mi><mml:mn>1</mml:mn></mml:msub><mml:msubsup><mml:mo stretchy="false">∫</mml:mo><mml:mn>0</mml:mn><mml:mn>1</mml:mn></mml:msubsup><mml:mrow><mml:mi>x</mml:mi><mml:mo>&nbsp;</mml:mo><mml:mi>d</mml:mi><mml:mi>x</mml:mi><mml:mo>&nbsp;</mml:mo></mml:mrow></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:02.000Z" lastModifiedTime="2026-09-28T01:50:03.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{34}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mroot><mml:mi>x</mml:mi><mml:mi>n</mml:mi></mml:mroot></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:04.000Z" lastModifiedTime="2026-09-28T01:50:05.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{37}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfrac><mml:mi>a</mml:mi><mml:mi>b</mml:mi></mml:mfrac></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:06.000Z" lastModifiedTime="2026-09-28T01:50:07.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{40}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:munderover><mml:mo stretchy="false">∏</mml:mo><mml:mrow><mml:mi>k</mml:mi><mml:mo>=</mml:mo><mml:mn>1</mml:mn></mml:mrow><mml:mi>n</mml:mi></mml:munderover><mml:mrow><mml:mi>k</mml:mi><mml:mo>&nbsp;</mml:mo></mml:mrow></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:08.000Z" lastModifiedTime="2026-09-28T01:50:09.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{43}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfenced open="[" close="]"><mml:mrow><mml:mi>a</mml:mi><mml:mo>+</mml:mo><mml:mi>b</mml:mi></mml:mrow></mml:mfenced></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:11.000Z" lastModifiedTime="2026-09-28T01:50:11.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{46}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mover accent="false"><mml:mi>x</mml:mi><mml:mo stretchy="true">¯</mml:mo></mml:mover></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:12.000Z" lastModifiedTime="2026-09-28T01:50:14.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{49}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mover accent="true"><mml:mi>x</mml:mi><mml:mo>^</mml:mo></mml:mover></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:15.000Z" lastModifiedTime="2026-09-28T01:50:16.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{52}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mtable><mml:mtr><mml:mtd><mml:mn>1</mml:mn></mml:mtd><mml:mtd><mml:mn>2</mml:mn></mml:mtd></mml:mtr><mml:mtr><mml:mtd><mml:mn>3</mml:mn></mml:mtd><mml:mtd><mml:mn>4</mml:mn></mml:mtd></mml:mtr></mml:mtable></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:17.000Z" lastModifiedTime="2026-09-28T01:50:18.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{55}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mtable><mml:mtr><mml:mtd><mml:maligngroup/><mml:mi>x</mml:mi><mml:malignmark/><mml:mo>=</mml:mo><mml:mn>1</mml:mn></mml:mtd></mml:mtr><mml:mtr><mml:mtd><mml:maligngroup/><mml:mi>y</mml:mi><mml:malignmark/><mml:mo>=</mml:mo><mml:mn>2</mml:mn></mml:mtd></mml:mtr></mml:mtable></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:18.000Z" lastModifiedTime="2026-09-28T01:50:20.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{58}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mover><mml:mi>x</mml:mi><mml:mn>2</mml:mn></mml:mover></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:21.000Z" lastModifiedTime="2026-09-28T01:50:22.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{61}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:munder><mml:mi>x</mml:mi><mml:mn>2</mml:mn></mml:munder></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:23.000Z" lastModifiedTime="2026-09-28T01:50:24.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{64}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mpadded><mml:mi>x</mml:mi></mml:mpadded></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:26.000Z" lastModifiedTime="2026-09-28T01:50:26.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{67}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:menclose notation="box"><mml:mi>x</mml:mi></mml:menclose></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:27.000Z" lastModifiedTime="2026-09-28T01:50:29.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{70}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfrac><mml:mrow><mml:mi>f</mml:mi><mml:mfenced><mml:mi>x</mml:mi></mml:mfenced></mml:mrow><mml:mrow><mml:msup><mml:mi>x</mml:mi><mml:mn>2</mml:mn></mml:msup><mml:mo>+</mml:mo><mml:mn>1</mml:mn></mml:mrow></mml:mfrac></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:30.000Z" lastModifiedTime="2026-09-28T01:50:31.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{73}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mover><mml:mo stretchy="false">∑</mml:mo><mml:mi>n</mml:mi></mml:mover><mml:mrow><mml:mi>x</mml:mi><mml:mo>&nbsp;</mml:mo></mml:mrow></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:31.000Z" lastModifiedTime="2026-09-28T01:50:33.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{76}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:msup><mml:mi>e</mml:mi><mml:mrow><mml:mi>x</mml:mi><mml:mo>+</mml:mo><mml:mn>1</mml:mn></mml:mrow></mml:msup></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:34.000Z" lastModifiedTime="2026-09-28T01:50:35.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{79}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mi>&#120572;</mml:mi><mml:mo>+</mml:mo><mml:mi>&#120573;</mml:mi></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:35.000Z" lastModifiedTime="2026-09-28T01:50:37.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{82}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfrac><mml:mrow><mml:mi>a</mml:mi><mml:mo>+</mml:mo><mml:mi>b</mml:mi></mml:mrow><mml:mrow><mml:mi>c</mml:mi><mml:mo>+</mml:mo><mml:mi>d</mml:mi></mml:mrow></mml:mfrac></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:38.000Z" lastModifiedTime="2026-09-28T01:50:39.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{85}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:msup><mml:mi>x</mml:mi><mml:mn>2</mml:mn></mml:msup><mml:mo>+</mml:mo><mml:msup><mml:mi>y</mml:mi><mml:mn>2</mml:mn></mml:msup><mml:mo>=</mml:mo><mml:msup><mml:mi>z</mml:mi><mml:mn>2</mml:mn></mml:msup></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:40.000Z" lastModifiedTime="2026-09-30T21:33:58.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{88}{B0}" alignment="left" quickStyleIndex="0" selected="partial" style="font-family:'Cambria Math';font-size:11.0pt"><one:T selected="all"><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mrow><mml:mrow><mml:munder><mml:mrow><mml:mi>lim</mml:mi></mml:mrow><mml:mrow><mml:mtable><mml:mtr><mml:mtd><mml:maligngroup/><mml:mi>n</mml:mi><mml:mo>→</mml:mo></mml:mtd></mml:mtr><mml:mtr><mml:mtd><mml:maligngroup/><mml:mo>∞</mml:mo></mml:mtd></mml:mtr></mml:mtable></mml:mrow></mml:munder></mml:mrow><mml:mo>⁡</mml:mo><mml:mrow><mml:msub><mml:mi>a</mml:mi><mml:mi>n</mml:mi></mml:msub></mml:mrow></mml:mrow></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:42.000Z" lastModifiedTime="2026-09-28T01:50:43.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{92}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfenced open="{" close="}"><mml:mrow><mml:mi>a</mml:mi><mml:mo>+</mml:mo><mml:mi>b</mml:mi></mml:mrow></mml:mfenced></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:43.000Z" lastModifiedTime="2026-09-30T21:16:44.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{95}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T21:19:08.000Z" lastModifiedTime="2026-09-30T21:21:20.000Z" objectID="{874D6C63-6972-4307-808D-05317B186F28}{12}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[<span
4style='font-family:Calibri' lang=en-US>One </span><!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML"><mml:mo>+</mml:mo><mml:mi>y</mml:mi><mml:mo>&nbsp;</mml:mo></mml:math><![endif]--><span
5style='font-family:Calibri' lang=en-US> four</span>]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T21:19:12.000Z" lastModifiedTime="2026-09-30T21:19:12.000Z" objectID="{874D6C63-6972-4307-808D-05317B186F28}{17}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Five]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T21:27:55.000Z" lastModifiedTime="2026-09-30T21:27:56.000Z" objectID="{874D6C63-6972-4307-808D-05317B186F28}{25}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Six]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T21:28:00.000Z" lastModifiedTime="2026-09-30T21:28:00.000Z" objectID="{874D6C63-6972-4307-808D-05317B186F28}{29}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Alpepsilon]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/equation-select/lab/placeholder-enter.xml created+5
...@@ -0,0 +1,5 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{74DAA177-309A-0F3A-0778-74DBF1E66004}{1}{B0}" name="Read about Rust the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-30T21:37:41.000Z" pageLevel="1" isCurrentlyViewed="true" selected="partial" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline selected="partial" author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-30T21:37:41.000Z" objectID="{D5D767FE-588D-0007-11D3-54BB0905E9FF}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="198.3904571533203" height="515.2821655273437"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren selected="partial"><one:OE creationTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-28T01:49:44.000Z" objectID="{D5D767FE-588D-0007-11D3-54BB0905E9FF}{24}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:T><![CDATA[Read about Rust <a
3href="https://example.invalid/rust">the Rust site</a>]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:49:49.000Z" lastModifiedTime="2026-09-30T21:18:26.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{12}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:msub><mml:mi>a</mml:mi><mml:mn>1</mml:mn></mml:msub><mml:msubsup><mml:mo stretchy="false">∫</mml:mo><mml:mn>0</mml:mn><mml:mn>1</mml:mn></mml:msubsup><mml:mrow><mml:mi>x</mml:mi><mml:mo>&nbsp;</mml:mo><mml:mi>d</mml:mi><mml:mi>x</mml:mi><mml:mo>&nbsp;</mml:mo></mml:mrow></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:02.000Z" lastModifiedTime="2026-09-28T01:50:03.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{34}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mroot><mml:mi>x</mml:mi><mml:mi>n</mml:mi></mml:mroot></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:04.000Z" lastModifiedTime="2026-09-28T01:50:05.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{37}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfrac><mml:mi>a</mml:mi><mml:mi>b</mml:mi></mml:mfrac></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:06.000Z" lastModifiedTime="2026-09-28T01:50:07.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{40}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:munderover><mml:mo stretchy="false">∏</mml:mo><mml:mrow><mml:mi>k</mml:mi><mml:mo>=</mml:mo><mml:mn>1</mml:mn></mml:mrow><mml:mi>n</mml:mi></mml:munderover><mml:mrow><mml:mi>k</mml:mi><mml:mo>&nbsp;</mml:mo></mml:mrow></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:08.000Z" lastModifiedTime="2026-09-28T01:50:09.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{43}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfenced open="[" close="]"><mml:mrow><mml:mi>a</mml:mi><mml:mo>+</mml:mo><mml:mi>b</mml:mi></mml:mrow></mml:mfenced></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:11.000Z" lastModifiedTime="2026-09-28T01:50:11.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{46}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mover accent="false"><mml:mi>x</mml:mi><mml:mo stretchy="true">¯</mml:mo></mml:mover></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:12.000Z" lastModifiedTime="2026-09-28T01:50:14.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{49}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mover accent="true"><mml:mi>x</mml:mi><mml:mo>^</mml:mo></mml:mover></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:15.000Z" lastModifiedTime="2026-09-28T01:50:16.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{52}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mtable><mml:mtr><mml:mtd><mml:mn>1</mml:mn></mml:mtd><mml:mtd><mml:mn>2</mml:mn></mml:mtd></mml:mtr><mml:mtr><mml:mtd><mml:mn>3</mml:mn></mml:mtd><mml:mtd><mml:mn>4</mml:mn></mml:mtd></mml:mtr></mml:mtable></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:17.000Z" lastModifiedTime="2026-09-28T01:50:18.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{55}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mtable><mml:mtr><mml:mtd><mml:maligngroup/><mml:mi>x</mml:mi><mml:malignmark/><mml:mo>=</mml:mo><mml:mn>1</mml:mn></mml:mtd></mml:mtr><mml:mtr><mml:mtd><mml:maligngroup/><mml:mi>y</mml:mi><mml:malignmark/><mml:mo>=</mml:mo><mml:mn>2</mml:mn></mml:mtd></mml:mtr></mml:mtable></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:18.000Z" lastModifiedTime="2026-09-28T01:50:20.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{58}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mover><mml:mi>x</mml:mi><mml:mn>2</mml:mn></mml:mover></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:21.000Z" lastModifiedTime="2026-09-28T01:50:22.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{61}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:munder><mml:mi>x</mml:mi><mml:mn>2</mml:mn></mml:munder></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:23.000Z" lastModifiedTime="2026-09-28T01:50:24.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{64}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mpadded><mml:mi>x</mml:mi></mml:mpadded></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:26.000Z" lastModifiedTime="2026-09-28T01:50:26.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{67}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:menclose notation="box"><mml:mi>x</mml:mi></mml:menclose></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:27.000Z" lastModifiedTime="2026-09-28T01:50:29.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{70}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfrac><mml:mrow><mml:mi>f</mml:mi><mml:mfenced><mml:mi>x</mml:mi></mml:mfenced></mml:mrow><mml:mrow><mml:msup><mml:mi>x</mml:mi><mml:mn>2</mml:mn></mml:msup><mml:mo>+</mml:mo><mml:mn>1</mml:mn></mml:mrow></mml:mfrac></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:30.000Z" lastModifiedTime="2026-09-28T01:50:31.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{73}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mover><mml:mo stretchy="false">∑</mml:mo><mml:mi>n</mml:mi></mml:mover><mml:mrow><mml:mi>x</mml:mi><mml:mo>&nbsp;</mml:mo></mml:mrow></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:31.000Z" lastModifiedTime="2026-09-28T01:50:33.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{76}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:msup><mml:mi>e</mml:mi><mml:mrow><mml:mi>x</mml:mi><mml:mo>+</mml:mo><mml:mn>1</mml:mn></mml:mrow></mml:msup></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:34.000Z" lastModifiedTime="2026-09-28T01:50:35.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{79}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mi>&#120572;</mml:mi><mml:mo>+</mml:mo><mml:mi>&#120573;</mml:mi></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:35.000Z" lastModifiedTime="2026-09-28T01:50:37.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{82}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfrac><mml:mrow><mml:mi>a</mml:mi><mml:mo>+</mml:mo><mml:mi>b</mml:mi></mml:mrow><mml:mrow><mml:mi>c</mml:mi><mml:mo>+</mml:mo><mml:mi>d</mml:mi></mml:mrow></mml:mfrac></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:38.000Z" lastModifiedTime="2026-09-28T01:50:39.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{85}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:msup><mml:mi>x</mml:mi><mml:mn>2</mml:mn></mml:msup><mml:mo>+</mml:mo><mml:msup><mml:mi>y</mml:mi><mml:mn>2</mml:mn></mml:msup><mml:mo>=</mml:mo><mml:msup><mml:mi>z</mml:mi><mml:mn>2</mml:mn></mml:msup></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:40.000Z" lastModifiedTime="2026-09-30T21:33:58.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{88}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mrow><mml:mrow><mml:munder><mml:mrow><mml:mi>lim</mml:mi></mml:mrow><mml:mrow><mml:mtable><mml:mtr><mml:mtd><mml:maligngroup/><mml:mi>n</mml:mi><mml:mo>→</mml:mo></mml:mtd></mml:mtr><mml:mtr><mml:mtd><mml:maligngroup/><mml:mo>∞</mml:mo></mml:mtd></mml:mtr></mml:mtable></mml:mrow></mml:munder></mml:mrow><mml:mo>⁡</mml:mo><mml:mrow><mml:msub><mml:mi>a</mml:mi><mml:mi>n</mml:mi></mml:msub></mml:mrow></mml:mrow></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:42.000Z" lastModifiedTime="2026-09-28T01:50:43.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{92}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfenced open="{" close="}"><mml:mrow><mml:mi>a</mml:mi><mml:mo>+</mml:mo><mml:mi>b</mml:mi></mml:mrow></mml:mfenced></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:43.000Z" lastModifiedTime="2026-09-30T21:16:44.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{95}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T21:19:08.000Z" lastModifiedTime="2026-09-30T21:21:20.000Z" objectID="{874D6C63-6972-4307-808D-05317B186F28}{12}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[<span
4style='font-family:Calibri' lang=en-US>One </span><!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML"><mml:mo>+</mml:mo><mml:mi>y</mml:mi><mml:mo>&nbsp;</mml:mo></mml:math><![endif]--><span
5style='font-family:Calibri' lang=en-US> four</span>]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T21:19:12.000Z" lastModifiedTime="2026-09-30T21:19:12.000Z" objectID="{874D6C63-6972-4307-808D-05317B186F28}{17}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Five]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T21:27:55.000Z" lastModifiedTime="2026-09-30T21:27:56.000Z" objectID="{874D6C63-6972-4307-808D-05317B186F28}{25}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Six]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T21:28:00.000Z" lastModifiedTime="2026-09-30T21:28:00.000Z" objectID="{874D6C63-6972-4307-808D-05317B186F28}{29}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Alpepsilon]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T21:34:44.000Z" lastModifiedTime="2026-09-30T21:37:41.000Z" objectID="{874D6C63-6972-4307-808D-05317B186F28}{36}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T21:37:41.000Z" lastModifiedTime="2026-09-30T21:37:41.000Z" objectID="{874D6C63-6972-4307-808D-05317B186F28}{39}{B0}" alignment="left" quickStyleIndex="0" selected="partial"><one:T selected="all"><![CDATA[]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/equation-select/lab/roots-deleted.xml created+3
...@@ -0,0 +1,3 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{74DAA177-309A-0F3A-0778-74DBF1E66004}{1}{B0}" name="Read about Rust the Rust site" dateTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-30T21:17:41.000Z" pageLevel="1" isCurrentlyViewed="true" selected="partial" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline selected="partial" author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-30T21:17:41.000Z" objectID="{D5D767FE-588D-0007-11D3-54BB0905E9FF}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="198.3904571533203" height="491.9617004394531"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren selected="partial"><one:OE creationTime="2026-09-12T09:49:16.000Z" lastModifiedTime="2026-09-28T01:49:44.000Z" objectID="{D5D767FE-588D-0007-11D3-54BB0905E9FF}{24}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:T><![CDATA[Read about Rust <a
3href="https://example.invalid/rust">the Rust site</a>]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:49:49.000Z" lastModifiedTime="2026-09-28T01:49:50.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{12}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:msub><mml:mi>a</mml:mi><mml:mn>1</mml:mn></mml:msub><mml:mo>+</mml:mo><mml:msub><mml:mi>b</mml:mi><mml:mn>2</mml:mn></mml:msub></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:49:51.000Z" lastModifiedTime="2026-09-28T01:49:52.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{18}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mpadded><mml:mrow><mml:mtext>Type&nbsp;equation&nbsp;here.</mml:mtext></mml:mrow></mml:mpadded></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:49:54.000Z" lastModifiedTime="2026-09-28T01:49:55.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{22}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:munderover><mml:mo stretchy="false">∑</mml:mo><mml:mrow><mml:mi>i</mml:mi><mml:mo>=</mml:mo><mml:mn>1</mml:mn></mml:mrow><mml:mi>n</mml:mi></mml:munderover><mml:mrow><mml:mi>i</mml:mi><mml:mo>&nbsp;</mml:mo></mml:mrow></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:49:56.000Z" lastModifiedTime="2026-09-28T01:49:57.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{25}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:msubsup><mml:mo stretchy="false">∫</mml:mo><mml:mn>0</mml:mn><mml:mn>1</mml:mn></mml:msubsup><mml:mrow><mml:mi>x</mml:mi><mml:mo>&nbsp;</mml:mo><mml:mi>d</mml:mi><mml:mi>x</mml:mi><mml:mo>&nbsp;</mml:mo></mml:mrow></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:02.000Z" lastModifiedTime="2026-09-28T01:50:03.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{34}{B0}" alignment="left" quickStyleIndex="0" selected="partial" style="font-family:'Cambria Math';font-size:11.0pt"><one:T selected="all"><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mroot><mml:mi>x</mml:mi><mml:mi>n</mml:mi></mml:mroot></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:04.000Z" lastModifiedTime="2026-09-28T01:50:05.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{37}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfrac><mml:mi>a</mml:mi><mml:mi>b</mml:mi></mml:mfrac></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:06.000Z" lastModifiedTime="2026-09-28T01:50:07.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{40}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:munderover><mml:mo stretchy="false">∏</mml:mo><mml:mrow><mml:mi>k</mml:mi><mml:mo>=</mml:mo><mml:mn>1</mml:mn></mml:mrow><mml:mi>n</mml:mi></mml:munderover><mml:mrow><mml:mi>k</mml:mi><mml:mo>&nbsp;</mml:mo></mml:mrow></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:08.000Z" lastModifiedTime="2026-09-28T01:50:09.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{43}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfenced open="[" close="]"><mml:mrow><mml:mi>a</mml:mi><mml:mo>+</mml:mo><mml:mi>b</mml:mi></mml:mrow></mml:mfenced></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:11.000Z" lastModifiedTime="2026-09-28T01:50:11.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{46}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mover accent="false"><mml:mi>x</mml:mi><mml:mo stretchy="true">¯</mml:mo></mml:mover></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:12.000Z" lastModifiedTime="2026-09-28T01:50:14.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{49}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mover accent="true"><mml:mi>x</mml:mi><mml:mo>^</mml:mo></mml:mover></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:15.000Z" lastModifiedTime="2026-09-28T01:50:16.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{52}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mtable><mml:mtr><mml:mtd><mml:mn>1</mml:mn></mml:mtd><mml:mtd><mml:mn>2</mml:mn></mml:mtd></mml:mtr><mml:mtr><mml:mtd><mml:mn>3</mml:mn></mml:mtd><mml:mtd><mml:mn>4</mml:mn></mml:mtd></mml:mtr></mml:mtable></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:17.000Z" lastModifiedTime="2026-09-28T01:50:18.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{55}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mtable><mml:mtr><mml:mtd><mml:maligngroup/><mml:mi>x</mml:mi><mml:malignmark/><mml:mo>=</mml:mo><mml:mn>1</mml:mn></mml:mtd></mml:mtr><mml:mtr><mml:mtd><mml:maligngroup/><mml:mi>y</mml:mi><mml:malignmark/><mml:mo>=</mml:mo><mml:mn>2</mml:mn></mml:mtd></mml:mtr></mml:mtable></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:18.000Z" lastModifiedTime="2026-09-28T01:50:20.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{58}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mover><mml:mi>x</mml:mi><mml:mn>2</mml:mn></mml:mover></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:21.000Z" lastModifiedTime="2026-09-28T01:50:22.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{61}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:munder><mml:mi>x</mml:mi><mml:mn>2</mml:mn></mml:munder></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:23.000Z" lastModifiedTime="2026-09-28T01:50:24.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{64}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mpadded><mml:mi>x</mml:mi></mml:mpadded></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:26.000Z" lastModifiedTime="2026-09-28T01:50:26.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{67}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:menclose notation="box"><mml:mi>x</mml:mi></mml:menclose></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:27.000Z" lastModifiedTime="2026-09-28T01:50:29.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{70}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfrac><mml:mrow><mml:mi>f</mml:mi><mml:mfenced><mml:mi>x</mml:mi></mml:mfenced></mml:mrow><mml:mrow><mml:msup><mml:mi>x</mml:mi><mml:mn>2</mml:mn></mml:msup><mml:mo>+</mml:mo><mml:mn>1</mml:mn></mml:mrow></mml:mfrac></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:30.000Z" lastModifiedTime="2026-09-28T01:50:31.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{73}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mover><mml:mo stretchy="false">∑</mml:mo><mml:mi>n</mml:mi></mml:mover><mml:mrow><mml:mi>x</mml:mi><mml:mo>&nbsp;</mml:mo></mml:mrow></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:31.000Z" lastModifiedTime="2026-09-28T01:50:33.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{76}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:msup><mml:mi>e</mml:mi><mml:mrow><mml:mi>x</mml:mi><mml:mo>+</mml:mo><mml:mn>1</mml:mn></mml:mrow></mml:msup></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:34.000Z" lastModifiedTime="2026-09-28T01:50:35.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{79}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mi>&#120572;</mml:mi><mml:mo>+</mml:mo><mml:mi>&#120573;</mml:mi></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:35.000Z" lastModifiedTime="2026-09-28T01:50:37.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{82}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfrac><mml:mrow><mml:mi>a</mml:mi><mml:mo>+</mml:mo><mml:mi>b</mml:mi></mml:mrow><mml:mrow><mml:mi>c</mml:mi><mml:mo>+</mml:mo><mml:mi>d</mml:mi></mml:mrow></mml:mfrac></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:38.000Z" lastModifiedTime="2026-09-28T01:50:39.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{85}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:msup><mml:mi>x</mml:mi><mml:mn>2</mml:mn></mml:msup><mml:mo>+</mml:mo><mml:msup><mml:mi>y</mml:mi><mml:mn>2</mml:mn></mml:msup><mml:mo>=</mml:mo><mml:msup><mml:mi>z</mml:mi><mml:mn>2</mml:mn></mml:msup></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:40.000Z" lastModifiedTime="2026-09-28T01:50:42.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{88}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mrow><mml:mrow><mml:munder><mml:mrow><mml:mi>lim</mml:mi></mml:mrow><mml:mrow><mml:mi>n</mml:mi><mml:mo>→</mml:mo><mml:mo>∞</mml:mo></mml:mrow></mml:munder></mml:mrow><mml:mo>⁡</mml:mo><mml:mrow><mml:msub><mml:mi>a</mml:mi><mml:mi>n</mml:mi></mml:msub></mml:mrow></mml:mrow></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:42.000Z" lastModifiedTime="2026-09-28T01:50:43.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{92}{B0}" alignment="left" quickStyleIndex="0" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfenced open="{" close="}"><mml:mrow><mml:mi>a</mml:mi><mml:mo>+</mml:mo><mml:mi>b</mml:mi></mml:mrow></mml:mfenced></mml:math><![endif]-->]]></one:T></one:OE><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-28T01:50:43.000Z" lastModifiedTime="2026-09-30T21:16:44.000Z" objectID="{E09287FB-1B45-46EB-AA32-4E04847887B9}{95}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/print/README.md created+33
...@@ -0,0 +1,33 @@
1# Print
2
3`native/Print.one` is a section the COM API made in OneNote 2010: "Printing test", a
4ruled page (horizontal lines every 0.2 points, as OneNote reads the XML's spacing, and a
5margin line) with a 70-paragraph outline running past the first sheet, a picture, an
6outline near the first sheet's foot and one reaching 867 points across; "Second page";
7and "Wide 1200" and "Wide 560", titled pages whose one word sits that far across.
8`native/section.pdf` is OneNote's File, Save As, PDF of the section, the same sheets File,
9Print prints. `native/rules-section.pdf` is its PDF of `page-background/candidate`.
10
11What they show, on a 612.36 by 790.92 point Letter sheet:
12
13- The page prints between 36-point top and bottom margins, the margin origin at (72, 36).
14 Content reaching past the paper's right edge shrinks the whole page to fit it with 9
15 points to spare: "Printing test" prints at 0.672, "Wide 560" at 0.978, "Wide 1200" at
16 0.484.
17- A sheet ends before the first line it would cut: the first holds paragraphs 1 to 37 and
18 the second starts at paragraph 38. Content below that on the first sheet waits for the
19 next.
20- Rule lines print across the paper's whole width, down to the bottom margin of every
21 sheet. The page colour does not print. Template art does, and a page longer than a sheet
22 only through its art ("VeryLargeGrid") takes a second sheet.
23- The footer, "Print Page 1", is the section's name and the sheet's number counted across
24 the whole printout, in 10-point Times New Roman centred near the paper's bottom edge; it
25 shrinks with the page towards the bottom left. No header prints; the title, date and
26 time are the page's own.
27
28Print Preview and Settings offers Print range (Current Page, Page Group, Current Section),
29Paper size, Scale content to paper width (on), Orientation, Footer (section and page
30number, page number, section, none) and Start page numbering at 1. Save As, PDF offers
31Selected Pages, Current Section and Current Notebook.
32
33`crates/canvas/src/print.rs` checks Snowbound's sheets against these without a VM.
corpus/print/native/Print.one created
Binary files /dev/null and b/corpus/print/native/Print.one differ
corpus/print/native/rules-section.pdf created
Binary files /dev/null and b/corpus/print/native/rules-section.pdf differ
corpus/print/native/section.pdf created
Binary files /dev/null and b/corpus/print/native/section.pdf differ
crates/canvas/Cargo.toml+7
...@@ -8,6 +8,8 @@ publish = false...@@ -8,6 +8,8 @@ publish = false
8gpu = ["draw/render", "dep:color_quant", "dep:image", "dep:png", "dep:resvg", "dep:sha2"]8gpu = ["draw/render", "dep:color_quant", "dep:image", "dep:png", "dep:resvg", "dep:sha2"]
9# How a page responds to input and presents itself to assistive technology.9# How a page responds to input and presents itself to assistive technology.
10interaction = ["gpu", "dep:accesskit"]10interaction = ["gpu", "dep:accesskit"]
11# Pages printed as PDF, as OneNote prints them.
12pdf = ["interaction", "draw/pdf"]
1113
12[dependencies]14[dependencies]
13draw = { path = "../draw", default-features = false }15draw = { path = "../draw", default-features = false }
...@@ -35,6 +37,7 @@ path = "src/bin/page_probe.rs"...@@ -35,6 +37,7 @@ path = "src/bin/page_probe.rs"
3537
36[dev-dependencies]38[dev-dependencies]
37draw = { path = "../draw", features = ["wgpu"] }39draw = { path = "../draw", features = ["wgpu"] }
40miniz_oxide = "0.8"
38accesskit_consumer = "0.39.0"41accesskit_consumer = "0.39.0"
39pollster = "0.4"42pollster = "0.4"
40wgpu.workspace = true43wgpu.workspace = true
...@@ -42,3 +45,7 @@ wgpu.workspace = true...@@ -42,3 +45,7 @@ wgpu.workspace = true
42[[example]]45[[example]]
43name = "render_page"46name = "render_page"
44required-features = ["gpu"]47required-features = ["gpu"]
48
49[[example]]
50name = "print_pdf"
51required-features = ["pdf"]
crates/canvas/examples/print_pdf.rs created+40
...@@ -0,0 +1,40 @@
1//! Writes a section's pages, or one titled page, as the PDF Print and Export as PDF make.
2//! `print_pdf SECTION.one NEW.pdf [TITLE] [--a4]`
3
4use canvas::{layout::TextEngine, print};
5use onestore::{RevisionIndex, Store, document::Document, page::Page};
6use std::{env, fs, path::Path};
7
8fn main() -> Result<(), Box<dyn std::error::Error>> {
9 let args: Vec<String> = env::args().skip(1).collect();
10 let [section, destination, rest @ ..] = args.as_slice() else {
11 return Err("Expected a section, a new PDF path and optionally a page title".into());
12 };
13 let a4 = rest.iter().any(|arg| arg == "--a4");
14 let title = rest.iter().find(|arg| *arg != "--a4");
15 let bytes = fs::read(section)?;
16 let store = Store::parse(&bytes)?;
17 let index = RevisionIndex::parse(&store)?;
18 let document = Document::parse(&index)?;
19 let mut spaces: Vec<_> = document
20 .pages()?
21 .into_iter()
22 .map(|(space, _)| space)
23 .collect();
24 spaces.dedup();
25 let pages = spaces
26 .into_iter()
27 .map(|space| Page::from_space(&document, space))
28 .collect::<Result<Vec<_>, _>>()?
29 .into_iter()
30 .filter(|page| title.is_none_or(|title| page.title == *title))
31 .collect();
32 let name = Path::new(section)
33 .file_stem()
34 .and_then(|stem| stem.to_str())
35 .unwrap_or_default();
36 let paper = if a4 { print::A4 } else { print::LETTER };
37 let pdf = print::pdf(pages, &mut TextEngine::default(), paper, name)?;
38 fs::write(destination, pdf)?;
39 Ok(())
40}
crates/canvas/examples/scratch_eqref.rs created+667
...@@ -0,0 +1,667 @@
1//! Every place the canvas shows a page's content as a placeholder, draws it read-only, or
2//! refuses an ordinary edit as content it cannot edit, across the active pages of every
3//! section under the given directories, grouped by kind as Markdown. Identical sections
4//! count once.
5//!
6//! `cargo run --release -p canvas --features gpu --example unsupported_inventory -- corpus`
7
8use canvas::{
9 document::TextPosition,
10 editor::{CanvasEditor, EditorError, TextOutline},
11 layout::{LayoutError, TextEngine},
12 outline::Arrange,
13};
14use onestore::{
15 Arena, ExGuid, Section,
16 page::{
17 Image, Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent,
18 text::{Affinity, EditError},
19 },
20};
21use std::{
22 collections::{BTreeMap, BTreeSet, hash_map::DefaultHasher},
23 hash::{Hash, Hasher},
24 path::{Path, PathBuf},
25};
26
27#[derive(Default)]
28struct Inventory {
29 /// Kind to the places it occurs, as `section — page`.
30 kinds: BTreeMap<(Stage, String), Vec<String>>,
31 sections: usize,
32 pages: usize,
33}
34
35#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
36enum Stage {
37 /// Drawn as an "Unsupported content" (or "Image unavailable") box.
38 Placeholder,
39 /// Drawn as stored, but the editor cannot hold it.
40 ReadOnly,
41 /// An ordinary edit the editor refuses as content it cannot edit.
42 Refused,
43 /// The page or section does not open at all.
44 Failure,
45}
46
47impl Inventory {
48 fn add(&mut self, stage: Stage, kind: impl Into<String>, place: &str) {
49 self.kinds
50 .entry((stage, kind.into()))
51 .or_default()
52 .push(place.to_owned());
53 }
54}
55
56/// MS-ONE's names for the object kinds it documents, and the observed undocumented ones.
57fn jcid_name(jcid: u32) -> String {
58 let name = match jcid {
59 0x60007 => "section",
60 0x60008 => "page series",
61 0x6000b => "page",
62 0x6000c => "outline",
63 0x6000d => "outline element",
64 0x6000e => "rich text",
65 0x60011 => "picture",
66 0x60012 => "number list",
67 0x60014 => "ink container",
68 0x60019 => "outline group",
69 0x60022 => "table",
70 0x60023 => "table row",
71 0x60024 => "table cell",
72 0x6002c => "title",
73 0x60035 => "embedded file",
74 0x60037 => "page manifest",
75 0x6003c => "version history",
76 0x6003d => "version proxy",
77 _ => "undocumented",
78 };
79 format!("jcid 0x{jcid:05x} ({name})")
80}
81
82fn unsupported_error(error: &EditorError) -> bool {
83 matches!(
84 error,
85 EditorError::Layout(LayoutError::UnsupportedContent)
86 | EditorError::Edit(EditError::UnsupportedContent)
87 )
88}
89
90/// What a text paragraph holds that bears on editing it.
91fn traits(text: &Paragraph) -> Vec<&'static str> {
92 let spans = text.spans();
93 let mut traits = Vec::new();
94 if text.text().contains('\u{fffc}')
95 || spans.iter().any(|s| s.format.embedded_object == Some(true))
96 {
97 traits.push("embedded object in text");
98 }
99 if spans.iter().any(|s| s.format.math == Some(true)) {
100 traits.push("equation");
101 }
102 if spans.iter().any(|s| s.format.hyperlink == Some(true)) {
103 traits.push("link");
104 }
105 if spans.iter().any(|s| s.format.hidden == Some(true)) {
106 traits.push("hidden text");
107 }
108 if text.text().is_empty() {
109 traits.push("empty");
110 }
111 traits
112}
113
114/// Why `node`, alone in an outline, does not lay out as content the canvas understands.
115fn culprit(
116 engine: &mut TextEngine,
117 outline: &Outline,
118 node: &PageParagraph,
119 page: &Page,
120) -> Option<String> {
121 let alone = Outline {
122 paragraphs: vec![PageParagraph {
123 parent: None,
124 level: 1,
125 ..node.clone()
126 }],
127 unsupported: Vec::new(),
128 ..outline.clone()
129 };
130 match alone.layout(engine, &page.definitions) {
131 Err(LayoutError::UnsupportedContent) => {}
132 _ => return None,
133 }
134 Some(match &node.content {
135 ParagraphContent::Unsupported(u) => format!("holds {}", jcid_name(u.jcid)),
136 ParagraphContent::Table(table) if !table.tags.is_empty() => "table with note tags".into(),
137 ParagraphContent::Table(table) => {
138 let cells = || table.rows.iter().flat_map(|row| &row.cells);
139 if let Some(cell) = cells().find(|cell| !cell.unsupported.is_empty()) {
140 format!(
141 "table cell holding {}",
142 cell.unsupported
143 .iter()
144 .map(|u| jcid_name(u.jcid))
145 .collect::<Vec<_>>()
146 .join(", ")
147 )
148 } else {
149 let inner = cells()
150 .flat_map(|cell| &cell.paragraphs)
151 .find_map(|inner| culprit(engine, outline, inner, page));
152 format!(
153 "table cell: {}",
154 inner.unwrap_or_else(|| "nested content".into())
155 )
156 }
157 }
158 ParagraphContent::Image(_) => "picture without a size".into(),
159 ParagraphContent::Ink(_) => "handwriting without strokes".into(),
160 ParagraphContent::Attachment(_) => "file".into(),
161 ParagraphContent::Text(text) => {
162 let lists: Vec<String> = node
163 .lists
164 .iter()
165 .filter_map(|id| page.definitions.get(id))
166 .map(|definition| format!("{:?}", definition.kind))
167 .collect();
168 if !lists.is_empty() {
169 format!("list marker {}", lists.join(", "))
170 } else if text
171 .text
172 .spans()
173 .iter()
174 .any(|s| s.format.math == Some(true))
175 {
176 "equation the math layout does not parse".into()
177 } else {
178 format!("text ({})", traits(&text.text).join(", "))
179 }
180 }
181 })
182}
183
184/// Why an outline the page shows is not editable.
185fn diagnose(engine: &mut TextEngine, outline: &Outline, page: &Page) -> Vec<String> {
186 let mut reasons: Vec<String> = outline
187 .unsupported
188 .iter()
189 .map(|u| format!("outline child {}", jcid_name(u.jcid)))
190 .collect();
191 reasons.extend(
192 outline
193 .paragraphs
194 .iter()
195 .filter_map(|node| culprit(engine, outline, node, page)),
196 );
197 if reasons.is_empty() {
198 let text = outline.paragraphs.iter().any(|node| {
199 node.text().is_some()
200 || matches!(&node.content, ParagraphContent::Table(t)
201 if t.rows.iter().flat_map(|r| &r.cells).any(|c| !c.paragraphs.is_empty()))
202 });
203 reasons.push(if text {
204 match TextOutline::from_outline(engine, outline, &page.definitions) {
205 Err(error) => format!("editor: {error}"),
206 Ok(_) => "outline on its own is editable".into(),
207 }
208 } else {
209 let kinds: BTreeSet<&str> = outline
210 .paragraphs
211 .iter()
212 .map(|node| match node.content {
213 ParagraphContent::Image(_) => "pictures",
214 ParagraphContent::Attachment(_) => "files",
215 ParagraphContent::Ink(_) => "handwriting",
216 _ => "other",
217 })
218 .collect();
219 format!(
220 "no paragraph of text (only {})",
221 kinds.into_iter().collect::<Vec<_>>().join(", ")
222 )
223 });
224 }
225 reasons
226}
227
228/// A picture that draws as "Image unavailable": no data, or (with `--features gpu`) data
229/// the renderer does not decode, named by its leading bytes.
230fn picture(image: &Image, at: &str, inventory: &mut Inventory, place: &str) {
231 let Some(bytes) = &image.bytes else {
232 return inventory.add(
233 Stage::Placeholder,
234 format!("{at} picture without data"),
235 place,
236 );
237 };
238 #[cfg(feature = "gpu")]
239 if [Some(bytes), image.display.as_ref()]
240 .into_iter()
241 .flatten()
242 .all(|bytes| draw::RasterImage::measure(bytes).is_err())
243 {
244 let format = match &bytes[..bytes.len().min(4)] {
245 [0x50, 0x4b, 3, 4] => "a zip package (XPS printout)",
246 [1, 0, 0, 0] => "EMF",
247 [0xd7, 0xcd, 0xc6, 0x9a] | [1, 0, 9, 0] => "WMF",
248 [b'B', b'M', ..] => "BMP",
249 _ => "an unknown format",
250 };
251 inventory.add(
252 Stage::Placeholder,
253 format!("{at} picture stored as {format}"),
254 place,
255 );
256 }
257 #[cfg(not(feature = "gpu"))]
258 let _ = bytes;
259}
260
261/// Pictures that draw as "Image unavailable" and objects the model keeps aside.
262fn scan_paragraphs(nodes: &[PageParagraph], at: &str, inventory: &mut Inventory, place: &str) {
263 for node in nodes {
264 match &node.content {
265 ParagraphContent::Unsupported(u) => inventory.add(
266 Stage::Placeholder,
267 format!("{at} paragraph holding {}", jcid_name(u.jcid)),
268 place,
269 ),
270 ParagraphContent::Image(image) => picture(image, at, inventory, place),
271 ParagraphContent::Table(table) => {
272 for cell in table.rows.iter().flat_map(|row| &row.cells) {
273 for u in &cell.unsupported {
274 inventory.add(
275 Stage::Placeholder,
276 format!("table cell child {}", jcid_name(u.jcid)),
277 place,
278 );
279 }
280 scan_paragraphs(&cell.paragraphs, "table cell", inventory, place);
281 }
282 }
283 _ => {}
284 }
285 }
286}
287
288/// Ordinary edits at every text paragraph of every editable outline: typing at its start,
289/// middle and end, Enter, Backspace at its start, Delete at its end, and deleting from its
290/// middle into the next.
291fn probe(
292 editor: &mut CanvasEditor,
293 engine: &mut TextEngine,
294 inventory: &mut Inventory,
295 place: &str,
296) {
297 let ids: Vec<ExGuid> = editor.outlines().iter().map(|o| o.id).collect();
298 for id in ids {
299 if editor.focus_outline(id).is_err() {
300 continue;
301 }
302 let document = editor.active_outline().document();
303 let containers = containers(document.nodes());
304 let leaves: Vec<(Option<ExGuid>, Paragraph)> = document
305 .text_nodes()
306 .map(|node| {
307 (
308 containers.get(&node.id).copied().flatten(),
309 node.text().unwrap().text.clone(),
310 )
311 })
312 .collect();
313 for (index, (container, text)) in leaves.iter().enumerate() {
314 let length = text.text().encode_utf16().count() as u32;
315 // The middle of what shows, where a click could put the caret.
316 let Ok(projection) = text.project() else {
317 continue;
318 };
319 let shown = projection.text();
320 let middle = shown
321 .text()
322 .char_indices()
323 .map(|(at, _)| at)
324 .nth(shown.text().chars().count() / 2)
325 .and_then(|at| shown.utf16_offset(at).ok())
326 .and_then(|at| projection.source_offset(at, Affinity::Downstream).ok())
327 .unwrap_or(length);
328 let at = |offset| TextPosition {
329 paragraph: index,
330 offset,
331 };
332 let next = leaves.get(index + 1);
333 let mut probes: Vec<(&str, [TextPosition; 2], Probe)> = vec![
334 ("typing at a paragraph's start", [at(0); 2], Probe::Type),
335 ("typing inside a paragraph", [at(middle); 2], Probe::Type),
336 ("typing at a paragraph's end", [at(length); 2], Probe::Type),
337 ("Enter inside a paragraph", [at(middle); 2], Probe::Enter),
338 (
339 "Delete at a paragraph's end",
340 [at(length); 2],
341 Probe::Delete,
342 ),
343 ];
344 if index > 0 {
345 probes.push((
346 "Backspace at a paragraph's start",
347 [at(0); 2],
348 Probe::Backspace,
349 ));
350 }
351 if let Some((_, next_text)) = next {
352 let end = next_text.text().encode_utf16().count() as u32 / 2;
353 probes.push((
354 "deleting a selection into the next paragraph",
355 [
356 at(middle),
357 TextPosition {
358 paragraph: index + 1,
359 offset: end,
360 },
361 ],
362 Probe::Backspace,
363 ));
364 }
365 for (name, positions, kind) in probes {
366 if editor.select(positions.into()).is_err() {
367 continue;
368 }
369 let result = match kind {
370 Probe::Type => editor.insert(engine, "x").map(|()| true),
371 Probe::Enter => editor.enter(engine, false).map(|()| true),
372 Probe::Delete => editor.delete(engine, false),
373 Probe::Backspace => editor.delete(engine, true),
374 };
375 match result {
376 Ok(true) => {
377 editor.undo(engine).ok();
378 }
379 Ok(false) => {}
380 Err(error) if unsupported_error(&error) => {
381 let show = |t: &Paragraph, o: u32| {
382 let b = t.byte_offset(o).unwrap_or(0);
383 let v = |s: &str| s.replace('\u{fdd0}', "⟨").replace('\u{fdef}', "⟩").replace('\u{fdee}', "|").replace('\u{fffc}', "¤");
384 format!("{}‸{}", v(&t.text()[..b]), v(&t.text()[b..]))
385 };
386 let a = show(text, positions[0].offset);
387 let b = if positions[1].paragraph != index { show(&leaves[positions[1].paragraph].1, positions[1].offset) } else { String::new() };
388 eprintln!("REFUSED {name} @ {place}\n A: {a}\n B: {b}");
389 let crossing = positions[0].paragraph != positions[1].paragraph
390 || name.starts_with("Backspace")
391 || name.starts_with("Delete");
392 let neighbour = match name {
393 n if n.starts_with("Backspace") => leaves.get(index - 1),
394 _ if crossing => next,
395 _ => None,
396 };
397 let mut why: Vec<&str> = traits(text);
398 if let Some((other, other_text)) = neighbour {
399 if other != container {
400 why.push("across a table cell boundary");
401 }
402 why.extend(traits(other_text).into_iter().map(|t| match t {
403 "embedded object in text" => "next to an embedded object",
404 "equation" => "next to an equation",
405 "link" => "next to a link",
406 t => t,
407 }));
408 }
409 if container.is_some() {
410 why.push("in a table cell");
411 }
412 why.sort();
413 why.dedup();
414 inventory.add(
415 Stage::Refused,
416 format!("{name} [{}]", why.join(", ")),
417 place,
418 );
419 }
420 Err(_) => {}
421 }
422 }
423 }
424 }
425}
426
427#[derive(Clone, Copy)]
428enum Probe {
429 Type,
430 Enter,
431 Delete,
432 Backspace,
433}
434
435/// Each paragraph's table cell, `None` at the outline's top.
436fn containers(nodes: &[PageParagraph]) -> BTreeMap<ExGuid, Option<ExGuid>> {
437 let mut map = BTreeMap::new();
438 let mut pending = vec![(None, nodes)];
439 while let Some((container, nodes)) = pending.pop() {
440 for node in nodes {
441 map.insert(node.id, container);
442 if let ParagraphContent::Table(table) = &node.content {
443 for cell in table.rows.iter().flat_map(|row| &row.cells) {
444 pending.push((Some(cell.id), cell.paragraphs.as_slice()));
445 }
446 }
447 }
448 }
449 map
450}
451
452fn page(engine: &mut TextEngine, page: Page, place: &str, inventory: &mut Inventory) {
453 for object in &page.objects {
454 match object {
455 PageObject::Unsupported(u) => inventory.add(
456 Stage::Placeholder,
457 format!("page object {}", jcid_name(u.jcid)),
458 place,
459 ),
460 PageObject::Image(image) => picture(image, "page", inventory, place),
461 PageObject::Outline(outline) => {
462 for u in &outline.unsupported {
463 inventory.add(
464 Stage::Placeholder,
465 format!("outline child {}", jcid_name(u.jcid)),
466 place,
467 );
468 }
469 scan_paragraphs(&outline.paragraphs, "outline", inventory, place);
470 }
471 PageObject::Title(title) => {
472 for outline in &title.outlines {
473 scan_paragraphs(&outline.paragraphs, "title", inventory, place);
474 }
475 }
476 _ => {}
477 }
478 }
479 let mut editor = match CanvasEditor::from_page(page.clone(), engine) {
480 Ok(editor) => editor,
481 Err(error) => {
482 inventory.add(
483 Stage::Failure,
484 format!("page does not open: {error}"),
485 place,
486 );
487 return;
488 }
489 };
490 for object in &page.objects {
491 let (outlines, title) = match object {
492 PageObject::Outline(outline) => (std::slice::from_ref(outline), None),
493 PageObject::Title(title) => (title.outlines.as_slice(), Some(title)),
494 _ => continue,
495 };
496 for outline in outlines {
497 if editor.has_page_outline(outline.id)
498 || editor.outlines().iter().any(|o| o.id == outline.id)
499 {
500 continue;
501 }
502 if let Some(title) = title {
503 if title.date == Some(outline.id) {
504 if editor.date().is_none() {
505 inventory.add(Stage::ReadOnly, "page date the date editor declines", place);
506 }
507 continue;
508 }
509 if !outline.title {
510 continue;
511 }
512 }
513 let placeholder = matches!(
514 outline.layout(engine, &page.definitions),
515 Err(LayoutError::UnsupportedContent)
516 );
517 let at = if title.is_some() { "title" } else { "outline" };
518 for reason in diagnose(engine, outline, &page) {
519 let stage = if placeholder {
520 Stage::Placeholder
521 } else {
522 Stage::ReadOnly
523 };
524 let whole = if placeholder {
525 format!("whole {at} as a box: {reason}")
526 } else {
527 format!("{at} read-only: {reason}")
528 };
529 inventory.add(stage, whole, place);
530 }
531 }
532 }
533 probe(&mut editor, engine, inventory, place);
534}
535
536fn section(engine: &mut TextEngine, path: &Path, root: &Path, inventory: &mut Inventory) {
537 let Ok(image) = std::fs::read(path) else {
538 return;
539 };
540 let name = path
541 .strip_prefix(root)
542 .unwrap_or(path)
543 .display()
544 .to_string();
545 let arena = Arena::default();
546 let mut section = match Section::open(&arena, image) {
547 Ok(section) => section,
548 Err(error) => {
549 inventory.add(
550 Stage::Failure,
551 format!("section does not open: {}", error.message),
552 &name,
553 );
554 return;
555 }
556 };
557 inventory.sections += 1;
558 let pages = match section.pages() {
559 Ok(pages) => pages,
560 Err(error) => {
561 inventory.add(
562 Stage::Failure,
563 format!("page list does not read: {}", error.message),
564 &name,
565 );
566 return;
567 }
568 };
569 for (space, title, _) in pages {
570 inventory.pages += 1;
571 let place = format!("{name} — {title}");
572 match section.page(space) {
573 Ok(model) => page(engine, model, &place, inventory),
574 Err(error) => inventory.add(
575 Stage::Failure,
576 format!("page does not read: {}", error.message),
577 &place,
578 ),
579 }
580 }
581}
582
583fn sections(directory: &Path, out: &mut Vec<PathBuf>) {
584 let Ok(entries) = std::fs::read_dir(directory) else {
585 return;
586 };
587 let mut entries: Vec<PathBuf> = entries.flatten().map(|entry| entry.path()).collect();
588 entries.sort();
589 for path in entries {
590 if path.is_dir() {
591 sections(&path, out);
592 } else if path.extension().is_some_and(|e| e == "one") {
593 out.push(path);
594 }
595 }
596}
597
598fn main() {
599 let roots: Vec<PathBuf> = std::env::args().skip(1).map(PathBuf::from).collect();
600 let mut engine = TextEngine::default();
601 let mut inventory = Inventory::default();
602 let mut seen = BTreeSet::new();
603 let mut duplicates = 0;
604 for root in &roots {
605 let mut paths = Vec::new();
606 sections(root, &mut paths);
607 for path in paths {
608 let Ok(bytes) = std::fs::read(&path) else {
609 continue;
610 };
611 let mut hasher = DefaultHasher::new();
612 bytes.hash(&mut hasher);
613 if !seen.insert(hasher.finish()) {
614 duplicates += 1;
615 continue;
616 }
617 section(&mut engine, &path, root, &mut inventory);
618 }
619 }
620 println!("# Unsupported content inventory\n");
621 println!(
622 "{} distinct sections ({duplicates} identical copies skipped), {} pages, under {}.\n",
623 inventory.sections,
624 inventory.pages,
625 roots
626 .iter()
627 .map(|r| format!("`{}`", r.display()))
628 .collect::<Vec<_>>()
629 .join(", ")
630 );
631 let headings = [
632 (Stage::Placeholder, "Drawn as a placeholder box"),
633 (Stage::ReadOnly, "Drawn as stored, read-only"),
634 (
635 Stage::Refused,
636 "Edits refused as content the editor cannot edit",
637 ),
638 (Stage::Failure, "Does not open"),
639 ];
640 for (stage, heading) in headings {
641 let mut rows: Vec<(&String, &Vec<String>)> = inventory
642 .kinds
643 .iter()
644 .filter(|((s, _), _)| *s == stage)
645 .map(|((_, kind), places)| (kind, places))
646 .collect();
647 rows.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then(a.0.cmp(b.0)));
648 println!("## {heading}\n");
649 if rows.is_empty() {
650 println!("None.\n");
651 continue;
652 }
653 println!("| Kind | Count | Pages | Examples |\n| --- | ---: | ---: | --- |");
654 for (kind, places) in rows {
655 let pages: BTreeSet<&String> = places.iter().collect();
656 let examples: Vec<String> = pages.iter().take(3).map(|p| format!("`{p}`")).collect();
657 println!(
658 "| {} | {} | {} | {} |",
659 kind.replace('|', "\\|"),
660 places.len(),
661 pages.len(),
662 examples.join("<br>").replace('|', "\\|")
663 );
664 }
665 println!();
666 }
667}
crates/canvas/examples/scratch_files.rs created+15
...@@ -0,0 +1,15 @@
1use onestore::{RevisionIndex, Store, document::{Document, Kind}};
2fn main() {
3 let bytes = std::fs::read(std::env::args().nth(1).unwrap()).unwrap();
4 let store = Store::parse(&bytes).unwrap();
5 let index = RevisionIndex::parse(&store).unwrap();
6 let document = Document::parse(&index).unwrap();
7 for (space, _) in document.pages().unwrap() {
8 let revision = document.active(space).unwrap();
9 for (id, node) in &revision.nodes {
10 if let Kind::File { extension, payload, .. } = &node.kind {
11 println!("{id:?} {extension} {:?}", payload.map(|p| (p.len(), &p[..4.min(p.len())])));
12 }
13 }
14 }
15}
crates/canvas/examples/scratch_runs.rs created+23
...@@ -0,0 +1,23 @@
1use onestore::{Arena, Section, page::{PageObject}};
2fn main() {
3 let path = std::env::args().nth(1).unwrap();
4 let want = std::env::args().nth(2).unwrap_or_default();
5 let arena = Arena::default();
6 let mut section = Section::open(&arena, std::fs::read(path).unwrap()).unwrap();
7 for (space, _, _) in section.pages().unwrap() {
8 let page = section.page(space).unwrap();
9 for object in &page.objects {
10 let PageObject::Outline(outline) = object else { continue };
11 for p in &outline.paragraphs {
12 let Some(text) = p.text() else { continue };
13 if !text.text.text().contains(want.as_str()) { continue }
14 let mut start = 0;
15 println!("-- {:?}", p.id);
16 for span in text.text.spans() {
17 println!(" {:?} math={:?} obj={:?} emb={:?}", &text.text.text()[start..span.end], span.format.math, span.format.math_object, span.format.embedded_object);
18 start = span.end;
19 }
20 }
21 }
22 }
23}
crates/canvas/src/editor.rs+35-32
...@@ -2178,39 +2178,29 @@ impl CanvasEditor {...@@ -2178,39 +2178,29 @@ impl CanvasEditor {
2178 Ok(true)2178 Ok(true)
2179 }2179 }
21802180
2181 /// Puts `image` in the active outline's flow after the caret's paragraph, as OneNote2181 /// Puts `image` at the caret as OneNote 2010 pastes and inserts a picture: in an outline
2182 /// inserts a picture into an outline; the caret stays.2182 /// as [`Self::insert_attachment`] puts a file, and on blank page at the caret with the
2183 /// caret moving to the grid row below it, where typing starts a new outline.
2183 pub fn insert_picture(2184 pub fn insert_picture(
2184 &mut self,2185 &mut self,
2185 engine: &mut TextEngine,2186 engine: &mut TextEngine,
2186 image: onestore::page::Image,2187 mut image: onestore::page::Image,
2187 ) -> Result<(), EditorError> {2188 ) -> Result<(), EditorError> {
2188 let selection = self.selection();2189 let Focus::Caret { outline, .. } = &self.active else {
2189 let caret = selection.positions[0].max(selection.positions[1]);2190 return self.insert_in_flow(engine, ParagraphContent::Image(image));
2190 let (container, index, node) = self
2191 .active_outline()
2192 .document
2193 .leaf(caret.paragraph)
2194 .ok_or(EditError::InvalidRange)?;
2195 let picture = PageParagraph {
2196 id: onestore::page::text::new_id()?,
2197 content: ParagraphContent::Image(image),
2198 style: None,
2199 lists: Vec::new(),
2200 tags: Vec::new(),
2201 collapsed: false,
2202 ..node.clone()
2203 };2191 };
2204 self.commit(2192 let [x, y] = outline.origin();
2205 engine,2193 [image.layout.x, image.layout.y] = [Some(x), Some(y)];
2206 DocumentEdit {2194 image.layout.width_set_by_user = Some(false);
2207 columns: BTreeMap::new(),2195 let height = image.layout.max_height.unwrap_or_default();
2208 container,2196 self.finish_composition();
2209 range: index + 1..index + 1,2197 let index = self.objects.len();
2210 replacement: vec![picture],2198 self.add_picture(index, image);
2211 },2199 self.undo.push(History::Picture { index, image: None });
2212 selection,2200 self.redo.clear();
2213 )2201 let margin = self.margin_origin()[1];
2202 let row = ((y + height - margin) / 18.0).floor() + 1.0;
2203 self.place_caret(engine, [x, margin + row * 18.0], DEFAULT_OUTLINE_WIDTH)
2214 }2204 }
22152205
2216 /// Attaches `file` at the caret as OneNote 2010 does (`corpus/attachment-insert`): the2206 /// Attaches `file` at the caret as OneNote 2010 does (`corpus/attachment-insert`): the
...@@ -2241,11 +2231,21 @@ impl CanvasEditor {...@@ -2241,11 +2231,21 @@ impl CanvasEditor {
2241 self.redo.clear();2231 self.redo.clear();
2242 return Ok(());2232 return Ok(());
2243 }2233 }
2244 let file = match self.across(engine, |editor, engine| {2234 self.insert_in_flow(engine, ParagraphContent::Attachment(file))
2245 editor.insert_attachment(engine, file.clone())2235 }
2236
2237 /// Splits the caret's paragraph around `content`, which takes the place of an empty first
2238 /// half; the caret starts the second.
2239 fn insert_in_flow(
2240 &mut self,
2241 engine: &mut TextEngine,
2242 content: ParagraphContent,
2243 ) -> Result<(), EditorError> {
2244 let content = match self.across(engine, |editor, engine| {
2245 editor.insert_in_flow(engine, content.clone())
2246 })? {2246 })? {
2247 Some(()) => return Ok(()),2247 Some(()) => return Ok(()),
2248 None => file,2248 None => content,
2249 };2249 };
2250 let [anchor, focus] = self.selection().positions;2250 let [anchor, focus] = self.selection().positions;
2251 let (start, end) = (anchor.min(focus), anchor.max(focus));2251 let (start, end) = (anchor.min(focus), anchor.max(focus));
...@@ -2260,7 +2260,7 @@ impl CanvasEditor {...@@ -2260,7 +2260,7 @@ impl CanvasEditor {
2260 };2260 };
2261 let node = PageParagraph {2261 let node = PageParagraph {
2262 id: onestore::page::text::new_id()?,2262 id: onestore::page::text::new_id()?,
2263 content: ParagraphContent::Attachment(file),2263 content,
2264 style: None,2264 style: None,
2265 lists: Vec::new(),2265 lists: Vec::new(),
2266 tags: Vec::new(),2266 tags: Vec::new(),
...@@ -3209,6 +3209,7 @@ impl CanvasEditor {...@@ -3209,6 +3209,7 @@ impl CanvasEditor {
3209 engine: &mut TextEngine,3209 engine: &mut TextEngine,
3210 replacement: Vec<Paragraph>,3210 replacement: Vec<Paragraph>,
3211 ) -> Result<(), EditorError> {3211 ) -> Result<(), EditorError> {
3212 self.take_objects()?;
3212 let start = self.active_outline().selection.positions[0]3213 let start = self.active_outline().selection.positions[0]
3213 .min(self.active_outline().selection.positions[1]);3214 .min(self.active_outline().selection.positions[1]);
3214 let end = self.active_outline().selection.positions[0]3215 let end = self.active_outline().selection.positions[0]
...@@ -3308,6 +3309,7 @@ impl CanvasEditor {...@@ -3308,6 +3309,7 @@ impl CanvasEditor {
3308 editor.paste(engine, text, language)3309 editor.paste(engine, text, language)
3309 });3310 });
3310 }3311 }
3312 self.take_objects()?;
3311 let lines = text3313 let lines = text
3312 .split('\n')3314 .split('\n')
3313 .map(|line| line.strip_suffix('\r').unwrap_or(line))3315 .map(|line| line.strip_suffix('\r').unwrap_or(line))
...@@ -3569,6 +3571,7 @@ impl CanvasEditor {...@@ -3569,6 +3571,7 @@ impl CanvasEditor {
3569 self.grouped(|editor| editor.remove_page(engine, false))?;3571 self.grouped(|editor| editor.remove_page(engine, false))?;
3570 return Ok(true);3572 return Ok(true);
3571 }3573 }
3574 self.take_objects()?;
3572 let selection = self.active_outline().selection;3575 let selection = self.active_outline().selection;
3573 let [anchor, focus] = selection.positions;3576 let [anchor, focus] = selection.positions;
3574 let mut range = anchor.min(focus)..anchor.max(focus);3577 let mut range = anchor.min(focus)..anchor.max(focus);
crates/canvas/src/editor/equation.rs+117-5
...@@ -36,6 +36,107 @@ pub(crate) fn zone(text: &Paragraph, offset: u32) -> Option<Range<u32>> {...@@ -36,6 +36,107 @@ pub(crate) fn zone(text: &Paragraph, offset: u32) -> Option<Range<u32>> {
36 current.filter(|zone| zone.start <= offset && offset <= zone.end)36 current.filter(|zone| zone.start <= offset && offset <= zone.end)
37}37}
3838
39/// An object of an equation holding a position: its range, from its opening control to past
40/// its closing one, and the argument the position lies in.
41#[derive(PartialEq)]
42struct Holder {
43 range: Range<u32>,
44 argument: usize,
45 placeholder: bool,
46}
47
48/// The objects of `text` holding UTF-16 `offset` strictly inside, outermost first.
49fn holders(text: &Paragraph, offset: u32) -> Vec<Holder> {
50 // Each object's range and whether it is a placeholder; open objects and held ones by
51 // index, with the argument reached.
52 let mut objects: Vec<(Range<u32>, bool)> = Vec::new();
53 let (mut open, mut held) = (Vec::new(), None);
54 let mut unit = 0;
55 for c in text.text().chars() {
56 if unit == offset {
57 held = Some(open.clone());
58 }
59 match c {
60 OBJECT_START => {
61 // An equation's empty placeholder, "Type equation here.", is a box OneNote
62 // marks with this symbol.
63 let placeholder = text.format_at(unit + 1).is_ok_and(|format| {
64 format
65 .math_object
66 .as_ref()
67 .is_some_and(|object| object.kind == 11 && object.symbols == ['⬚'])
68 });
69 open.push((objects.len(), 0));
70 objects.push((unit..unit, placeholder));
71 }
72 ARGUMENT_SEPARATOR => {
73 if let Some((_, argument)) = open.last_mut() {
74 *argument += 1;
75 }
76 }
77 OBJECT_END => {
78 if let Some((index, _)) = open.pop() {
79 objects[index].0.end = unit + 1;
80 }
81 }
82 _ => {}
83 }
84 unit += c.len_utf16() as u32;
85 }
86 held.unwrap_or(open)
87 .into_iter()
88 .map(|(index, argument)| Holder {
89 range: objects[index].0.clone(),
90 argument,
91 placeholder: objects[index].1,
92 })
93 .collect()
94}
95
96/// `range` as OneNote 2010's equation editor selects it (`corpus/equation-select`): an end
97/// inside an object not holding the other end in the same argument takes the whole object, as
98/// does an end inside a placeholder, and across paragraphs an object so taken at the end of
99/// its paragraph takes the paragraph's end with it.
100pub(crate) fn widen(
101 document: &TextDocument,
102 range: Range<TextPosition>,
103) -> Result<Range<TextPosition>, EditError> {
104 let text = |paragraph| document.paragraph(paragraph).ok_or(EditError::InvalidRange);
105 let last = text(range.end.paragraph)?;
106 let starts = holders(text(range.start.paragraph)?, range.start.offset);
107 let ends = holders(last, range.end.offset);
108 let common = if range.start.paragraph == range.end.paragraph {
109 starts.iter().zip(&ends).take_while(|(a, b)| a == b).count()
110 } else {
111 0
112 };
113 let level = |holders: &[Holder]| {
114 holders
115 .iter()
116 .position(|holder| holder.placeholder)
117 .map_or(common, |placeholder| placeholder.min(common))
118 };
119 let mut widened = range.clone();
120 if let Some(holder) = starts.get(level(&starts)) {
121 widened.start.offset = holder.range.start;
122 }
123 if let Some(holder) = ends.get(level(&ends)) {
124 widened.end.offset = holder.range.end;
125 let next = range.end.paragraph + 1;
126 let container = |paragraph| document.leaf(paragraph).map(|(container, ..)| container);
127 if range.start.paragraph != range.end.paragraph
128 && widened.end.offset == last.utf16_offset(last.text().len())?
129 && container(next).is_some_and(|next| Some(next) == container(range.end.paragraph))
130 {
131 widened.end = TextPosition {
132 paragraph: next,
133 offset: 0,
134 };
135 }
136 }
137 Ok(widened)
138}
139
39/// The format text typed after an equation in `text` takes: the paragraph's last text's, or140/// The format text typed after an equation in `text` takes: the paragraph's last text's, or
40/// its first run's without the equation editor's.141/// its first run's without the equation editor's.
41pub(super) fn text_after(text: &Paragraph) -> Format {142pub(super) fn text_after(text: &Paragraph) -> Format {
...@@ -70,6 +171,20 @@ impl CanvasEditor {...@@ -70,6 +171,20 @@ impl CanvasEditor {
70 Some((caret.paragraph, zone(text, caret.offset)?))171 Some((caret.paragraph, zone(text, caret.offset)?))
71 }172 }
72173
174 /// Widens the selection an edit is about to replace as the equation editor selects.
175 pub(super) fn take_objects(&mut self) -> Result<(), EditError> {
176 let [anchor, focus] = self.active_outline().selection.positions;
177 let range = anchor.min(focus)..anchor.max(focus);
178 let widened = widen(&self.active_outline().document, range.clone())?;
179 if widened != range {
180 self.active_outline_mut().selection = Selection {
181 positions: [widened.start, widened.end],
182 affinities: [Affinity::Downstream, Affinity::Upstream],
183 };
184 }
185 Ok(())
186 }
187
73 /// Whether the caret is in an equation, which Linear and Professional act on.188 /// Whether the caret is in an equation, which Linear and Professional act on.
74 pub fn in_equation(&self) -> bool {189 pub fn in_equation(&self) -> bool {
75 self.caret_zone().is_some()190 self.caret_zone().is_some()
...@@ -373,13 +488,10 @@ impl CanvasEditor {...@@ -373,13 +488,10 @@ impl CanvasEditor {
373 for part in parts {488 for part in parts {
374 broken.append(part)?;489 broken.append(part)?;
375 }490 }
376 let Some(linear) = Math::parse(&broken)491 let Ok(nodes) = Math::parse(&broken) else {
377 .ok()
378 .and_then(|nodes| Math::linear(&nodes))
379 else {
380 return Ok(false);492 return Ok(false);
381 };493 };
382 let built = Math::paragraph(&Math::from_linear(&linear), &math.spans()[0].format);494 let built = Math::paragraph(&nodes, &math.spans()[0].format);
383 // The caret starts the new row: after as many characters other than spaces as came495 // The caret starts the new row: after as many characters other than spaces as came
384 // before it.496 // before it.
385 let before = broken497 let before = broken
crates/canvas/src/editor/table.rs+1
...@@ -398,6 +398,7 @@ impl CanvasEditor {...@@ -398,6 +398,7 @@ impl CanvasEditor {
398 if soft {398 if soft {
399 return self.insert(engine, "\u{000b}");399 return self.insert(engine, "\u{000b}");
400 }400 }
401 self.take_objects()?;
401 if self.active_outline().title {402 if self.active_outline().title {
402 return self.leave_title(engine);403 return self.leave_title(engine);
403 }404 }
crates/canvas/src/gpu/mod.rs+1
...@@ -110,6 +110,7 @@ impl Glyphs for TextLayout {...@@ -110,6 +110,7 @@ impl Glyphs for TextLayout {
110 };110 };
111 draw::paint_parley_run(111 draw::paint_parley_run(
112 &run,112 &run,
113 &self.text,
113 bounds.baseline,114 bounds.baseline,
114 run.style().brush.rise,115 run.style().brush.rise,
115 [bounds.top, bounds.height],116 [bounds.top, bounds.height],
crates/canvas/src/gpu/page.rs+85
...@@ -766,6 +766,91 @@ impl PageScene {...@@ -766,6 +766,91 @@ impl PageScene {
766 None766 None
767 }767 }
768768
769 /// Where the page read without an editor lies, in page coordinates: the rectangle all
770 /// its content covers, and the top and bottom of each line of text and each picture, which
771 /// a sheet of paper does not cut through where it can help it.
772 pub(crate) fn printed_extent(&self) -> Result<([f32; 4], Vec<[f32; 2]>), SceneError> {
773 let mut bounds = [
774 f32::INFINITY,
775 f32::INFINITY,
776 f32::NEG_INFINITY,
777 f32::NEG_INFINITY,
778 ];
779 let mut rows = Vec::new();
780 let mut cover = |[x0, y0, x1, y1]: [f32; 4], row: bool| {
781 bounds = [
782 bounds[0].min(x0),
783 bounds[1].min(y0),
784 bounds[2].max(x1),
785 bounds[3].max(y1),
786 ];
787 if row {
788 rows.push([y0, y1]);
789 }
790 };
791 for content in self.objects(None)? {
792 match content {
793 Content::Outline {
794 source,
795 layout,
796 below_title,
797 } => {
798 let [x, y] = outline_origin(
799 [
800 source.layout.x.unwrap_or(0.0),
801 source.layout.y.unwrap_or(0.0),
802 ],
803 *below_title,
804 None,
805 )?;
806 cover([x, y, x + layout.size[0], y + layout.size[1]], false);
807 for paragraph in &layout.paragraphs {
808 let [px, py] = [x + paragraph.origin[0], y + paragraph.origin[1]];
809 for (line, bounds) in paragraph.text.lines() {
810 let top = py + bounds.top;
811 let right = px + line.metrics().advance;
812 cover([px, top, right, top + bounds.height], true);
813 }
814 }
815 for object in &layout.objects {
816 let [x0, y0, x1, y1] = object.rect;
817 cover([x + x0, y + y0, x + x1, y + object.bottom.max(y1)], true);
818 }
819 }
820 Content::Image(source) => {
821 let [x, y] = [
822 source.layout.x.unwrap_or(0.0),
823 source.layout.y.unwrap_or(0.0),
824 ];
825 let size = match self.backgrounds.get(&source.id) {
826 Some(art) => Some(art.size),
827 None => source
828 .layout
829 .max_width
830 .zip(source.layout.max_height)
831 .map(|(width, height)| [width, height]),
832 };
833 if let Some([width, height]) = size {
834 cover([x, y, x + width, y + height], !source.background);
835 }
836 }
837 Content::File { .. } => {
838 if let Some((_, rect)) = content.file() {
839 cover(rect, true);
840 }
841 }
842 Content::Ink(ink) => {
843 if let Some(rect) = crate::editor::page::ink_bounds(ink) {
844 cover(rect, false);
845 }
846 }
847 Content::ReadOnly(object) => cover(object.rect(), true),
848 Content::Date { .. } | Content::Editable(_) => {}
849 }
850 }
851 Ok((bounds, rows))
852 }
853
769 /// The offset is in document points.854 /// The offset is in document points.
770 pub fn append_primitives<'a>(855 pub fn append_primitives<'a>(
771 &'a self,856 &'a self,
crates/canvas/src/interaction/ink.rs+1-9
...@@ -2,7 +2,7 @@...@@ -2,7 +2,7 @@
2//! dragged out on the grid, the stroke eraser, and the lasso whose drawings move and delete,2//! dragged out on the grid, the stroke eraser, and the lasso whose drawings move and delete,
3//! as OneNote 2010 has them (`corpus/ink-tools`).3//! as OneNote 2010 has them (`corpus/ink-tools`).
44
5use super::{PageView, Response, Result, snap_to_grid};5use super::{PageView, Response, Result};
6use crate::editor::{FAVORITES, Pen};6use crate::editor::{FAVORITES, Pen};
7use crate::gpu::colorref;7use crate::gpu::colorref;
8use draw::{8use draw::{
...@@ -184,14 +184,6 @@ impl PageView {...@@ -184,14 +184,6 @@ impl PageView {
184 Ok(Some(self.changed()?))184 Ok(Some(self.changed()?))
185 }185 }
186186
187 fn grid(&self, point: [f32; 2]) -> [f32; 2] {
188 if self.modifiers.option {
189 point
190 } else {
191 snap_to_grid(point, self.editor.margin_origin())
192 }
193 }
194
195 /// The pointer moved to page point `point` during a gesture.187 /// The pointer moved to page point `point` during a gesture.
196 pub(super) fn ink_moved(&mut self, point: [f32; 2]) -> Result<Response> {188 pub(super) fn ink_moved(&mut self, point: [f32; 2]) -> Result<Response> {
197 let pressure = self.ink.pressure;189 let pressure = self.ink.pressure;
crates/canvas/src/interaction/mod.rs+50-24
...@@ -258,6 +258,9 @@ pub struct PageView {...@@ -258,6 +258,9 @@ pub struct PageView {
258 /// A platform scroll view owns the viewport: changes neither clamp it nor reveal the258 /// A platform scroll view owns the viewport: changes neither clamp it nor reveal the
259 /// caret, which the host does knowing its bars and keyboard.259 /// caret, which the host does knowing its bars and keyboard.
260 pub host_viewport: bool,260 pub host_viewport: bool,
261 /// OneNote's Snap to Grid: clicks, drags and shapes land on the placement grid unless
262 /// Option (Alt) is held. Off, they land where the pointer is.
263 pub snap_to_grid: bool,
261 /// Matches of the search shown on the page, marked under their text.264 /// Matches of the search shown on the page, marked under their text.
262 pub found: Vec<crate::search::PageMatch>,265 pub found: Vec<crate::search::PageMatch>,
263 /// The note playing, highlighted as See Playback highlights it.266 /// The note playing, highlighted as See Playback highlights it.
...@@ -271,6 +274,9 @@ pub struct PageView {...@@ -271,6 +274,9 @@ pub struct PageView {
271 /// When the caret last moved, which restarts its blink.274 /// When the caret last moved, which restarts its blink.
272 blink_from: Instant,275 blink_from: Instant,
273 ink: ink::State,276 ink: ink::State,
277 /// What the view showed before each zoom since the page opened, in document points:
278 /// scrolling still reaches it, so a zoom keeps the point under the pointer in place.
279 reach: Option<[f32; 4]>,
274}280}
275281
276/// Room, in OneNote pixels, the view leaves beyond content it scrolls to.282/// Room, in OneNote pixels, the view leaves beyond content it scrolls to.
...@@ -326,6 +332,7 @@ impl PageView {...@@ -326,6 +332,7 @@ impl PageView {
326 focused: true,332 focused: true,
327 touch: false,333 touch: false,
328 host_viewport: false,334 host_viewport: false,
335 snap_to_grid: true,
329 found: Vec::new(),336 found: Vec::new(),
330 played: None,337 played: None,
331 spelling: None,338 spelling: None,
...@@ -333,6 +340,7 @@ impl PageView {...@@ -333,6 +340,7 @@ impl PageView {
333 caret: 1.0,340 caret: 1.0,
334 blink_from: Instant::now(),341 blink_from: Instant::now(),
335 ink: Default::default(),342 ink: Default::default(),
343 reach: None,
336 };344 };
337 view.place_opened();345 view.place_opened();
338 view346 view
...@@ -376,6 +384,7 @@ impl PageView {...@@ -376,6 +384,7 @@ impl PageView {
376 place: Option<Place>,384 place: Option<Place>,
377 ) {385 ) {
378 self.replace(editor, scene);386 self.replace(editor, scene);
387 self.reach = None;
379 let Some(place) = place else {388 let Some(place) = place else {
380 return self.place_opened();389 return self.place_opened();
381 };390 };
...@@ -461,14 +470,7 @@ impl PageView {...@@ -461,14 +470,7 @@ impl PageView {
461 }470 }
462 let point = self.viewport.document_point(self.pointer);471 let point = self.viewport.document_point(self.pointer);
463 let position = [point[0] - grab[0], point[1] - grab[1]];472 let position = [point[0] - grab[0], point[1] - grab[1]];
464 Some((473 Some((*id, self.grid(position)))
465 *id,
466 if self.modifiers.option {
467 position
468 } else {
469 snap_to_grid(position, self.editor.margin_origin())
470 },
471 ))
472 }474 }
473475
474 /// The dragged outline and how far it would move.476 /// The dragged outline and how far it would move.
...@@ -505,12 +507,16 @@ impl PageView {...@@ -505,12 +507,16 @@ impl PageView {
505 return Some((*id, origin, size));507 return Some((*id, origin, size));
506 }508 }
507 let origin = [origin[0] + delta[0], origin[1] + delta[1]];509 let origin = [origin[0] + delta[0], origin[1] + delta[1]];
508 let origin = if self.modifiers.option {510 Some((*id, self.grid(origin), size))
509 origin511 }
512
513 /// `point` on the placement grid, where Snap to Grid is on and Option isn't held.
514 fn grid(&self, point: [f32; 2]) -> [f32; 2] {
515 if self.snap_to_grid && !self.modifiers.option {
516 snap_to_grid(point, self.editor.margin_origin())
510 } else {517 } else {
511 snap_to_grid(origin, self.editor.margin_origin())518 point
512 };519 }
513 Some((*id, origin, size))
514 }520 }
515521
516 fn pixel(&self) -> f32 {522 fn pixel(&self) -> f32 {
...@@ -786,14 +792,27 @@ impl PageView {...@@ -786,14 +792,27 @@ impl PageView {
786 self.moved()792 self.moved()
787 }793 }
788794
789 /// Scales the view by `factor` about the pointer, as a trackpad pinch does.795 /// Scales the view by `factor` about `anchor`, in device pixels from the view's
790 pub fn pinch(&mut self, factor: f32) -> Result<Response> {796 /// top-left, as a trackpad pinch does about the pointer.
791 self.zoom_about(factor, self.pointer);797 pub fn pinch(&mut self, factor: f32, anchor: [f32; 2]) -> Result<Response> {
798 self.zoom_about(factor, anchor);
792 self.moved()799 self.moved()
793 }800 }
794801
795 /// Scales the view by `factor`, keeping the document point under `anchor` in place.802 /// Scales the view by `factor`, keeping the document point under `anchor` in place.
796 fn zoom_about(&mut self, factor: f32, anchor: [f32; 2]) {803 fn zoom_about(&mut self, factor: f32, anchor: [f32; 2]) {
804 let [x0, y0] = self.viewport.document_point([0.0; 2]);
805 let [x1, y1] = self
806 .viewport
807 .document_point(self.viewport.size.map(|size| size as f32));
808 self.reach = Some(self.reach.map_or([x0, y0, x1, y1], |reach| {
809 [
810 reach[0].min(x0),
811 reach[1].min(y0),
812 reach[2].max(x1),
813 reach[3].max(y1),
814 ]
815 }));
797 let point = self.viewport.document_point(anchor);816 let point = self.viewport.document_point(anchor);
798 let dpr = self.display_scale;817 let dpr = self.display_scale;
799 self.viewport.scale = (self.viewport.scale * factor).clamp(dpr / 3.0, dpr * 16.0 / 3.0);818 self.viewport.scale = (self.viewport.scale * factor).clamp(dpr / 3.0, dpr * 16.0 / 3.0);
...@@ -841,7 +860,16 @@ impl PageView {...@@ -841,7 +860,16 @@ impl PageView {
841 )860 )
842 })861 })
843 });862 });
844 scroll::Scroll::new(self.viewport, editable.map(|rect| (rect, pad)).chain(fixed))863 let mut scroll =
864 scroll::Scroll::new(self.viewport, editable.map(|rect| (rect, pad)).chain(fixed));
865 if let Some(reach) = self.reach {
866 for axis in 0..2 {
867 scroll.min[axis] = scroll.min[axis].min(reach[axis] * self.viewport.scale);
868 scroll.max[axis] = scroll.max[axis]
869 .max(reach[axis + 2] * self.viewport.scale - self.viewport.size[axis] as f32);
870 }
871 }
872 scroll
845 }873 }
846874
847 /// Scrolls the view's corner `offset` device pixels from the page origin along `axis`,875 /// Scrolls the view's corner `offset` device pixels from the page origin along `axis`,
...@@ -1263,11 +1291,7 @@ impl PageView {...@@ -1263,11 +1291,7 @@ impl PageView {
1263 /// A fresh click places text 7 px above the pointer, on the grid.1291 /// A fresh click places text 7 px above the pointer, on the grid.
1264 fn place_caret(&mut self, point: [f32; 2]) -> Result<()> {1292 fn place_caret(&mut self, point: [f32; 2]) -> Result<()> {
1265 let position = [point[0], point[1] - 7.0 * self.pixel()];1293 let position = [point[0], point[1] - 7.0 * self.pixel()];
1266 let position = if self.modifiers.option {1294 let position = self.grid(position);
1267 position
1268 } else {
1269 snap_to_grid(position, self.editor.margin_origin())
1270 };
1271 self.editor1295 self.editor
1272 .place_caret(&mut self.engine, position, DEFAULT_OUTLINE_WIDTH)?;1296 .place_caret(&mut self.engine, position, DEFAULT_OUTLINE_WIDTH)?;
1273 Ok(())1297 Ok(())
...@@ -1662,7 +1686,8 @@ impl PageView {...@@ -1662,7 +1686,8 @@ impl PageView {
1662 self.edited()1686 self.edited()
1663 }1687 }
16641688
1665 /// Insert, Picture: a PNG, JPEG or GIF `size` points large after the caret's paragraph.1689 /// Insert, Picture, or a pasted one: a PNG, JPEG or GIF `size` points large; see
1690 /// [`CanvasEditor::insert_picture`].
1666 pub fn insert_picture(&mut self, bytes: Vec<u8>, size: [f32; 2]) -> Result<Response> {1691 pub fn insert_picture(&mut self, bytes: Vec<u8>, size: [f32; 2]) -> Result<Response> {
1667 if !self.accepts_text() || self.editor.marked_range().is_some() {1692 if !self.accepts_text() || self.editor.marked_range().is_some() {
1668 return Ok(Response::default());1693 return Ok(Response::default());
...@@ -1682,6 +1707,7 @@ impl PageView {...@@ -1682,6 +1707,7 @@ impl PageView {
1682 printout: None,1707 printout: None,
1683 };1708 };
1684 self.editor.insert_picture(&mut self.engine, image)?;1709 self.editor.insert_picture(&mut self.engine, image)?;
1710 self.follow_pictures()?;
1685 self.edited()1711 self.edited()
1686 }1712 }
16871713
...@@ -2269,7 +2295,7 @@ fn page_hit(...@@ -2269,7 +2295,7 @@ fn page_hit(
2269/// The page's rule lines over `visible`, a document rectangle, as OneNote 2010 draws them:2295/// The page's rule lines over `visible`, a document rectangle, as OneNote 2010 draws them:
2270/// 1/96 inch wide, or a device pixel where that is wider, horizontal lines from the margin2296/// 1/96 inch wide, or a device pixel where that is wider, horizontal lines from the margin
2271/// origin down, and a grid's vertical lines through it or a margin line 1/96 inch left of it.2297/// origin down, and a grid's vertical lines through it or a margin line 1/96 inch left of it.
2272fn rule_primitives(2298pub(crate) fn rule_primitives(
2273 lines: Option<onestore::page::RuleLines>,2299 lines: Option<onestore::page::RuleLines>,
2274 [x, y]: [f32; 2],2300 [x, y]: [f32; 2],
2275 [left, top, right, bottom]: [f32; 4],2301 [left, top, right, bottom]: [f32; 4],
crates/canvas/src/interaction/tests.rs+28-9
...@@ -1753,6 +1753,12 @@ fn events_route_through_the_view_as_the_host_delivers_them() {...@@ -1753,6 +1753,12 @@ fn events_route_through_the_view_as_the_host_delivers_them() {
1753 click(&mut view, [400.0, 300.0], now + Duration::from_secs(3));1753 click(&mut view, [400.0, 300.0], now + Duration::from_secs(3));
1754 let caret = view.editor.caret_outline().unwrap().origin();1754 let caret = view.editor.caret_outline().unwrap().origin();
1755 assert_eq!(caret, snap_to_grid([400.0, 293.0], [36.0, 14.4]));1755 assert_eq!(caret, snap_to_grid([400.0, 293.0], [36.0, 14.4]));
1756
1757 // With Snap to Grid off it lands where the pointer is, as OneNote 2010 places it.
1758 view.snap_to_grid = false;
1759 click(&mut view, [410.0, 330.0], now + Duration::from_secs(5));
1760 let caret = view.editor.caret_outline().unwrap().origin();
1761 assert_eq!(caret, [410.0, 323.0]);
1756}1762}
17571763
1758#[test]1764#[test]
...@@ -2471,6 +2477,8 @@ fn the_page_selection_does_not_return_with_its_text_selection() {...@@ -2471,6 +2477,8 @@ fn the_page_selection_does_not_return_with_its_text_selection() {
2471 assert_eq!(view.editor.whole(), None);2477 assert_eq!(view.editor.whole(), None);
2472}2478}
24732479
2480/// The point under the pointer stays put through a pinch, as in Safari, even where the
2481/// page's content is too small to scroll that far; scrolling afterwards still reaches it.
2474#[test]2482#[test]
2475fn pinch_zooms_about_the_pointer_within_range() {2483fn pinch_zooms_about_the_pointer_within_range() {
2476 let mut engine = TextEngine::default();2484 let mut engine = TextEngine::default();
...@@ -2488,17 +2496,28 @@ fn pinch_zooms_about_the_pointer_within_range() {...@@ -2488,17 +2496,28 @@ fn pinch_zooms_about_the_pointer_within_range() {
2488 1.0,2496 1.0,
2489 Duration::from_millis(500),2497 Duration::from_millis(500),
2490 );2498 );
2491 // The host's own scrolling leaves the anchor unclamped, as iOS's does.2499 let pointer = [700.0, 500.0];
2492 view.host_viewport = true;
2493 let pointer = [300.0, 200.0];
2494 let _ = view.pointer_moved(pointer).unwrap();
2495 let under = view.viewport.document_point(pointer);2500 let under = view.viewport.document_point(pointer);
2501 let shown = view.viewport.document_point([800.0, 600.0]);
2502 let stays = |view: &PageView| {
2503 let after = view.viewport.document_point(pointer);
2504 assert!(
2505 (after[0] - under[0]).abs() < 1e-3 && (after[1] - under[1]).abs() < 1e-3,
2506 "{under:?} moved to {after:?}"
2507 );
2508 };
2496 let zoom = view.zoom();2509 let zoom = view.zoom();
2497 assert!(view.pinch(1.5).unwrap().moved);2510 for factor in [1.05, 1.0 / 1.05, 1.05] {
2498 assert!((view.zoom() / zoom - 1.5).abs() < 1e-5);2511 for _ in 0..10 {
2499 let after = view.viewport.document_point(pointer);2512 assert!(view.pinch(factor, pointer).unwrap().moved);
2500 assert!((after[0] - under[0]).abs() < 1e-3 && (after[1] - under[1]).abs() < 1e-3);2513 stays(&view);
2501 let _ = view.pinch(100.0).unwrap();2514 }
2515 }
2516 assert!((view.zoom() / zoom - 1.05_f32.powi(10)).abs() < 1e-4);
2517 let _ = view.wheel([-5000.0; 2]).unwrap();
2518 let corner = view.viewport.document_point([800.0, 600.0]);
2519 assert!((corner[0] - shown[0]).abs() < 1e-3 && (corner[1] - shown[1]).abs() < 1e-3);
2520 let _ = view.pinch(100.0, pointer).unwrap();
2502 assert!((view.zoom() - 4.0).abs() < 1e-5);2521 assert!((view.zoom() - 4.0).abs() < 1e-5);
2503}2522}
25042523
crates/canvas/src/layout.rs+3
...@@ -61,6 +61,8 @@ pub struct LineBox {...@@ -61,6 +61,8 @@ pub struct LineBox {
61pub struct TextLayout {61pub struct TextLayout {
62 id: u64,62 id: u64,
63 pub(crate) shaped: Arc<Layout<TextBrush>>,63 pub(crate) shaped: Arc<Layout<TextBrush>>,
64 /// The paragraph text laid out, which a PDF's text layer maps glyphs back to.
65 pub(crate) text: Arc<str>,
64 lines: Vec<LineBox>,66 lines: Vec<LineBox>,
65 /// Where each inline space lies: its index in the spaces laid out, its x, and its line.67 /// Where each inline space lies: its index in the spaces laid out, its x, and its line.
66 spaces: Vec<(usize, f32, usize)>,68 spaces: Vec<(usize, f32, usize)>,
...@@ -518,6 +520,7 @@ impl TextEngine {...@@ -518,6 +520,7 @@ impl TextEngine {
518 Ok(TextLayout {520 Ok(TextLayout {
519 id,521 id,
520 shaped: Arc::new(shaped),522 shaped: Arc::new(shaped),
523 text: text.into(),
521 lines,524 lines,
522 spaces: placed,525 spaces: placed,
523 })526 })
crates/canvas/src/lib.rs+2
...@@ -14,6 +14,8 @@ pub mod date;...@@ -14,6 +14,8 @@ pub mod date;
14pub mod gpu;14pub mod gpu;
15#[cfg(feature = "interaction")]15#[cfg(feature = "interaction")]
16pub mod interaction;16pub mod interaction;
17#[cfg(feature = "pdf")]
18pub mod print;
1719
18/// Parley's caret affinity mapped onto the page model's hidden-field affinity.20/// Parley's caret affinity mapped onto the page model's hidden-field affinity.
19pub fn affinity(affinity: parley::Affinity) -> onestore::page::text::Affinity {21pub fn affinity(affinity: parley::Affinity) -> onestore::page::text::Affinity {
crates/canvas/src/print.rs created+553
...@@ -0,0 +1,553 @@
1//! Pages on sheets of paper as OneNote 2010 prints them and saves them as PDF.
2//!
3//! OneNote draws a page between half-inch top and bottom margins, the page's margin origin
4//! an inch from the paper's left edge and at the top margin, shrinking the whole page
5//! when its content reaches past the paper's right edge ("Scale content to paper width").
6//! A page longer than a sheet continues on the next, which starts at the first line of text
7//! or picture the sheet's foot would have cut. Rule lines and template art print across the
8//! paper's width; the page colour does not. Each sheet's footer names the section and
9//! numbers the sheet, counting from the first page printed, in Times New Roman.
10
11use crate::gpu::{Paper, page::PageScene};
12use crate::layout::{LayoutError, TextEngine};
13use draw::{Layer, Primitive, RenderError, Sheet};
14use onestore::page::{Page, text::Paragraph};
15use std::fmt;
16
17pub const LETTER: [f32; 2] = [612.0, 792.0];
18pub const A4: [f32; 2] = [595.276, 841.89];
19
20/// The paper's top and bottom margins, where no page content prints.
21const MARGIN: f32 = 36.0;
22/// How far from the paper's left edge the margin origin prints at full size.
23const LEFT: f32 = 72.0;
24/// The room OneNote leaves right of the content when it shrinks a page to the paper's width.
25const RIGHT: f32 = 9.0;
26/// Pixels per point pictures are decoded at, 300 a inch.
27const DENSITY: f32 = 300.0 / 72.0;
28
29/// How a page lies on its sheets.
30struct Pagination {
31 /// Paper points per page point.
32 scale: f32,
33 /// The page x at the paper's left edge.
34 left: f32,
35 /// Where each sheet starts down the page.
36 tops: Vec<f32>,
37 /// How much of the page a sheet holds, in page points.
38 band: f32,
39}
40
41impl Pagination {
42 /// Lays content covering `bounds`, whose `rows` (lines of text, pictures) no sheet
43 /// should cut, on `paper` from the page's `margin_origin`.
44 fn new(
45 bounds: [f32; 4],
46 rows: &[[f32; 2]],
47 margin_origin: [f32; 2],
48 paper: [f32; 2],
49 ) -> Self {
50 let left = margin_origin[0] - LEFT;
51 let scale = if bounds[2].is_finite() {
52 (paper[0] / (bounds[2] - left + RIGHT)).min(1.0)
53 } else {
54 1.0
55 };
56 let band = (paper[1] - 2.0 * MARGIN) / scale;
57 let first = if bounds[1].is_finite() {
58 margin_origin[1].min(bounds[1])
59 } else {
60 margin_origin[1]
61 };
62 let bottom = bounds[3].max(first);
63 let mut tops = vec![first];
64 let mut top = first;
65 while top + band < bottom {
66 // Moves the cut up to the top of whatever it would cross, until nothing shorter
67 // than a sheet crosses it.
68 let mut cut = top + band;
69 while let Some(row) = rows.iter().find(|[start, end]| {
70 *start < cut && *end > cut && *start > top + 1.0 && end - start <= band
71 }) {
72 cut = row[0];
73 }
74 tops.push(cut);
75 top = cut;
76 }
77 Self {
78 scale,
79 left,
80 tops,
81 band,
82 }
83 }
84
85 /// Each sheet's rows of the page, top and bottom: to where the next sheet starts, or
86 /// the paper's bottom margin on the last.
87 fn sheets(&self) -> impl Iterator<Item = [f32; 2]> + '_ {
88 self.tops.iter().enumerate().map(|(at, top)| {
89 [
90 *top,
91 self.tops.get(at + 1).copied().unwrap_or(top + self.band),
92 ]
93 })
94 }
95}
96
97#[derive(Debug)]
98pub enum PrintError {
99 Scene(crate::gpu::page::SceneError),
100 Layout(LayoutError),
101 Render(RenderError),
102}
103
104impl fmt::Display for PrintError {
105 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
106 match self {
107 Self::Scene(error) => write!(formatter, "The page could not be laid out: {error}"),
108 Self::Layout(error) => write!(formatter, "The footer could not be laid out: {error:?}"),
109 Self::Render(error) => write!(formatter, "The PDF could not be written: {error:?}"),
110 }
111 }
112}
113
114impl std::error::Error for PrintError {}
115
116/// `pages` in order as a PDF on `paper` (points), each on as many sheets as it takes, the
117/// footers naming `section`.
118pub fn pdf(
119 pages: Vec<Page>,
120 engine: &mut TextEngine,
121 paper: [f32; 2],
122 section: &str,
123) -> Result<Vec<u8>, PrintError> {
124 let title = pages
125 .first()
126 .map(|page| page.title.clone())
127 .unwrap_or_default();
128 let mut printed = Vec::with_capacity(pages.len());
129 for page in pages {
130 let (margin_origin, rules) = (page.margin_origin, page.rule_lines);
131 let mut scene = PageScene::new(page, engine).map_err(PrintError::Scene)?;
132 scene.settle(None, DENSITY, Paper::WHITE);
133 let (bounds, rows) = scene.printed_extent().map_err(PrintError::Scene)?;
134 let pagination = Pagination::new(bounds, &rows, margin_origin, paper);
135 printed.push((scene, pagination, margin_origin, rules));
136 }
137 let mut contents = Vec::with_capacity(printed.len());
138 for (scene, pagination, margin_origin, rules) in &printed {
139 let mut primitives = Vec::new();
140 scene
141 .append_primitives(&mut primitives, [0.0; 2], Paper::WHITE)
142 .map_err(PrintError::Scene)?;
143 let rules: Vec<_> = pagination
144 .sheets()
145 .map(|[top, _]| {
146 let right = pagination.left + paper[0] / pagination.scale;
147 crate::interaction::rule_primitives(
148 *rules,
149 *margin_origin,
150 [pagination.left, top, right, top + pagination.band],
151 0.0,
152 Paper::WHITE,
153 )
154 })
155 .collect();
156 contents.push((primitives, rules));
157 }
158 let format = onestore::document::Format {
159 font: Some("Times New Roman".into()),
160 font_size: Some(10.0),
161 ..Default::default()
162 };
163 let mut footers = Vec::new();
164 for (sheet, _) in printed
165 .iter()
166 .flat_map(|(_, pagination, ..)| pagination.sheets())
167 .enumerate()
168 {
169 let text = match section {
170 "" => format!("Page {}", sheet + 1),
171 section => format!("{section} Page {}", sheet + 1),
172 };
173 let layout = engine
174 .layout(&Paragraph::new(text, format.clone()), f32::MAX)
175 .map_err(PrintError::Layout)?;
176 footers.push(layout);
177 }
178 let footer_primitives: Vec<_> = footers
179 .iter()
180 .map(|layout| {
181 let (line, bounds) = layout.lines().next().expect("A laid-out footer has a line");
182 [Primitive::Text {
183 text: layout,
184 origin: [-line.metrics().advance / 2.0, -bounds.baseline],
185 clip: None,
186 ink: Paper::WHITE.ink,
187 }]
188 })
189 .collect();
190 let mut sheets = Vec::new();
191 let mut footer = footer_primitives.iter();
192 for ((_, pagination, ..), (primitives, rules)) in printed.iter().zip(&contents) {
193 let scale = pagination.scale;
194 for ([top, bottom], rules) in pagination.sheets().zip(rules) {
195 let origin = [-pagination.left * scale, MARGIN - top * scale];
196 // Rule lines run to the bottom margin, the page's content to where the next
197 // sheet takes it up.
198 let page = |primitives, bottom: f32| Layer {
199 scale,
200 origin,
201 clip: Some([0.0, MARGIN, paper[0], MARGIN + (bottom - top) * scale]),
202 backdrop: None,
203 round: None,
204 motion: None,
205 primitives,
206 };
207 // OneNote shrinks the footer with the page, towards the paper's bottom left.
208 let footer = Layer {
209 scale,
210 origin: [
211 paper[0] / 2.0 * scale,
212 paper[1] - 2.0 - (1.0 - scale) * 16.0,
213 ],
214 clip: None,
215 backdrop: None,
216 round: None,
217 motion: None,
218 primitives: footer.next().expect("Each sheet has a footer"),
219 };
220 sheets.push(Sheet {
221 size: paper,
222 layers: vec![
223 page(rules, top + pagination.band),
224 page(primitives, bottom),
225 footer,
226 ],
227 });
228 }
229 }
230 draw::pdf(&title, &sheets).map_err(PrintError::Render)
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236 use onestore::{RevisionIndex, Store, document::Document};
237 use std::collections::HashMap;
238
239 fn section(path: &str) -> Vec<Page> {
240 let bytes = std::fs::read(format!("{}/../../{path}", env!("CARGO_MANIFEST_DIR"))).unwrap();
241 let store = Store::parse(&bytes).unwrap();
242 let index = RevisionIndex::parse(&store).unwrap();
243 let document = Document::parse(&index).unwrap();
244 let mut spaces: Vec<_> = document
245 .pages()
246 .unwrap()
247 .into_iter()
248 .map(|(space, _)| space)
249 .collect();
250 spaces.dedup();
251 spaces
252 .into_iter()
253 .map(|space| Page::from_space(&document, space).unwrap())
254 .collect()
255 }
256
257 /// Each sheet's size and text, as a reader extracts it through the fonts' ToUnicode maps.
258 fn sheets(pdf: &[u8]) -> Vec<([f32; 2], String)> {
259 let find = |haystack: &[u8], needle: &[u8]| {
260 haystack
261 .windows(needle.len())
262 .position(|window| window == needle)
263 };
264 let mut objects: HashMap<u32, (&[u8], Vec<u8>)> = HashMap::new();
265 let mut rest = pdf;
266 while let Some(start) = find(rest, b" 0 obj\n") {
267 let number = rest[..start]
268 .rsplit(|byte| !byte.is_ascii_digit())
269 .next()
270 .unwrap();
271 let number: u32 = std::str::from_utf8(number).unwrap().parse().unwrap();
272 rest = &rest[start + 7..];
273 let end = find(rest, b"endobj").unwrap();
274 let object = &rest[..end];
275 let (dictionary, stream) = match find(object, b"stream\n") {
276 Some(at) => {
277 let data = &object[at + 7..find(object, b"\nendstream").unwrap()];
278 (
279 &object[..at],
280 miniz_oxide::inflate::decompress_to_vec_zlib(data).unwrap_or_default(),
281 )
282 }
283 None => (object, Vec::new()),
284 };
285 objects.insert(number, (dictionary, stream));
286 rest = &rest[end..];
287 }
288 let text = |bytes: &[u8]| String::from_utf8_lossy(bytes).into_owned();
289 let reference = |dictionary: &[u8], key: &str| -> Option<u32> {
290 let dictionary = text(dictionary);
291 let at = dictionary.find(&format!("{key} "))? + key.len() + 1;
292 dictionary[at..].split(' ').next()?.parse().ok()
293 };
294 let tree = objects
295 .values()
296 .find(|(dictionary, _)| text(dictionary).contains("/Type /Pages"))
297 .unwrap();
298 let kids = text(tree.0);
299 let kids = &kids[kids.find("/Kids [").unwrap() + 7..];
300 let kids: Vec<u32> = kids[..kids.find(']').unwrap()]
301 .split(" 0 R")
302 .filter_map(|kid| kid.trim().parse().ok())
303 .collect();
304 kids.into_iter()
305 .map(|page| {
306 let dictionary = text(objects[&page].0);
307 let media: Vec<f32> = dictionary[dictionary.find("/MediaBox [").unwrap() + 11..]
308 .split(']')
309 .next()
310 .unwrap()
311 .split_whitespace()
312 .map(|value| value.parse().unwrap())
313 .collect();
314 let mut maps: HashMap<String, HashMap<u16, String>> = HashMap::new();
315 for (at, _) in dictionary.match_indices("/F") {
316 let mut words = dictionary[at + 1..].split_whitespace();
317 let (Some(name), Some(font)) = (
318 words.next(),
319 words.next().and_then(|id| id.parse::<u32>().ok()),
320 ) else {
321 continue;
322 };
323 let Some(unicode) = reference(objects[&font].0, "/ToUnicode") else {
324 continue;
325 };
326 let cmap = text(&objects[&unicode].1);
327 let mut map = HashMap::new();
328 for line in cmap.lines().filter(|line| line.starts_with('<')) {
329 let hex: Vec<&str> = line
330 .split(['<', '>', ' '])
331 .filter(|part| !part.is_empty())
332 .collect();
333 let units: Vec<u16> = hex[1]
334 .as_bytes()
335 .chunks(4)
336 .map(|unit| {
337 u16::from_str_radix(std::str::from_utf8(unit).unwrap(), 16).unwrap()
338 })
339 .collect();
340 map.insert(
341 u16::from_str_radix(hex[0], 16).unwrap(),
342 String::from_utf16_lossy(&units),
343 );
344 }
345 maps.insert(name.to_owned(), map);
346 }
347 let content = &objects[&reference(objects[&page].0, "/Contents").unwrap()].1;
348 let (mut shown, mut font, mut name, mut at) =
349 (String::new(), String::new(), String::new(), 0);
350 while at < content.len() {
351 let byte = content[at];
352 at += 1;
353 let mut string = Vec::new();
354 match byte {
355 b'(' => {
356 while content[at] != b')' {
357 if content[at] == b'\\' {
358 at += 1;
359 if content[at].is_ascii_digit() {
360 let octal =
361 std::str::from_utf8(&content[at..at + 3]).unwrap();
362 string.push(u8::from_str_radix(octal, 8).unwrap());
363 at += 3;
364 continue;
365 }
366 string.push(match content[at] {
367 b'n' => b'\n',
368 b'r' => b'\r',
369 b't' => b'\t',
370 b'b' => 8,
371 b'f' => 12,
372 other => other,
373 });
374 } else {
375 string.push(content[at]);
376 }
377 at += 1;
378 }
379 at += 1;
380 }
381 b'<' if content[at] != b'<' => {
382 let end =
383 at + content[at..].iter().position(|byte| *byte == b'>').unwrap();
384 string = content[at..end]
385 .chunks(2)
386 .map(|pair| {
387 u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16)
388 .unwrap()
389 })
390 .collect();
391 at = end + 1;
392 }
393 b'/' => {
394 let end = at
395 + content[at..]
396 .iter()
397 .position(|byte| byte.is_ascii_whitespace())
398 .unwrap();
399 name = text(&content[at..end]);
400 at = end;
401 }
402 b'T' if content.get(at) == Some(&b'f') => font = name.clone(),
403 b'T' if content.get(at) == Some(&b'm') => shown.push('\n'),
404 _ => {}
405 }
406 if let Some(map) = maps.get(&font) {
407 for cid in string.chunks(2).filter(|pair| pair.len() == 2) {
408 if let Some(text) = map.get(&u16::from_be_bytes([cid[0], cid[1]])) {
409 shown.push_str(text);
410 }
411 }
412 }
413 }
414 ([media[2], media[3]], shown)
415 })
416 .collect()
417 }
418
419 /// A ligature's glyph reads back as the letters it joins.
420 #[test]
421 fn glyphs_read_back_as_their_text() {
422 let mut engine = TextEngine::default();
423 let format = onestore::document::Format {
424 font: Some("Calibri".into()),
425 font_size: Some(11.0),
426 ..Default::default()
427 };
428 let layout = engine
429 .layout(&Paragraph::new("office fish ffi".into(), format), f32::MAX)
430 .unwrap();
431 let primitives = [Primitive::Text {
432 text: &layout,
433 origin: [72.0, 72.0],
434 clip: None,
435 ink: Paper::WHITE.ink,
436 }];
437 let sheet = Sheet {
438 size: LETTER,
439 layers: vec![Layer {
440 scale: 1.0,
441 origin: [0.0; 2],
442 clip: None,
443 backdrop: None,
444 round: None,
445 motion: None,
446 primitives: &primitives,
447 }],
448 };
449 let pdf = draw::pdf("", &[sheet]).unwrap();
450 assert_eq!(sheets(&pdf)[0].1.trim(), "office fish ffi");
451 }
452
453 #[test]
454 fn sheets_break_between_lines() {
455 // Lines 20 points tall from 90 down; a Letter sheet holds 720 points of page.
456 let rows: Vec<[f32; 2]> = (0..60)
457 .map(|line| [90.0 + line as f32 * 20.0, 110.0 + line as f32 * 20.0])
458 .collect();
459 let pagination = Pagination::new([36.0, 14.4, 400.0, 1290.0], &rows, [36.0, 14.4], LETTER);
460 assert_eq!(pagination.scale, 1.0);
461 // 14.4 + 720 cuts the line from 730 to 750, which starts the next sheet.
462 assert_eq!(pagination.tops, [14.4, 730.0]);
463 let sheets: Vec<_> = pagination.sheets().collect();
464 assert_eq!(sheets, [[14.4, 730.0], [730.0, 1450.0]]);
465 }
466
467 #[test]
468 fn a_line_taller_than_a_sheet_is_cut() {
469 let pagination = Pagination::new(
470 [36.0, 14.4, 400.0, 1000.0],
471 &[[20.0, 1000.0]],
472 [36.0, 14.4],
473 LETTER,
474 );
475 assert_eq!(pagination.tops, [14.4, 734.4]);
476 }
477
478 /// OneNote's scales for pages whose content ends 866.7, 581.8 and 1221.4 points across,
479 /// on its 612.36-point Letter sheet (`corpus/print`).
480 #[test]
481 fn wide_pages_shrink_to_the_paper_as_onenote_shrinks_them() {
482 for (right, onenote) in [
483 (866.7, 0.67167),
484 (581.8, 0.978),
485 (1221.4, 0.48361),
486 (400.0, 1.0),
487 ] {
488 let scale = Pagination::new(
489 [36.0, 14.4, right, 100.0],
490 &[],
491 [36.0, 14.4],
492 [612.36, 790.92],
493 )
494 .scale;
495 assert!(
496 (scale - onenote).abs() < 0.002,
497 "{right}: {scale} for {onenote}"
498 );
499 }
500 }
501
502 /// `corpus/print`: the section OneNote 2010 saved as `native/section.pdf`, on the
503 /// same sheets with the same text.
504 #[test]
505 fn a_section_prints_on_onenotes_sheets() {
506 let pdf = pdf(
507 section("corpus/print/native/Print.one"),
508 &mut TextEngine::default(),
509 LETTER,
510 "Print",
511 )
512 .unwrap();
513 let sheets = sheets(&pdf);
514 assert_eq!(sheets.len(), 5);
515 for (at, (size, text)) in sheets.iter().enumerate() {
516 assert_eq!(*size, LETTER);
517 assert!(text.contains(&format!("Print Page {}", at + 1)), "{text}");
518 }
519 let [first, second] = [&sheets[0].1, &sheets[1].1];
520 assert!(first.contains("Printing test"));
521 assert!(
522 first.contains(
523 "Line 37 of the long outline, with enough words to show wrapping where the"
524 )
525 );
526 assert!(first.contains("outline is narrow. Searchable-37"));
527 assert!(first.contains("Far right outline past the paper width"));
528 assert!(!first.contains("Searchable-38"));
529 assert!(second.contains("Line 38 of the long outline"));
530 assert!(second.contains("Searchable-70"));
531 assert!(!second.contains("Searchable-37\n"));
532 assert!(sheets[2].1.contains("Short second page"));
533 }
534
535 /// `corpus/page-background`'s pages, ruled and with template art, take the twelve
536 /// sheets OneNote 2010 prints them on (`corpus/print/native/rules-section.pdf`), the
537 /// art reaching past the Very Large Grid page's first.
538 #[test]
539 fn ruled_pages_and_art_print_on_onenotes_sheets() {
540 let pdf = pdf(
541 section("corpus/page-background/candidate/Rules.one"),
542 &mut TextEngine::default(),
543 LETTER,
544 "Rules",
545 )
546 .unwrap();
547 let sheets = sheets(&pdf);
548 assert_eq!(sheets.len(), 12);
549 assert!(sheets[8].1.contains("VeryLargeGrid"));
550 assert!(sheets[9].1.contains("Rules Page 10"));
551 assert!(!sheets[9].1.contains("VeryLargeGrid"));
552 }
553}
crates/canvas/tests/links_equations.rs+187
...@@ -797,3 +797,190 @@ fn equations_draw_inline_and_url_text_shows_as_a_link() {...@@ -797,3 +797,190 @@ fn equations_draw_inline_and_url_text_shows_as_a_link() {
797 assert_eq!(canvas::editor::shown_urls(&url), vec![5..22]);797 assert_eq!(canvas::editor::shown_urls(&url), vec![5..22]);
798 assert!(url.spans()[0].format.hyperlink.is_none());798 assert!(url.spans()[0].format.hyperlink.is_none());
799}799}
800
801/// Selections reaching into equation objects, and Enter in a placeholder and in a limit, act
802/// as OneNote 2010's equation editor acted on the same page (`corpus/equation-select`): an
803/// end inside an object the other end is outside takes the whole object, and across
804/// paragraphs the end of the paragraph with it; a placeholder is taken whole; Enter inside a
805/// limit makes its argument an equation array. Each action saves as one revision.
806#[test]
807fn selections_into_equation_objects_take_them_whole() {
808 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
809 let source =
810 std::fs::read(root.join("corpus/math-edit/native-editor-4/notebook/links.one")).unwrap();
811 let (space, page) = pages(&source).remove(0);
812 let mut engine = TextEngine::default();
813 let engine = &mut engine;
814 let mut editor = CanvasEditor::from_page(page, engine).unwrap();
815 let body = editor
816 .outlines()
817 .iter()
818 .find(|outline| !outline.title)
819 .unwrap()
820 .id;
821 editor.focus_outline(body).unwrap();
822 let texts = |editor: &CanvasEditor| -> Vec<String> {
823 editor
824 .active_outline()
825 .document()
826 .paragraphs()
827 .map(|text| text.text().to_owned())
828 .collect()
829 };
830 let original = texts(&editor);
831 let find = |editor: &CanvasEditor, needle: &str| {
832 let paragraph = texts(editor)
833 .iter()
834 .position(|text| text.contains(needle))
835 .unwrap();
836 let text = editor
837 .active_outline()
838 .document()
839 .paragraphs()
840 .nth(paragraph)
841 .unwrap()
842 .clone();
843 TextPosition {
844 paragraph,
845 offset: text
846 .utf16_offset(text.text().find(needle).unwrap())
847 .unwrap(),
848 }
849 };
850 let arena = Arena::default();
851 let mut section = Section::open(&arena, source).unwrap();
852 let mut save = |editor: &mut CanvasEditor| {
853 let ops = editor
854 .take_ops()
855 .unwrap()
856 .into_iter()
857 .map(|op| onestore::op::Op::Page { space, op })
858 .collect();
859 section
860 .apply(
861 "Author",
862 &onestore::op::Edit {
863 at: 134_000_000_000_000_000,
864 ops,
865 },
866 )
867 .unwrap();
868 section.seal().unwrap().unwrap();
869 };
870 let delete = |editor: &mut CanvasEditor, engine: &mut TextEngine, from, to| {
871 editor.select([from, to].into()).unwrap();
872 assert!(editor.delete(engine, true).unwrap());
873 };
874
875 // Enter in "Type equation here." leaves the placeholder's paragraph empty above a new one.
876 let placeholder = find(&editor, "equation here");
877 select(&mut editor, placeholder.paragraph, placeholder.offset..placeholder.offset);
878 editor.enter(engine, false).unwrap();
879 save(&mut editor);
880 let after = texts(&editor);
881 assert_eq!(after[placeholder.paragraph..=placeholder.paragraph + 1], ["", ""]);
882 assert_eq!(caret(&editor).paragraph, placeholder.paragraph + 1);
883
884 // From inside a square root into a cube root: both paragraphs go whole.
885 let (from, to) = (find(&editor, "\u{fdef}+1"), find(&editor, "3\u{fdee}𝑥"));
886 let to = TextPosition {
887 offset: to.offset + 4,
888 ..to
889 };
890 delete(&mut editor, engine, from, to);
891 save(&mut editor);
892 let mut expected = after.clone();
893 expected.drain(from.paragraph..=to.paragraph);
894 assert_eq!(texts(&editor), expected);
895
896 // From after a₁ into a sum: the sum goes whole with its paragraph's end, joining the
897 // integral below it.
898 let from = find(&editor, "+\u{fdd0}𝑏");
899 let to = find(&editor, "𝑖 \u{fdef}");
900 let to = TextPosition {
901 offset: to.offset + 2,
902 ..to
903 };
904 delete(&mut editor, engine, from, to);
905 save(&mut editor);
906 assert_eq!(
907 texts(&editor)[from.paragraph],
908 "\u{fdd0}𝑎\u{fdee}1\u{fdef}\u{fdd0}0\u{fdee}1\u{fdee}𝑥 𝑑𝑥 \u{fdef}"
909 );
910
911 // Enter inside a limit's argument makes it an equation array.
912 let limit = find(&editor, "∞");
913 select(&mut editor, limit.paragraph, limit.offset..limit.offset);
914 editor.enter(engine, false).unwrap();
915 save(&mut editor);
916 assert!(texts(&editor)[limit.paragraph].contains("𝑛→\u{fdee}∞\u{fdef}"));
917
918 // Text around equations: from a fraction's numerator into a root, both taken whole.
919 let end = texts(&editor).len() - 1;
920 let offset = editor
921 .active_outline()
922 .document()
923 .paragraphs()
924 .nth(end)
925 .unwrap()
926 .utf16_offset(texts(&editor)[end].len())
927 .unwrap();
928 select(&mut editor, end, offset..offset);
929 for (before, linear, after) in [("One ", "a/b+c ", " two"), ("Three ", "\\sqrt x+y ", " four")] {
930 typed(&mut editor, engine, before);
931 editor.insert_equation(engine).unwrap();
932 typed(&mut editor, engine, linear);
933 editor
934 .move_selection(engine, draw::edit::Movement::Right, false)
935 .unwrap();
936 typed(&mut editor, engine, after);
937 line(&mut editor, engine);
938 }
939 save(&mut editor);
940 let from = find(&editor, "𝑎\u{fdee}𝑏\u{fdef}+𝑐");
941 let to = find(&editor, "𝑥\u{fdef}+𝑦");
942 let (from, to) = (
943 TextPosition {
944 offset: from.offset + 2,
945 ..from
946 },
947 TextPosition {
948 offset: to.offset + 2,
949 ..to
950 },
951 );
952 delete(&mut editor, engine, from, to);
953 save(&mut editor);
954 let joined = &texts(&editor)[from.paragraph];
955 assert!(joined.starts_with("One +𝑦"), "{joined:?}");
956 assert!(joined.ends_with(" four"), "{joined:?}");
957
958 let written = section.image();
959 let (_, stored) = pages(&written).remove(0);
960 let stored: Vec<String> = stored
961 .objects
962 .iter()
963 .filter_map(|object| match object {
964 PageObject::Outline(outline) if !outline.title => Some(outline),
965 _ => None,
966 })
967 .flat_map(|outline| &outline.paragraphs)
968 .filter_map(|paragraph| paragraph.text())
969 .map(|text| text.text.text().to_owned())
970 .collect();
971 assert_eq!(stored, texts(&editor));
972 if let Some(directory) = std::env::var_os("CANVAS_EQUATION_SELECT_EXPORT") {
973 let directory = std::path::PathBuf::from(directory);
974 std::fs::create_dir(&directory).unwrap();
975 std::fs::write(directory.join("links.one"), &written).unwrap();
976 let file_id = Store::parse(&written).unwrap().header.file_id;
977 std::fs::write(
978 directory.join("Open Notebook.onetoc2"),
979 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("links.one", file_id)])
980 .unwrap(),
981 )
982 .unwrap();
983 }
984 while editor.undo(engine).unwrap() {}
985 assert_eq!(texts(&editor), original);
986}
crates/canvas/tests/picture_paste.rs created+196
...@@ -0,0 +1,196 @@
1//! Pictures pasted or inserted through the editor, placed as OneNote 2010 places them (lab,
2//! 2026-09-30): at a caret in text the paragraph splits around the picture, and at a caret
3//! on blank page the picture lies on the page there with the caret on the grid row below.
4//! Each is one edit and one undo step. `CANVAS_PICTURE_EXPORT` names a directory receiving
5//! the section as a notebook, for a cold reopen in OneNote 2010.
6
7use canvas::{
8 document::TextPosition,
9 editor::{CanvasEditor, Selection},
10 layout::TextEngine,
11};
12use onestore::{
13 Arena, Section, Store,
14 document::Layout,
15 op::{Edit, Op},
16 page::{Image, PageObject, ParagraphContent},
17};
18use parley::Affinity;
19
20fn picture(size: [f32; 2]) -> Image {
21 let mut png = Vec::new();
22 image::RgbaImage::from_pixel(200, 100, image::Rgba([30, 144, 255, 255]))
23 .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
24 .unwrap();
25 Image {
26 id: onestore::page::text::new_id().unwrap(),
27 layout: Layout {
28 max_width: Some(size[0]),
29 max_height: Some(size[1]),
30 ..Default::default()
31 },
32 size: Some(size),
33 bytes: Some(png.into()),
34 display: None,
35 alt: None,
36 background: false,
37 printout: None,
38 }
39}
40
41fn caret(paragraph: usize, offset: u32) -> Selection {
42 let position = TextPosition { paragraph, offset };
43 Selection {
44 positions: [position; 2],
45 affinities: [Affinity::Downstream; 2],
46 }
47}
48
49/// The body outline's paragraphs: text as itself, a picture as its size.
50fn body(editor: &CanvasEditor) -> Vec<String> {
51 let page = editor.page().unwrap();
52 let outline = page
53 .objects
54 .iter()
55 .find_map(|object| match object {
56 PageObject::Outline(outline) if !outline.title => Some(outline),
57 _ => None,
58 })
59 .unwrap();
60 outline
61 .paragraphs
62 .iter()
63 .map(|paragraph| match &paragraph.content {
64 ParagraphContent::Text(text) => text.text.text().to_owned(),
65 ParagraphContent::Image(image) => format!("{:?}", image.size.unwrap()),
66 _ => panic!(),
67 })
68 .collect()
69}
70
71fn page_pictures(editor: &CanvasEditor) -> Vec<Image> {
72 editor
73 .page()
74 .unwrap()
75 .objects
76 .into_iter()
77 .filter_map(|object| match object {
78 PageObject::Image(image) if !image.background => Some(image),
79 _ => None,
80 })
81 .collect()
82}
83
84#[test]
85fn pasted_pictures_split_the_text_or_lie_on_the_page_as_onenote_places_them() {
86 let source = onestore::create_section("pictures.one", "Pasted pictures", "Author").unwrap();
87 let arena = Arena::default();
88 let mut section = Section::open(&arena, source.clone()).unwrap();
89 let (space, ..) = section.pages().unwrap()[0].clone();
90 let mut engine = TextEngine::default();
91 let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap();
92 let mut at = 133_000_000_000_000_000;
93 let mut store = |editor: &mut CanvasEditor| {
94 let ops = editor.take_ops().unwrap();
95 assert!(!ops.is_empty());
96 at += 10_000_000;
97 let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect();
98 section.apply("Author", &Edit { at, ops }).unwrap();
99 };
100 let text = editor
101 .outlines()
102 .iter()
103 .find(|outline| !outline.title)
104 .unwrap()
105 .id;
106 editor.focus_outline(text).unwrap();
107
108 // Within text, the caret starting the second half.
109 editor.select(caret(0, 6)).unwrap();
110 editor
111 .insert_picture(&mut engine, picture([150.0, 75.0]))
112 .unwrap();
113 store(&mut editor);
114 assert_eq!(body(&editor), ["Pasted", "[150.0, 75.0]", " pictures"]);
115 assert_eq!(
116 editor.selection().positions,
117 [TextPosition {
118 paragraph: 1,
119 offset: 0
120 }; 2]
121 );
122 editor.undo(&mut engine).unwrap();
123 store(&mut editor);
124 assert_eq!(body(&editor), ["Pasted pictures"]);
125
126 // At the end, an empty paragraph follows for the caret; at an empty paragraph's start
127 // the picture takes its place.
128 editor.select(caret(0, 15)).unwrap();
129 editor
130 .insert_picture(&mut engine, picture([150.0, 75.0]))
131 .unwrap();
132 store(&mut editor);
133 editor
134 .insert_picture(&mut engine, picture([200.0, 100.0]))
135 .unwrap();
136 store(&mut editor);
137 assert_eq!(
138 body(&editor),
139 ["Pasted pictures", "[150.0, 75.0]", "[200.0, 100.0]", ""]
140 );
141
142 // On blank page: at the caret, the caret moving to the grid row below (OneNote put a
143 // 75 pt picture at y 266.4 and the caret at 356.4, a 72 pt one at 248.4 and 338.4).
144 for (y, height, below) in [(266.4, 75.0, 356.4), (248.4 + 180.0, 72.0, 338.4 + 180.0)] {
145 editor.place_caret(&mut engine, [270.0, y], 468.0).unwrap();
146 editor
147 .insert_picture(&mut engine, picture([150.0, height]))
148 .unwrap();
149 store(&mut editor);
150 let placed = page_pictures(&editor).pop().unwrap();
151 assert_eq!([placed.layout.x, placed.layout.y], [Some(270.0), Some(y)]);
152 assert_eq!(editor.active_outline().origin(), [270.0, below]);
153 }
154 editor.insert(&mut engine, "below").unwrap();
155 store(&mut editor);
156 editor.undo(&mut engine).unwrap();
157 editor.undo(&mut engine).unwrap();
158 store(&mut editor);
159 assert_eq!(page_pictures(&editor).len(), 1);
160
161 let mut image = source;
162 section.seal().unwrap().unwrap().apply(&mut image).unwrap();
163 let arena = Arena::default();
164 let reopened = Section::open(&arena, image.clone()).unwrap();
165 let page = reopened.page(space).unwrap();
166 let stored: Vec<_> = page
167 .objects
168 .iter()
169 .filter_map(|object| match object {
170 PageObject::Image(image) => Some(image),
171 _ => None,
172 })
173 .collect();
174 assert_eq!(stored.len(), 1);
175 assert_eq!(
176 [stored[0].layout.x, stored[0].layout.y],
177 [Some(270.0), Some(266.4)]
178 );
179 assert_eq!(*stored[0], page_pictures(&editor)[0]);
180
181 if let Some(directory) = std::env::var_os("CANVAS_PICTURE_EXPORT") {
182 let directory = std::path::PathBuf::from(directory);
183 std::fs::create_dir_all(&directory).unwrap();
184 std::fs::write(directory.join("pictures.one"), &image).unwrap();
185 let file_id = Store::parse(&image).unwrap().header.file_id;
186 std::fs::write(
187 directory.join("Open Notebook.onetoc2"),
188 onestore::create_table_of_contents(
189 "Open Notebook.onetoc2",
190 &[("pictures.one", file_id)],
191 )
192 .unwrap(),
193 )
194 .unwrap();
195 }
196}
crates/draw/Cargo.toml+5
...@@ -11,6 +11,8 @@ default = ["wgpu"]...@@ -11,6 +11,8 @@ default = ["wgpu"]
11# Windows 7 has no Direct3D 12.11# Windows 7 has no Direct3D 12.
12render = ["dep:base64", "dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:swash"]12render = ["dep:base64", "dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:swash"]
13wgpu = ["render", "dep:wgpu"]13wgpu = ["render", "dep:wgpu"]
14# Writes layers as PDF pages, text as real text in subset fonts.
15pdf = ["render", "dep:miniz_oxide", "dep:pdf-writer", "dep:subsetter"]
1416
15[dependencies]17[dependencies]
16parley.workspace = true18parley.workspace = true
...@@ -19,7 +21,10 @@ base64 = { version = "0.22.1", optional = true }...@@ -19,7 +21,10 @@ base64 = { version = "0.22.1", optional = true }
19bytemuck = { version = "1.25", features = ["derive"], optional = true }21bytemuck = { version = "1.25", features = ["derive"], optional = true }
20image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png", "tiff"], optional = true }22image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png", "tiff"], optional = true }
21linebender_resource_handle = { version = "0.1.1", optional = true }23linebender_resource_handle = { version = "0.1.1", optional = true }
24miniz_oxide = { version = "0.8", optional = true }
25pdf-writer = { version = "0.15", optional = true }
22roxmltree = { version = "0.21", optional = true }26roxmltree = { version = "0.21", optional = true }
27subsetter = { version = "0.2.6", default-features = false, optional = true }
23swash = { version = "0.2.10", optional = true }28swash = { version = "0.2.10", optional = true }
24wgpu = { workspace = true, optional = true }29wgpu = { workspace = true, optional = true }
2530
crates/draw/src/render.rs+5
...@@ -3,6 +3,8 @@ mod dual;...@@ -3,6 +3,8 @@ mod dual;
3#[cfg(any(windows, not(feature = "wgpu")))]3#[cfg(any(windows, not(feature = "wgpu")))]
4mod gl;4mod gl;
5mod icon;5mod icon;
6#[cfg(feature = "pdf")]
7mod pdf;
6mod text;8mod text;
7#[cfg(feature = "wgpu")]9#[cfg(feature = "wgpu")]
8mod translucent;10mod translucent;
...@@ -20,6 +22,8 @@ pub use backend::Target;...@@ -20,6 +22,8 @@ pub use backend::Target;
20#[cfg(all(feature = "wgpu", windows))]22#[cfg(all(feature = "wgpu", windows))]
21pub use gl::Target as GlTarget;23pub use gl::Target as GlTarget;
22pub use icon::{Palette, picture_icon};24pub use icon::{Palette, picture_icon};
25#[cfg(feature = "pdf")]
26pub use pdf::{Sheet, pdf};
23pub use text::{Decoration, Glyph, GlyphRun, Glyphs, paint_parley_run};27pub use text::{Decoration, Glyph, GlyphRun, Glyphs, paint_parley_run};
24#[cfg(feature = "wgpu")]28#[cfg(feature = "wgpu")]
25pub use translucent::Translucent;29pub use translucent::Translucent;
...@@ -1736,6 +1740,7 @@ mod tests {...@@ -1736,6 +1740,7 @@ mod tests {
1736 if let PositionedLayoutItem::GlyphRun(run) = item {1740 if let PositionedLayoutItem::GlyphRun(run) = item {
1737 paint_parley_run(1741 paint_parley_run(
1738 &run,1742 &run,
1743 "",
1739 metrics.baseline,1744 metrics.baseline,
1740 0.0,1745 0.0,
1741 [metrics.block_min_coord, metrics.line_height],1746 [metrics.block_min_coord, metrics.line_height],
crates/draw/src/render/pdf.rs created+1004
...@@ -0,0 +1,1004 @@
1//! Layers written as the sheets of a PDF: glyphs as text in subset fonts, which viewers
2//! select, search and copy, shapes and ink as vector paths, pictures and icons as images.
3
4use super::{
5 GlyphRun, Layer, PathStyle, Primitive, RasterImage, RenderError, Stroke, icon, srgb_byte,
6};
7use parley::fontique::Blob;
8use pdf_writer::{
9 Content, Filter, Finish, Name, Pdf, Rect, Ref, Str, TextStr,
10 types::{
11 BlendMode, CidFontType, FontFlags, LineCapStyle, LineJoinStyle, SystemInfo,
12 TextRenderingMode, UnicodeCmap,
13 },
14};
15use std::collections::{BTreeMap, HashMap, btree_map::Entry};
16use swash::{
17 FontRef, StringId,
18 scale::{Render, ScaleContext, Source, StrikeWith, image::Content as Pixels},
19 tag_from_bytes,
20 zeno::{Command, PathData},
21};
22
23/// One sheet of paper.
24pub struct Sheet<'a> {
25 /// Width and height in points.
26 pub size: [f32; 2],
27 /// Layers whose device units are points from the sheet's top left.
28 pub layers: Vec<Layer<'a>>,
29}
30
31/// `sheets` as a PDF document titled `title`.
32pub fn pdf(title: &str, sheets: &[Sheet<'_>]) -> Result<Vec<u8>, RenderError> {
33 let mut document = Document {
34 pdf: Pdf::new(),
35 next: 1,
36 fonts: Vec::new(),
37 images: HashMap::new(),
38 states: HashMap::new(),
39 scale: ScaleContext::new(),
40 rows: None,
41 };
42 let catalog = document.reserve();
43 let tree = document.reserve();
44 let pages: Vec<_> = sheets
45 .iter()
46 .map(|sheet| {
47 let page = document.reserve();
48 let content = document.reserve();
49 let used = document.sheet(sheet, content)?;
50 Ok((page, used))
51 })
52 .collect::<Result<_, RenderError>>()?;
53 for ((page, used), sheet) in pages.iter().zip(sheets) {
54 let mut writer = document.pdf.page(*page);
55 writer
56 .parent(tree)
57 .media_box(Rect::new(0.0, 0.0, sheet.size[0], sheet.size[1]))
58 .contents(used.content);
59 let mut resources = writer.resources();
60 let mut fonts = resources.fonts();
61 for font in &used.fonts {
62 let reference = document.fonts[*font].reference;
63 fonts.pair(Name(format!("F{font}").as_bytes()), reference);
64 }
65 fonts.finish();
66 let mut images = resources.x_objects();
67 for image in &used.images {
68 images.pair(Name(format!("I{}", image.get()).as_bytes()), *image);
69 }
70 images.finish();
71 let mut states = resources.ext_g_states();
72 for state in &used.states {
73 states.pair(Name(format!("G{}", state.get()).as_bytes()), *state);
74 }
75 }
76 document
77 .pdf
78 .pages(tree)
79 .kids(pages.iter().map(|(page, _)| *page))
80 .count(pages.len() as i32);
81 document.pdf.catalog(catalog).pages(tree);
82 let info = document.reserve();
83 document
84 .pdf
85 .document_info(info)
86 .title(TextStr(title))
87 .producer(TextStr("Snowbound"));
88 let fonts = std::mem::take(&mut document.fonts);
89 for font in fonts.into_iter().filter(|font| font.outlined) {
90 document.write_font(font)?;
91 }
92 Ok(document.pdf.finish())
93}
94
95struct Document {
96 pdf: Pdf,
97 next: i32,
98 fonts: Vec<Font>,
99 /// Image XObjects by what they show.
100 images: HashMap<ImageKey, Ref>,
101 /// Graphics states by fill and stroke opacity bits and whether they multiply.
102 states: HashMap<(u32, bool), Ref>,
103 scale: ScaleContext,
104 /// The top and bottom of the layer drawn, in its units, where it clips; lines of text
105 /// outside it are left out, so that no sheet's text layer holds text it does not show.
106 rows: Option<[f32; 2]>,
107}
108
109#[derive(Hash, PartialEq, Eq)]
110enum ImageKey {
111 Raster(u64),
112 Icon {
113 sources: usize,
114 size: u32,
115 tint: [u32; 3],
116 palette: [Option<[u32; 3]>; 3],
117 },
118 Glyph {
119 font: u64,
120 index: u32,
121 glyph: u32,
122 size: u32,
123 color: [u32; 3],
124 },
125}
126
127/// A face the document shows glyphs of.
128struct Font {
129 reference: Ref,
130 data: Blob<u8>,
131 index: u32,
132 /// Whether its glyphs are outlines the PDF embeds; others draw as pictures.
133 outlined: bool,
134 glyphs: subsetter::GlyphRemapper,
135 /// The text each subset glyph shows where it first appeared.
136 text: BTreeMap<u16, String>,
137}
138
139/// What a sheet's content refers to.
140struct Used {
141 content: Ref,
142 fonts: Vec<usize>,
143 images: Vec<Ref>,
144 states: Vec<Ref>,
145}
146
147impl Used {
148 fn add<T: PartialEq>(list: &mut Vec<T>, item: T) {
149 if !list.contains(&item) {
150 list.push(item);
151 }
152 }
153}
154
155fn compress(data: &[u8]) -> Vec<u8> {
156 miniz_oxide::deflate::compress_to_vec_zlib(data, 6)
157}
158
159/// sRGB components of a linear colour.
160fn encoded(color: [f32; 4]) -> [f32; 3] {
161 [color[0], color[1], color[2]].map(|value| f32::from(srgb_byte(value.clamp(0.0, 1.0))) / 255.0)
162}
163
164/// Bézier handle length for a quarter circle of radius one.
165const KAPPA: f32 = 0.552_284_8;
166
167/// Appends an ellipse's quarter arcs around `rect`'s corners of radii `radius`, as a
168/// closed path.
169fn rounded(content: &mut Content, [left, top, right, bottom]: [f32; 4], radius: [f32; 2]) {
170 let rx = radius[0].clamp(0.0, (right - left) / 2.0);
171 let ry = radius[1].clamp(0.0, (bottom - top) / 2.0);
172 if rx <= 0.0 || ry <= 0.0 {
173 content.rect(left, top, right - left, bottom - top);
174 return;
175 }
176 let [kx, ky] = [rx * KAPPA, ry * KAPPA];
177 content.move_to(left + rx, top);
178 content.line_to(right - rx, top);
179 content.cubic_to(right - rx + kx, top, right, top + ry - ky, right, top + ry);
180 content.line_to(right, bottom - ry);
181 content.cubic_to(
182 right,
183 bottom - ry + ky,
184 right - rx + kx,
185 bottom,
186 right - rx,
187 bottom,
188 );
189 content.line_to(left + rx, bottom);
190 content.cubic_to(
191 left + rx - kx,
192 bottom,
193 left,
194 bottom - ry + ky,
195 left,
196 bottom - ry,
197 );
198 content.line_to(left, top + ry);
199 content.cubic_to(left, top + ry - ky, left + rx - kx, top, left + rx, top);
200 content.close_path();
201}
202
203fn circle(content: &mut Content, [x, y]: [f32; 2], radius: f32) {
204 rounded(
205 content,
206 [x - radius, y - radius, x + radius, y + radius],
207 [radius; 2],
208 );
209}
210
211/// The text each of `count` glyphs shows: a cluster's text goes to its first glyph, and a
212/// ligature's later clusters to the glyph before them.
213fn glyph_text(
214 text: &str,
215 clusters: &mut dyn Iterator<Item = (std::ops::Range<usize>, usize)>,
216 count: usize,
217) -> Vec<String> {
218 let mut texts = vec![String::new(); count];
219 let mut at = 0_usize;
220 for (range, glyphs) in clusters {
221 let shown = text.get(range).unwrap_or_default();
222 if glyphs == 0 {
223 if let Some(previous) = at.checked_sub(1).and_then(|at| texts.get_mut(at)) {
224 previous.push_str(shown);
225 }
226 continue;
227 }
228 if let Some(first) = texts.get_mut(at) {
229 first.push_str(shown);
230 }
231 at += glyphs;
232 }
233 texts
234}
235
236impl Document {
237 fn reserve(&mut self) -> Ref {
238 let reference = Ref::new(self.next);
239 self.next += 1;
240 reference
241 }
242
243 fn sheet(&mut self, sheet: &Sheet<'_>, content_ref: Ref) -> Result<Used, RenderError> {
244 let mut used = Used {
245 content: content_ref,
246 fonts: Vec::new(),
247 images: Vec::new(),
248 states: Vec::new(),
249 };
250 let mut content = Content::new();
251 // Layers measure down from the top left, as the renderer's do.
252 content.transform([1.0, 0.0, 0.0, -1.0, 0.0, sheet.size[1]]);
253 for layer in &sheet.layers {
254 content.save_state();
255 if let Some([left, top, right, bottom]) = layer.clip {
256 content
257 .rect(left, top, right - left, bottom - top)
258 .clip_nonzero()
259 .end_path();
260 }
261 self.rows = layer
262 .clip
263 .map(|clip| [clip[1], clip[3]].map(|y| (y - layer.origin[1]) / layer.scale));
264 content.transform([
265 layer.scale,
266 0.0,
267 0.0,
268 layer.scale,
269 layer.origin[0],
270 layer.origin[1],
271 ]);
272 for primitive in layer.primitives {
273 self.primitive(&mut content, &mut used, primitive)?;
274 }
275 content.restore_state();
276 }
277 let data = compress(&content.finish());
278 self.pdf
279 .stream(content_ref, &data)
280 .filter(Filter::FlateDecode);
281 Ok(used)
282 }
283
284 /// Sets the fill, or with `stroke` the stroke, to `color` with its opacity.
285 fn paint(&mut self, content: &mut Content, used: &mut Used, color: [f32; 4], stroke: bool) {
286 let [red, green, blue] = encoded(color);
287 if stroke {
288 content.set_stroke_rgb(red, green, blue);
289 } else {
290 content.set_fill_rgb(red, green, blue);
291 }
292 if color[3] < 1.0 {
293 let state = self.state(color[3], false);
294 Used::add(&mut used.states, state);
295 content.set_parameters(Name(format!("G{}", state.get()).as_bytes()));
296 }
297 }
298
299 fn state(&mut self, opacity: f32, multiply: bool) -> Ref {
300 let key = (opacity.clamp(0.0, 1.0).to_bits(), multiply);
301 if let Some(state) = self.states.get(&key) {
302 return *state;
303 }
304 let state = self.reserve();
305 let mut writer = self.pdf.ext_graphics(state);
306 writer
307 .non_stroking_alpha(opacity.clamp(0.0, 1.0))
308 .stroking_alpha(opacity.clamp(0.0, 1.0));
309 if multiply {
310 writer.blend_mode(BlendMode::Multiply);
311 }
312 writer.finish();
313 self.states.insert(key, state);
314 state
315 }
316
317 fn primitive(
318 &mut self,
319 content: &mut Content,
320 used: &mut Used,
321 primitive: &Primitive<'_>,
322 ) -> Result<(), RenderError> {
323 match primitive {
324 Primitive::Text {
325 text,
326 origin,
327 clip,
328 ink,
329 } => {
330 content.save_state();
331 if let Some([left, top, right, bottom]) = *clip {
332 content
333 .rect(left, top, right - left, bottom - top)
334 .clip_nonzero()
335 .end_path();
336 }
337 text.runs(&mut |run| self.run(content, used, run, *origin, *ink))?;
338 content.restore_state();
339 }
340 Primitive::Icon {
341 sources,
342 origin,
343 size,
344 tint,
345 palette,
346 } => {
347 // Icons are small art; eight pixels a point keeps them sharp on paper.
348 let side = (size * 8.0).ceil().clamp(16.0, 512.0) as u32;
349 let key = ImageKey::Icon {
350 sources: sources.as_ptr() as usize,
351 size: side,
352 tint: [tint[0], tint[1], tint[2]].map(f32::to_bits),
353 palette: palette.bits(),
354 };
355 let image = match self.images.get(&key) {
356 Some(image) => *image,
357 None => {
358 let raster =
359 icon::rasterize(sources, side, [tint[0], tint[1], tint[2]], palette);
360 let image = self.image([side; 2], &raster.data, false);
361 self.images.insert(key, image);
362 image
363 }
364 };
365 content.save_state();
366 if tint[3] < 1.0 {
367 let state = self.state(tint[3], false);
368 Used::add(&mut used.states, state);
369 content.set_parameters(Name(format!("G{}", state.get()).as_bytes()));
370 }
371 self.place(
372 content,
373 used,
374 image,
375 [origin[0], origin[1], origin[0] + size, origin[1] + size],
376 );
377 content.restore_state();
378 }
379 Primitive::Path {
380 data,
381 origin,
382 style,
383 colors,
384 } => {
385 let fill = matches!(style, PathStyle::Fill);
386 let (PathStyle::Fill | PathStyle::Stroke(_)) = style else {
387 return Ok(());
388 };
389 content.save_state();
390 content.transform([1.0, 0.0, 0.0, 1.0, origin[0], origin[1]]);
391 let average = std::array::from_fn(|at| (colors[0][at] + colors[1][at]) / 2.0);
392 self.paint(content, used, average, !fill);
393 let mut last = [0.0_f32; 2];
394 for command in data.commands() {
395 match command {
396 Command::MoveTo(to) => {
397 content.move_to(to.x, to.y);
398 last = [to.x, to.y];
399 }
400 Command::LineTo(to) => {
401 content.line_to(to.x, to.y);
402 last = [to.x, to.y];
403 }
404 Command::CurveTo(one, two, to) => {
405 content.cubic_to(one.x, one.y, two.x, two.y, to.x, to.y);
406 last = [to.x, to.y];
407 }
408 Command::QuadTo(control, to) => {
409 let near =
410 |from: f32, control: f32| from + (control - from) * 2.0 / 3.0;
411 content.cubic_to(
412 near(last[0], control.x),
413 near(last[1], control.y),
414 near(to.x, control.x),
415 near(to.y, control.y),
416 to.x,
417 to.y,
418 );
419 last = [to.x, to.y];
420 }
421 Command::Close => {
422 content.close_path();
423 }
424 }
425 }
426 match style {
427 PathStyle::Stroke(width) => {
428 content
429 .set_line_width(*width)
430 .set_line_cap(LineCapStyle::RoundCap)
431 .set_line_join(LineJoinStyle::RoundJoin)
432 .stroke();
433 }
434 _ => {
435 content.fill_nonzero();
436 }
437 }
438 content.restore_state();
439 }
440 Primitive::Rect { rect, color } => {
441 content.save_state();
442 self.paint(content, used, *color, false);
443 content
444 .rect(rect[0], rect[1], rect[2] - rect[0], rect[3] - rect[1])
445 .fill_nonzero();
446 content.restore_state();
447 }
448 Primitive::RoundedRect {
449 rect,
450 radius,
451 stroke,
452 color,
453 } => {
454 content.save_state();
455 self.paint(content, used, *color, stroke.is_some());
456 rounded(content, *rect, *radius);
457 match stroke {
458 None => {
459 content.fill_nonzero();
460 }
461 Some(Stroke::Solid(width)) => {
462 content.set_line_width(*width).stroke();
463 }
464 Some(Stroke::Dashed(width)) => {
465 content
466 .set_line_width(*width)
467 .set_dash_pattern([width * 2.0, width * 2.0], 0.0)
468 .stroke();
469 }
470 }
471 content.restore_state();
472 }
473 Primitive::Gradient {
474 rect,
475 radius,
476 colors,
477 } => {
478 content.save_state();
479 let average = std::array::from_fn(|at| (colors[0][at] + colors[1][at]) / 2.0);
480 self.paint(content, used, average, false);
481 rounded(content, *rect, *radius);
482 content.fill_nonzero();
483 content.restore_state();
484 }
485 // Soft shadows are for the screen.
486 Primitive::Shadow { .. } => {}
487 Primitive::Image { image, rect } => {
488 let key = ImageKey::Raster(image.id());
489 let reference = match self.images.get(&key) {
490 Some(reference) => *reference,
491 None => {
492 let reference = self.raster(image);
493 self.images.insert(key, reference);
494 reference
495 }
496 };
497 self.place(content, used, reference, *rect);
498 }
499 Primitive::Segment {
500 from,
501 to,
502 width,
503 round,
504 color,
505 } => {
506 content.save_state();
507 self.paint(content, used, *color, true);
508 content
509 .set_line_width(*width)
510 .set_line_cap(if *round {
511 LineCapStyle::RoundCap
512 } else {
513 LineCapStyle::ProjectingSquareCap
514 })
515 .move_to(from[0], from[1])
516 .line_to(to[0], to[1])
517 .stroke();
518 content.restore_state();
519 }
520 Primitive::Taper {
521 from,
522 to,
523 widths,
524 color,
525 } => {
526 content.save_state();
527 self.paint(content, used, *color, false);
528 let [from_radius, to_radius] = widths.map(|width| width / 2.0);
529 // Each part fills on its own, as their windings differ.
530 circle(content, *from, from_radius);
531 content.fill_nonzero();
532 circle(content, *to, to_radius);
533 content.fill_nonzero();
534 let [dx, dy] = [to[0] - from[0], to[1] - from[1]];
535 let length = dx.hypot(dy);
536 if length > (from_radius - to_radius).abs() {
537 // The lines touching both ends' circles.
538 let [ux, uy] = [dx / length, dy / length];
539 let sine = (from_radius - to_radius) / length;
540 let cosine = (1.0 - sine * sine).sqrt();
541 let touch = |center: &[f32; 2], radius: f32, side: f32| {
542 [
543 center[0] + radius * (-uy * side * cosine + ux * sine),
544 center[1] + radius * (ux * side * cosine + uy * sine),
545 ]
546 };
547 let corners = [
548 touch(from, from_radius, 1.0),
549 touch(to, to_radius, 1.0),
550 touch(to, to_radius, -1.0),
551 touch(from, from_radius, -1.0),
552 ];
553 content.move_to(corners[0][0], corners[0][1]);
554 for corner in &corners[1..] {
555 content.line_to(corner[0], corner[1]);
556 }
557 content.close_path().fill_nonzero();
558 }
559 content.restore_state();
560 }
561 Primitive::Highlight {
562 from,
563 to,
564 width,
565 color,
566 } => {
567 content.save_state();
568 let state = self.state(color[3], true);
569 Used::add(&mut used.states, state);
570 content.set_parameters(Name(format!("G{}", state.get()).as_bytes()));
571 let [red, green, blue] = encoded(*color);
572 content
573 .set_stroke_rgb(red, green, blue)
574 .set_line_width(*width)
575 .set_line_cap(LineCapStyle::ProjectingSquareCap)
576 .move_to(from[0], from[1])
577 .line_to(to[0], to[1])
578 .stroke();
579 content.restore_state();
580 }
581 }
582 Ok(())
583 }
584
585 /// Draws image XObject `image` filling `rect`.
586 fn place(&mut self, content: &mut Content, used: &mut Used, image: Ref, rect: [f32; 4]) {
587 Used::add(&mut used.images, image);
588 content.save_state();
589 // Image space runs up from the bottom left.
590 content.transform([
591 rect[2] - rect[0],
592 0.0,
593 0.0,
594 rect[1] - rect[3],
595 rect[0],
596 rect[3],
597 ]);
598 content.x_object(Name(format!("I{}", image.get()).as_bytes()));
599 content.restore_state();
600 }
601
602 /// A picture as an image XObject: opaque photographs as JPEG, others losslessly.
603 fn raster(&mut self, image: &RasterImage) -> Ref {
604 // Pixels are premultiplied in linear light.
605 let straight: Vec<u8> = image
606 .pixels()
607 .chunks_exact(4)
608 .flat_map(|pixel| {
609 let alpha = pixel[3];
610 if alpha == 255 || alpha == 0 {
611 return [pixel[0], pixel[1], pixel[2], alpha];
612 }
613 let [red, green, blue] = [pixel[0], pixel[1], pixel[2]].map(|byte| {
614 let linear = super::linear(f32::from(byte) / 255.0);
615 srgb_byte((linear * 255.0 / f32::from(alpha)).min(1.0))
616 });
617 [red, green, blue, alpha]
618 })
619 .collect();
620 let size = image.size();
621 let opaque = straight.chunks_exact(4).all(|pixel| pixel[3] == 255);
622 if opaque && size[0] * size[1] >= 64 * 64 {
623 let rgb: Vec<u8> = straight
624 .chunks_exact(4)
625 .flat_map(|pixel| [pixel[0], pixel[1], pixel[2]])
626 .collect();
627 let mut jpeg = Vec::new();
628 let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 92);
629 if image::ImageEncoder::write_image(
630 encoder,
631 &rgb,
632 size[0],
633 size[1],
634 image::ExtendedColorType::Rgb8,
635 )
636 .is_ok()
637 {
638 let reference = self.reserve();
639 let mut writer = self.pdf.image_xobject(reference, &jpeg);
640 writer.filter(Filter::DctDecode);
641 writer
642 .width(size[0] as i32)
643 .height(size[1] as i32)
644 .color_space_name(Name(b"DeviceRGB"))
645 .bits_per_component(8)
646 .interpolate(true);
647 return reference;
648 }
649 }
650 self.image(size, &straight, true)
651 }
652
653 /// Straight sRGB RGBA pixels as a losslessly compressed image XObject.
654 fn image(&mut self, [width, height]: [u32; 2], rgba: &[u8], interpolate: bool) -> Ref {
655 let rgb: Vec<u8> = rgba
656 .chunks_exact(4)
657 .flat_map(|pixel| [pixel[0], pixel[1], pixel[2]])
658 .collect();
659 let mask = rgba.chunks_exact(4).any(|pixel| pixel[3] != 255).then(|| {
660 let alpha: Vec<u8> = rgba.chunks_exact(4).map(|pixel| pixel[3]).collect();
661 let mask = self.reserve();
662 let data = compress(&alpha);
663 let mut writer = self.pdf.image_xobject(mask, &data);
664 writer.filter(Filter::FlateDecode);
665 writer
666 .width(width as i32)
667 .height(height as i32)
668 .color_space_name(Name(b"DeviceGray"))
669 .bits_per_component(8)
670 .interpolate(interpolate);
671 mask
672 });
673 let reference = self.reserve();
674 let data = compress(&rgb);
675 let mut writer = self.pdf.image_xobject(reference, &data);
676 writer.filter(Filter::FlateDecode);
677 writer
678 .width(width as i32)
679 .height(height as i32)
680 .color_space_name(Name(b"DeviceRGB"))
681 .bits_per_component(8)
682 .interpolate(interpolate);
683 if let Some(mask) = mask {
684 writer.s_mask(mask);
685 }
686 reference
687 }
688
689 /// The document's face for a run's font, added on first use.
690 fn font(&mut self, data: &Blob<u8>, index: u32) -> Result<usize, RenderError> {
691 if let Some(found) = self
692 .fonts
693 .iter()
694 .position(|font| font.data.id() == data.id() && font.index == index)
695 {
696 return Ok(found);
697 }
698 let face = FontRef::from_index(data.as_ref(), index as usize)
699 .ok_or(RenderError::UnreadableFont)?;
700 let outlined = face.table(tag_from_bytes(b"glyf")).is_some()
701 || face.table(tag_from_bytes(b"CFF ")).is_some();
702 let reference = self.reserve();
703 self.fonts.push(Font {
704 reference,
705 data: data.clone(),
706 index,
707 outlined,
708 glyphs: subsetter::GlyphRemapper::new(),
709 text: BTreeMap::new(),
710 });
711 Ok(self.fonts.len() - 1)
712 }
713
714 fn run(
715 &mut self,
716 content: &mut Content,
717 used: &mut Used,
718 run: GlyphRun<'_>,
719 origin: [f32; 2],
720 ink: [f32; 4],
721 ) -> Result<(), RenderError> {
722 let [top, bottom] = [
723 origin[1] + run.line[0],
724 origin[1] + run.line[0] + run.line[1],
725 ];
726 if self
727 .rows
728 .is_some_and(|[first, last]| bottom <= first || top >= last)
729 {
730 return Ok(());
731 }
732 let font = self.font(run.font, run.index)?;
733 let color = run.color.unwrap_or(ink);
734 let glyphs: Vec<_> = run.glyphs.collect();
735 let texts = glyph_text(run.text, run.clusters, glyphs.len());
736 content.save_state();
737 if self.fonts[font].outlined {
738 Used::add(&mut used.fonts, font);
739 self.paint(content, used, color, false);
740 let face = FontRef::from_index(run.font.as_ref(), run.index as usize)
741 .ok_or(RenderError::UnreadableFont)?;
742 let units = f32::from(face.metrics(&[]).units_per_em.max(1));
743 let advances = face.glyph_metrics(&[]);
744 let name = format!("F{font}");
745 content
746 .begin_text()
747 .set_font(Name(name.as_bytes()), run.size);
748 if run.embolden {
749 self.paint(content, used, color, true);
750 content
751 .set_text_rendering_mode(TextRenderingMode::FillStroke)
752 .set_line_width(run.size / 24.0);
753 }
754 let shear = run.skew.map_or(0.0, |degrees| degrees.to_radians().tan());
755 let mut shown: Vec<(u16, f32)> = Vec::new();
756 let mut pen: Option<[f32; 2]> = None;
757 let flush = |content: &mut Content, shown: &mut Vec<(u16, f32)>| {
758 if shown.is_empty() {
759 return;
760 }
761 let mut positioned = content.show_positioned();
762 let mut items = positioned.items();
763 for (glyph, adjust) in shown.drain(..) {
764 if adjust != 0.0 {
765 items.adjust(adjust);
766 }
767 items.show(Str(&glyph.to_be_bytes()));
768 }
769 };
770 for (glyph, text) in glyphs.iter().zip(texts) {
771 let Ok(id) = u16::try_from(glyph.id) else {
772 continue;
773 };
774 let face = &mut self.fonts[font];
775 let cid = face.glyphs.remap(id);
776 let [x, y] = [origin[0] + glyph.x, origin[1] + glyph.y];
777 let conflict = !text.is_empty()
778 && match face.text.entry(cid) {
779 Entry::Vacant(entry) => {
780 entry.insert(text.clone());
781 false
782 }
783 Entry::Occupied(entry) => *entry.get() != text,
784 };
785 let advance = advances.advance_width(id) / units * run.size;
786 match pen {
787 Some([at, line]) if !conflict && (line - y).abs() < 0.001 => {
788 shown.push((cid, (at - x) * 1000.0 / run.size));
789 }
790 _ => {
791 flush(content, &mut shown);
792 content.set_text_matrix([1.0, 0.0, shear, -1.0, x, y]);
793 if conflict {
794 // A glyph shared by different text says what it shows here.
795 content
796 .begin_marked_content_with_properties(Name(b"Span"))
797 .properties()
798 .actual_text(TextStr(&text));
799 content.show(Str(&cid.to_be_bytes()));
800 content.end_marked_content();
801 pen = None;
802 continue;
803 }
804 shown.push((cid, 0.0));
805 }
806 }
807 pen = Some([x + advance, y]);
808 }
809 flush(content, &mut shown);
810 content.end_text();
811 } else {
812 for glyph in &glyphs {
813 self.glyph_picture(content, used, &run, *glyph, origin, color);
814 }
815 }
816 for decoration in run.decorations {
817 self.paint(content, used, decoration.color.unwrap_or(color), false);
818 content
819 .rect(
820 origin[0] + decoration.x,
821 origin[1] + decoration.y,
822 decoration.width,
823 decoration.thickness,
824 )
825 .fill_nonzero();
826 }
827 content.restore_state();
828 Ok(())
829 }
830
831 /// A glyph of a face without outlines the PDF can embed, such as a colour emoji, drawn
832 /// as a picture four pixels a point.
833 fn glyph_picture(
834 &mut self,
835 content: &mut Content,
836 used: &mut Used,
837 run: &GlyphRun<'_>,
838 glyph: super::Glyph,
839 origin: [f32; 2],
840 color: [f32; 4],
841 ) {
842 const DENSITY: f32 = 4.0;
843 let Ok(id) = u16::try_from(glyph.id) else {
844 return;
845 };
846 let Some(face) = FontRef::from_index(run.font.as_ref(), run.index as usize) else {
847 return;
848 };
849 let size = run.size * DENSITY;
850 let mut scaler = self
851 .scale
852 .builder(face)
853 .size(size)
854 .normalized_coords(run.coords.iter().copied())
855 .build();
856 let Some(rendered) = Render::new(&[
857 Source::ColorOutline(0),
858 Source::ColorBitmap(StrikeWith::BestFit),
859 Source::Outline,
860 ])
861 .render(&mut scaler, id) else {
862 return;
863 };
864 let placement = rendered.placement;
865 if placement.width == 0 || placement.height == 0 {
866 return;
867 }
868 let key = ImageKey::Glyph {
869 font: run.font.id(),
870 index: run.index,
871 glyph: glyph.id,
872 size: size.to_bits(),
873 color: [color[0], color[1], color[2]].map(f32::to_bits),
874 };
875 let image = match self.images.get(&key) {
876 Some(image) => *image,
877 None => {
878 let [red, green, blue] = encoded(color).map(|value| (value * 255.0).round() as u8);
879 let rgba: Vec<u8> = match rendered.content {
880 Pixels::Mask => rendered
881 .data
882 .iter()
883 .flat_map(|coverage| [red, green, blue, *coverage])
884 .collect(),
885 Pixels::Color | Pixels::SubpixelMask => rendered.data.clone(),
886 };
887 let image = self.image([placement.width, placement.height], &rgba, true);
888 self.images.insert(key, image);
889 image
890 }
891 };
892 let [x, y] = [
893 origin[0] + glyph.x + placement.left as f32 / DENSITY,
894 origin[1] + glyph.y - placement.top as f32 / DENSITY,
895 ];
896 self.place(
897 content,
898 used,
899 image,
900 [
901 x,
902 y,
903 x + placement.width as f32 / DENSITY,
904 y + placement.height as f32 / DENSITY,
905 ],
906 );
907 }
908
909 /// Embeds `font`'s subset as a CID-keyed Type 0 font with a ToUnicode map.
910 fn write_font(&mut self, font: Font) -> Result<(), RenderError> {
911 let data = font.data.as_ref();
912 let face =
913 FontRef::from_index(data, font.index as usize).ok_or(RenderError::UnreadableFont)?;
914 let subset = subsetter::subset(data, font.index, &font.glyphs)
915 .map_err(|_| RenderError::UnreadableFont)?;
916 let cff = face.table(tag_from_bytes(b"CFF ")).is_some();
917 let metrics = face.metrics(&[]);
918 let scale = 1000.0 / f32::from(metrics.units_per_em.max(1));
919 let advances = face.glyph_metrics(&[]);
920 let widths: Vec<f32> = font
921 .glyphs
922 .remapped_gids()
923 .map(|old| advances.advance_width(old) * scale)
924 .collect();
925 let postscript: String = face
926 .localized_strings()
927 .find_by_id(StringId::PostScript, None)
928 .map(|name| name.chars().filter(char::is_ascii_alphanumeric).collect())
929 .filter(|name: &String| !name.is_empty())
930 .unwrap_or_else(|| "Font".to_owned());
931 // A subset's name starts with six capitals naming the subset.
932 let mut hash = font.reference.get() as u32;
933 let tag: String = (0..6)
934 .map(|_| {
935 hash = hash.wrapping_mul(1_103_515_245).wrapping_add(12_345);
936 char::from(b'A' + (hash >> 16) as u8 % 26)
937 })
938 .collect();
939 let base = format!("{tag}+{postscript}");
940 let [cid, descriptor, file, map] = [(); 4].map(|_| self.reserve());
941 let info = SystemInfo {
942 registry: Str(b"Adobe"),
943 ordering: Str(b"Identity"),
944 supplement: 0,
945 };
946 self.pdf
947 .type0_font(font.reference)
948 .base_font(Name(base.as_bytes()))
949 .encoding_predefined(Name(b"Identity-H"))
950 .descendant_font(cid)
951 .to_unicode(map);
952 let mut writer = self.pdf.cid_font(cid);
953 writer
954 .subtype(if cff {
955 CidFontType::Type0
956 } else {
957 CidFontType::Type2
958 })
959 .base_font(Name(base.as_bytes()))
960 .system_info(info)
961 .font_descriptor(descriptor)
962 .default_width(0.0);
963 if !cff {
964 writer.cid_to_gid_map_predefined(Name(b"Identity"));
965 }
966 writer.widths().consecutive(0, widths);
967 writer.finish();
968 let mut writer = self.pdf.font_descriptor(descriptor);
969 writer
970 .name(Name(base.as_bytes()))
971 .flags(FontFlags::SYMBOLIC)
972 .bbox(Rect::new(
973 0.0,
974 -metrics.descent * scale,
975 metrics.max_width * scale,
976 metrics.ascent * scale,
977 ))
978 .italic_angle(0.0)
979 .ascent(metrics.ascent * scale)
980 .descent(-metrics.descent * scale)
981 .cap_height(metrics.cap_height * scale)
982 .stem_v(80.0);
983 if cff {
984 writer.font_file3(file);
985 } else {
986 writer.font_file2(file);
987 }
988 writer.finish();
989 let compressed = compress(&subset);
990 let mut stream = self.pdf.stream(file, &compressed);
991 stream.filter(Filter::FlateDecode);
992 if cff {
993 stream.pair(Name(b"Subtype"), Name(b"OpenType"));
994 }
995 stream.finish();
996 let mut cmap = UnicodeCmap::new(Name(b"Custom"), info);
997 for (glyph, text) in &font.text {
998 cmap.pair_with_multiple(*glyph, text.chars());
999 }
1000 let cmap = compress(&cmap.finish());
1001 self.pdf.stream(map, &cmap).filter(Filter::FlateDecode);
1002 Ok(())
1003 }
1004}
crates/draw/src/render/text.rs+23-2
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1use crate::RenderError;1use crate::RenderError;
2use parley::fontique::Blob;2use parley::fontique::Blob;
3use std::ops::Range;
34
4/// Text the renderer can paint, as glyph runs in layer units. Owners recover their5/// Text the renderer can paint, as glyph runs in layer units. Owners recover their
5/// concrete text from a primitive through `Any`.6/// concrete text from a primitive through `Any`.
...@@ -32,6 +33,11 @@ pub struct GlyphRun<'a> {...@@ -32,6 +33,11 @@ pub struct GlyphRun<'a> {
32 pub glyphs: &'a mut dyn Iterator<Item = Glyph>,33 pub glyphs: &'a mut dyn Iterator<Item = Glyph>,
33 /// Underline and strikethrough, painted after the glyphs.34 /// Underline and strikethrough, painted after the glyphs.
34 pub decorations: &'a [Decoration],35 pub decorations: &'a [Decoration],
36 /// The source the glyphs show, for a document's text layer; the renderer ignores it.
37 pub text: &'a str,
38 /// Each cluster's bytes of `text` and how many glyphs show it, in `glyphs`' order; a
39 /// ligature's later clusters take none.
40 pub clusters: &'a mut dyn Iterator<Item = (Range<usize>, usize)>,
35}41}
3642
37/// A glyph's baseline position.43/// A glyph's baseline position.
...@@ -53,10 +59,12 @@ pub struct Decoration {...@@ -53,10 +59,12 @@ pub struct Decoration {
53 pub color: Option<[f32; 4]>,59 pub color: Option<[f32; 4]>,
54}60}
5561
56/// Paints one parley glyph run with its line's baseline moved to `baseline`, its glyphs62/// Paints one parley glyph run of `text` laid out, with its line's baseline moved to
57/// raised by `rise` and `backdrop` behind them.63/// `baseline`, its glyphs raised by `rise` and `backdrop` behind them.
64#[allow(clippy::too_many_arguments)]
58pub fn paint_parley_run<B: parley::Brush>(65pub fn paint_parley_run<B: parley::Brush>(
59 run: &parley::GlyphRun<'_, B>,66 run: &parley::GlyphRun<'_, B>,
67 text: &str,
60 baseline: f32,68 baseline: f32,
61 rise: f32,69 rise: f32,
62 line: [f32; 2],70 line: [f32; 2],
...@@ -104,6 +112,17 @@ pub fn paint_parley_run<B: parley::Brush>(...@@ -104,6 +112,17 @@ pub fn paint_parley_run<B: parley::Brush>(
104 x: glyph.x,112 x: glyph.x,
105 y: glyph.y + baseline - line_baseline - rise,113 y: glyph.y + baseline - line_baseline - rise,
106 });114 });
115 // The run's clusters are those of its style within its span of the line; walked only
116 // when a text layer asks.
117 let [start, end] = [run.offset(), run.offset() + run.advance()];
118 let mut at = None;
119 let mut clusters = run.run().visual_clusters().filter_map(|cluster| {
120 let x = *at.get_or_insert_with(|| cluster.visual_offset().unwrap_or(start));
121 at = Some(x + cluster.advance());
122 let within = x > start - 0.01 && x < end - 0.01;
123 (cluster.style_index() == run.style_index() && within)
124 .then(|| (cluster.text_range(), cluster.glyphs().count()))
125 });
107 paint(GlyphRun {126 paint(GlyphRun {
108 font: &font.data,127 font: &font.data,
109 index: font.index,128 index: font.index,
...@@ -116,5 +135,7 @@ pub fn paint_parley_run<B: parley::Brush>(...@@ -116,5 +135,7 @@ pub fn paint_parley_run<B: parley::Brush>(
116 line,135 line,
117 glyphs: &mut glyphs,136 glyphs: &mut glyphs,
118 decorations: &decorations,137 decorations: &decorations,
138 text,
139 clusters: &mut clusters,
119 })140 })
120}141}
crates/mobile/src/lib.rs+5-21
...@@ -29,7 +29,7 @@ use draw::edit::{Key, NamedKey, SelectionUnit};...@@ -29,7 +29,7 @@ use draw::edit::{Key, NamedKey, SelectionUnit};
29use onestore::{29use onestore::{
30 ExGuid,30 ExGuid,
31 op::{Edit, Op, PageOp},31 op::{Edit, Op, PageOp},
32 page::{Image, Page, ink::ShapeKind},32 page::{Page, ink::ShapeKind},
33};33};
34use parley::{Affinity, BoundingBox};34use parley::{Affinity, BoundingBox};
35use std::{35use std::{
...@@ -503,25 +503,9 @@ impl Canvas {...@@ -503,25 +503,9 @@ impl Canvas {
503 Ok(moved(self.page.set_paper(color, rules, Some(art))?))503 Ok(moved(self.page.set_paper(color, rules, Some(art))?))
504 }504 }
505505
506 /// Puts a picture of `size` points after the caret's paragraph.506 /// Puts a picture of `size` points at the caret.
507 fn insert_picture(&mut self, bytes: &[u8], size: [f32; 2]) -> Result<bool> {507 fn insert_picture(&mut self, bytes: &[u8], size: [f32; 2]) -> Result<bool> {
508 let image = Image {508 Ok(moved(self.page.insert_picture(bytes.to_vec(), size)?))
509 id: onestore::page::text::new_id()?,
510 layout: onestore::document::Layout {
511 max_width: Some(size[0]),
512 max_height: Some(size[1]),
513 ..Default::default()
514 },
515 size: Some(size),
516 bytes: Some(bytes.into()),
517 display: None,
518 alt: None,
519 background: false,
520 printout: None,
521 };
522 let page = &mut self.page;
523 page.editor.insert_picture(&mut page.engine, image)?;
524 Ok(true)
525 }509 }
526510
527 /// Gives the page date `seconds` since 1970 (keeping the stored fraction of a second),511 /// 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 {...@@ -1257,8 +1241,8 @@ pub extern "C" fn sb_view_page_text(view: &View) -> *mut c_char {
1257 owned(view.canvas.page_text())1241 owned(view.canvas.page_text())
1258}1242}
12591243
1260/// Puts the picture file `bytes` (JPEG or PNG), `width` × `height` points, after the1244/// Puts the picture file `bytes` (JPEG or PNG), `width` × `height` points, at the caret:
1261/// caret's paragraph.1245/// splitting its paragraph, or on the page at a caret on blank page.
1262///1246///
1263/// # Safety1247/// # Safety
1264/// `bytes` holds `length` bytes.1248/// `bytes` holds `length` bytes.
crates/mobile/src/tests.rs+1-1
...@@ -430,7 +430,7 @@ fn find_selects_the_match_and_formatting_reports_and_toggles() {...@@ -430,7 +430,7 @@ fn find_selects_the_match_and_formatting_reports_and_toggles() {
430}430}
431431
432#[test]432#[test]
433fn a_picture_goes_after_the_caret_paragraph_and_undoes() {433fn a_picture_goes_in_at_the_caret_and_undoes() {
434 let (_directory, section) = features();434 let (_directory, section) = features();
435 let mut canvas = canvas(&section, "Paragraph controls");435 let mut canvas = canvas(&section, "Paragraph controls");
436 focus(&mut canvas, "Collapsed parent");436 focus(&mut canvas, "Collapsed parent");
crates/notebook/Cargo.toml+3
...@@ -23,6 +23,9 @@ zeroize = { version = "1.9.0", optional = true }...@@ -23,6 +23,9 @@ zeroize = { version = "1.9.0", optional = true }
23[target.'cfg(target_vendor = "apple")'.dependencies]23[target.'cfg(target_vendor = "apple")'.dependencies]
24nix = { version = "0.31", default-features = false, features = ["fs"] }24nix = { version = "0.31", default-features = false, features = ["fs"] }
2525
26[target.'cfg(windows)'.dependencies]
27windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
28
26[dev-dependencies]29[dev-dependencies]
27libc = "0.2"30libc = "0.2"
28serde_json = "1"31serde_json = "1"
crates/notebook/src/lib.rs+2-1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1#![forbid(unsafe_code)]1// One Win32 call, where std has no safe form: `session`'s hidden attribute.
2#![deny(unsafe_code)]
2#![doc = include_str!("../README.md")]3#![doc = include_str!("../README.md")]
34
4pub mod discover;5pub mod discover;
crates/notebook/src/session.rs+26-2
...@@ -168,7 +168,9 @@ impl Storage for Directory {...@@ -168,7 +168,9 @@ impl Storage for Directory {
168 Ok(std::fs::create_dir(self.path(path))?)168 Ok(std::fs::create_dir(self.path(path))?)
169 }169 }
170170
171 /// macOS keeps the attribute as `UF_HIDDEN`, and passes it on to a share it mounted.171 /// macOS keeps the attribute as `UF_HIDDEN`, and passes it on to a share it mounted;
172 /// Linux has no attribute to set, a dot folder being hidden there already.
173 #[cfg_attr(windows, allow(unsafe_code))]
172 fn hide(&self, path: &str) -> Result<()> {174 fn hide(&self, path: &str) -> Result<()> {
173 #[cfg(target_vendor = "apple")]175 #[cfg(target_vendor = "apple")]
174 {176 {
...@@ -181,7 +183,29 @@ impl Storage for Directory {...@@ -181,7 +183,29 @@ impl Storage for Directory {
181 .map_err(io::Error::from)?;183 .map_err(io::Error::from)?;
182 }184 }
183 }185 }
184 #[cfg(not(target_vendor = "apple"))]186 #[cfg(windows)]
187 {
188 use std::os::windows::ffi::OsStrExt;
189 use windows_sys::Win32::Storage::FileSystem::{
190 FILE_ATTRIBUTE_HIDDEN, GetFileAttributesW, INVALID_FILE_ATTRIBUTES,
191 SetFileAttributesW,
192 };
193 let path: Vec<u16> = self
194 .path(path)
195 .as_os_str()
196 .encode_wide()
197 .chain([0])
198 .collect();
199 // SAFETY: `path` is a NUL-terminated UTF-16 string that outlives both calls.
200 let attributes = unsafe { GetFileAttributesW(path.as_ptr()) };
201 if attributes == INVALID_FILE_ATTRIBUTES
202 || unsafe { SetFileAttributesW(path.as_ptr(), attributes | FILE_ATTRIBUTE_HIDDEN) }
203 == 0
204 {
205 return Err(io::Error::last_os_error().into());
206 }
207 }
208 #[cfg(not(any(target_vendor = "apple", windows)))]
185 let _ = path;209 let _ = path;
186 Ok(())210 Ok(())
187 }211 }
crates/onestore/examples/opts29_dump.rs created+29
...@@ -0,0 +1,29 @@
1use onestore::{ObjectData, PropertySets, RevisionIndex, Store, Value};
2use std::{env, fs};
3
4fn main() -> Result<(), Box<dyn std::error::Error>> {
5 for path in env::args_os().skip(1) {
6 let bytes = fs::read(&path)?;
7 let store = Store::parse(&bytes)?;
8 let index = RevisionIndex::parse(&store)?;
9 println!("== {path:?}");
10 for space in index.spaces.keys() {
11 let Ok(resolved) = index.resolve_active(*space) else { continue };
12 println!("-- space {space:?}");
13 for (id, object) in &resolved.objects {
14 let ObjectData::Properties(data) = object.data else { continue };
15 let sets = PropertySets::parse(data)?;
16 println!("{id:?} jcid={:#x}", object.jcid);
17 for p in &sets.sets[0] {
18 let v = match &p.value {
19 Value::Bytes(b) if b.len() <= 64 => format!("{:02x?}", b),
20 Value::Bytes(b) => format!("{} bytes", b.len()),
21 other => format!("{other:?}").chars().take(120).collect(),
22 };
23 println!(" {:#010x} {v}", p.id);
24 }
25 }
26 }
27 }
28 Ok(())
29}
crates/snowbound/Cargo.toml+5-1
...@@ -10,7 +10,7 @@ default = ["wgpu"]...@@ -10,7 +10,7 @@ default = ["wgpu"]
10wgpu = ["draw/wgpu", "dep:wgpu"]10wgpu = ["draw/wgpu", "dep:wgpu"]
1111
12[dependencies]12[dependencies]
13canvas = { path = "../canvas", features = ["interaction"] }13canvas = { path = "../canvas", features = ["interaction", "pdf"] }
14draw = { path = "../draw", default-features = false, features = ["render"] }14draw = { path = "../draw", default-features = false, features = ["render"] }
15ui = { path = "../ui" }15ui = { path = "../ui" }
16png = "0.18"16png = "0.18"
...@@ -39,6 +39,10 @@ objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAl...@@ -39,6 +39,10 @@ objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAl
39objc2-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"] }39objc2-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"] }
40block2 = "0.5.1"40block2 = "0.5.1"
4141
42[target.'cfg(not(target_os = "macos"))'.dependencies]
43# Clipboard bitmaps; macOS reads the pasteboard's PNG and TIFF itself.
44arboard = { version = "3.6.1", default-features = false, features = ["image-data"] }
45
42[target.'cfg(windows)'.dependencies]46[target.'cfg(windows)'.dependencies]
43windows-sys = { version = "0.61", features = [47windows-sys = { version = "0.61", features = [
44 "Win32_Foundation",48 "Win32_Foundation",
crates/snowbound/assets/icon/Assets.car created
Binary files /dev/null and b/crates/snowbound/assets/icon/Assets.car differ
crates/snowbound/assets/icon/README.md created+20
...@@ -0,0 +1,20 @@
1# App icon
2
3`Assets.car` is `sources/Snowbound-Tahoe.icon` compiled by Xcode 26.0.1's `actool`,
4which can leave out the flattened renditions later versions always add. macOS 26 draws
5the Liquid Glass icon from it; earlier versions find no rendition there and draw
6`Snowbound-Sequoia.icns` (`tools/canvas/build_macos.py` bundles both). After changing
7the `.icon`, regenerate it with Xcode 26.0.1 (not newer), for the Intel floor:
8
9```sh
10DEVELOPER_DIR=/Applications/Xcode-26.0.1.app/Contents/Developer xcrun actool \
11 sources/Snowbound-Tahoe.icon --compile OUT --app-icon Snowbound-Tahoe \
12 --enable-on-demand-resources NO --development-region en --target-device mac \
13 --platform macosx --enable-icon-stack-fallback-generation=disabled \
14 --include-all-app-icons --minimum-deployment-target 10.13 \
15 --output-partial-info-plist OUT/partial.plist
16cp OUT/Assets.car Assets.car
17```
18
19It warns "Failed to generate flattened icon stack", which is the point.
20`xcrun assetutil --info Assets.car` should list no `Icon Image` renditions.
crates/snowbound/linux/README.md+2-2
...@@ -30,8 +30,8 @@ Snowbound....@@ -30,8 +30,8 @@ Snowbound.
3030
31## Requirements31## Requirements
3232
33- x86_64 or aarch64 Linux with glibc 2.31 or newer (Debian 11, Ubuntu 20.04,33- x86_64 or aarch64 Linux with glibc 2.17 or newer (RHEL 7, Debian 8,
34 Fedora 32 and later).34 Ubuntu 14.04 and later).
35- A Vulkan driver (Mesa's are standard) or, failing that, OpenGL ES 3 through35- A Vulkan driver (Mesa's are standard) or, failing that, OpenGL ES 3 through
36 EGL. `WGPU_BACKEND=gl ./bin/snowbound ...` forces OpenGL.36 EGL. `WGPU_BACKEND=gl ./bin/snowbound ...` forces OpenGL.
37- fontconfig, and X11 or Wayland with libxkbcommon.37- fontconfig, and X11 or Wayland with libxkbcommon.
crates/snowbound/linux/package.sh+3-3
...@@ -17,15 +17,15 @@ for arch do...@@ -17,15 +17,15 @@ for arch do
17 triple=$arch-unknown-linux-gnu17 triple=$arch-unknown-linux-gnu
18 variable=$(echo "$triple" | tr 'a-z-' 'A-Z_')18 variable=$(echo "$triple" | tr 'a-z-' 'A-Z_')
19 linker="$tools/cc-$arch"19 linker="$tools/cc-$arch"
20 # glibc 2.31 reaches back to Debian 11 and Ubuntu 20.04; zig rejects rustc's20 # glibc 2.17, Rust's own floor, reaches back to RHEL 7, Debian 8 and Ubuntu 14.04; zig
21 # --target, --no-undefined-version and -O1.21 # rejects rustc's --target, --no-undefined-version and -O1.
22 cat > "$linker" <<EOF22 cat > "$linker" <<EOF
23#!/bin/sh23#!/bin/sh
24for arg do24for arg do
25 shift25 shift
26 case "\$arg" in --target=*|-Wl,--no-undefined-version|-Wl,-O1) ;; *) set -- "\$@" "\$arg" ;; esac26 case "\$arg" in --target=*|-Wl,--no-undefined-version|-Wl,-O1) ;; *) set -- "\$@" "\$arg" ;; esac
27done27done
28exec zig cc -target $arch-linux-gnu.2.31 "\$@"28exec zig cc -target $arch-linux-gnu.2.17 "\$@"
29EOF29EOF
30 chmod +x "$linker"30 chmod +x "$linker"
31 rustup target add "$triple" >/dev/null31 rustup target add "$triple" >/dev/null
crates/snowbound/src/commands.rs+47-33
...@@ -23,6 +23,9 @@ pub enum Id {...@@ -23,6 +23,9 @@ pub enum Id {
23 PageVersions,23 PageVersions,
24 CopyPageLink,24 CopyPageLink,
25 ShowNotebook,25 ShowNotebook,
26 ExportPdf,
27 ExportSectionPdf,
28 Print,
26 Undo,29 Undo,
27 Redo,30 Redo,
28 Cut,31 Cut,
...@@ -44,7 +47,7 @@ pub enum Id {...@@ -44,7 +47,7 @@ pub enum Id {
44 ActualSize,47 ActualSize,
45 Sidebar,48 Sidebar,
46 PageList,49 PageList,
47 DarkPages,50 PagesMatchTheme,
48 FullPageView,51 FullPageView,
49 /// OneNote's Hide Spelling Errors, which leaves misspelled words unmarked.52 /// OneNote's Hide Spelling Errors, which leaves misspelled words unmarked.
50 HideSpelling,53 HideSpelling,
...@@ -71,6 +74,7 @@ pub enum Id {...@@ -71,6 +74,7 @@ pub enum Id {
71 Eraser,74 Eraser,
72 Lasso,75 Lasso,
73 Shape(ShapeKind),76 Shape(ShapeKind),
77 SnapToGrid,
74 Toggle(Toggle),78 Toggle(Toggle),
75 Highlight,79 Highlight,
76 FontColor,80 FontColor,
...@@ -235,6 +239,15 @@ pub const COMMANDS: &[Command] = &[...@@ -235,6 +239,15 @@ pub const COMMANDS: &[Command] = &[
235 NONE,239 NONE,
236 NONE,240 NONE,
237 ),241 ),
242 row!(Id::ExportPdf, "Export as PDF…", NONE, NONE),
243 row!(Id::ExportSectionPdf, "Export Section as PDF…", NONE, NONE),
244 // Go to takes OneNote's Ctrl+P, and Pause its Ctrl+Alt+P.
245 row!(
246 Id::Print,
247 "Print…",
248 &[cmd('p').option().shift()],
249 &[cmd('p').option().shift()]
250 ),
238 row!(Id::Undo, "Undo", &[cmd('z')], &[cmd('z')]),251 row!(Id::Undo, "Undo", &[cmd('z')], &[cmd('z')]),
239 row!(252 row!(
240 Id::Redo,253 Id::Redo,
...@@ -296,7 +309,7 @@ pub const COMMANDS: &[Command] = &[...@@ -296,7 +309,7 @@ pub const COMMANDS: &[Command] = &[
296 ),309 ),
297 row!(Id::Sidebar, "Notebook List", &[cmd('s').control()], NONE),310 row!(Id::Sidebar, "Notebook List", &[cmd('s').control()], NONE),
298 row!(Id::PageList, "Page List", NONE, NONE),311 row!(Id::PageList, "Page List", NONE, NONE),
299 row!(Id::DarkPages, "Dark Pages", NONE, NONE),312 row!(Id::PagesMatchTheme, "Pages Match UI Theme", NONE, NONE),
300 row!(Id::HideSpelling, "Hide Spelling Errors", NONE, NONE),313 row!(Id::HideSpelling, "Hide Spelling Errors", NONE, NONE),
301 row!(314 row!(
302 Id::Spelling,315 Id::Spelling,
...@@ -387,6 +400,7 @@ pub const COMMANDS: &[Command] = &[...@@ -387,6 +400,7 @@ pub const COMMANDS: &[Command] = &[
387 row!(Id::Shape(ShapeKind::Arrow), "Arrow", NONE, NONE),400 row!(Id::Shape(ShapeKind::Arrow), "Arrow", NONE, NONE),
388 row!(Id::Shape(ShapeKind::Rectangle), "Rectangle", NONE, NONE),401 row!(Id::Shape(ShapeKind::Rectangle), "Rectangle", NONE, NONE),
389 row!(Id::Shape(ShapeKind::Ellipse), "Oval", NONE, NONE),402 row!(Id::Shape(ShapeKind::Ellipse), "Oval", NONE, NONE),
403 row!(Id::SnapToGrid, "Snap To Grid", NONE, NONE),
390 row!(Id::Toggle(Toggle::Bold), "Bold", &[cmd('b')], &[cmd('b')]),404 row!(Id::Toggle(Toggle::Bold), "Bold", &[cmd('b')], &[cmd('b')]),
391 row!(405 row!(
392 Id::Toggle(Toggle::Italic),406 Id::Toggle(Toggle::Italic),
...@@ -773,9 +787,14 @@ impl State {...@@ -773,9 +787,14 @@ impl State {
773 .notebook()787 .notebook()
774 .is_some_and(|library| library.catalog().is_some()),788 .is_some_and(|library| library.catalog().is_some()),
775 ),789 ),
776 Id::NewPage | Id::NewSubpage | Id::CopyPageLink | Id::Find | Id::Search => {790 Id::NewPage
777 enabled(!modal && session.is_some())791 | Id::NewSubpage
778 }792 | Id::CopyPageLink
793 | Id::Find
794 | Id::Search
795 | Id::ExportPdf
796 | Id::ExportSectionPdf
797 | Id::Print => enabled(!modal && session.is_some()),
779 Id::PageVersions => session.filter(|_| !modal).map_or(798 Id::PageVersions => session.filter(|_| !modal).map_or(
780 Status {799 Status {
781 enabled: false,800 enabled: false,
...@@ -821,10 +840,14 @@ impl State {...@@ -821,10 +840,14 @@ impl State {
821 Some(crate::pane::Pane::Tags { .. })840 Some(crate::pane::Pane::Tags { .. })
822 )),841 )),
823 },842 },
824 Id::DarkPages => Status {843 Id::PagesMatchTheme => Status {
825 enabled: !modal,844 enabled: !modal,
826 checked: Some(!self.light_pages),845 checked: Some(!self.light_pages),
827 },846 },
847 Id::SnapToGrid => Status {
848 enabled: !modal,
849 checked: Some(self.view.snap_to_grid),
850 },
828 Id::HideSpelling => Status {851 Id::HideSpelling => Status {
829 enabled: !modal && self.spelling.is_some(),852 enabled: !modal && self.spelling.is_some(),
830 checked: Some(self.hide_spelling),853 checked: Some(self.hide_spelling),
...@@ -917,26 +940,6 @@ impl State {...@@ -917,26 +940,6 @@ impl State {
917 Ok(())940 Ok(())
918 }941 }
919942
920 /// Puts an encoded picture after the caret's paragraph at its size on a 96 dpi screen,
921 /// as OneNote 2010 inserts one.
922 pub(crate) fn insert_picture(&mut self, bytes: Vec<u8>) -> Result<(), Box<dyn Error>> {
923 let pixels = match draw::RasterImage::measure(&bytes) {
924 Ok(pixels) => pixels,
925 Err(_) => {
926 platform::alert(
927 "Couldn't insert the picture",
928 "Choose a PNG, JPEG or GIF picture.",
929 );
930 return Ok(());
931 }
932 };
933 let response = self
934 .view
935 .insert_picture(bytes, pixels.map(|side| side as f32 * 0.75))?;
936 self.respond(response);
937 Ok(())
938 }
939
940 /// The notebook the open section, or the notebook showing none, belongs to.943 /// The notebook the open section, or the notebook showing none, belongs to.
941 fn notebook(&self) -> Option<&Arc<crate::Library>> {944 fn notebook(&self) -> Option<&Arc<crate::Library>> {
942 self.session945 self.session
...@@ -967,12 +970,10 @@ impl State {...@@ -967,12 +970,10 @@ impl State {
967 return self.paper_page(color, self.view.editor.rule_lines(), None);970 return self.paper_page(color, self.view.editor.rule_lines(), None);
968 }971 }
969 Choice::Art(name) => {972 Choice::Art(name) => {
970 let editor = &self.view.editor;973 return self.with_art(name.and_then(canvas::template::find), |state, art| {
971 return self.paper_page(974 let editor = &state.view.editor;
972 editor.page_color(),975 state.paper_page(editor.page_color(), editor.rule_lines(), Some(art))
973 editor.rule_lines(),976 });
974 Some(name.and_then(canvas::template::find)),
975 );
976 }977 }
977 Choice::RuleLines(lines) => {978 Choice::RuleLines(lines) => {
978 let lines = lines.map(|index| canvas::template::RULE_LINES[index].1);979 let lines = lines.map(|index| canvas::template::RULE_LINES[index].1);
...@@ -1031,6 +1032,14 @@ impl State {...@@ -1031,6 +1032,14 @@ impl State {
1031 platform::reveal(&self.notebook().ok_or("No notebook is open")?.location);1032 platform::reveal(&self.notebook().ok_or("No notebook is open")?.location);
1032 return Ok(());1033 return Ok(());
1033 }1034 }
1035 Id::ExportPdf | Id::ExportSectionPdf | Id::Print => {
1036 let scope = if id == Id::ExportSectionPdf {
1037 crate::print::Scope::Section
1038 } else {
1039 crate::print::Scope::Page
1040 };
1041 return self.print(scope, id != Id::Print);
1042 }
1034 Id::Undo | Id::Redo => {1043 Id::Undo | Id::Redo => {
1035 let response = self.view.undo(id == Id::Redo)?;1044 let response = self.view.undo(id == Id::Redo)?;
1036 self.respond(response);1045 self.respond(response);
...@@ -1147,7 +1156,12 @@ impl State {...@@ -1147,7 +1156,12 @@ impl State {
1147 self.save_settings();1156 self.save_settings();
1148 return Ok(());1157 return Ok(());
1149 }1158 }
1150 Id::DarkPages => {1159 Id::SnapToGrid => {
1160 self.view.snap_to_grid = !self.view.snap_to_grid;
1161 self.save_settings();
1162 return Ok(());
1163 }
1164 Id::PagesMatchTheme => {
1151 self.light_pages = !self.light_pages;1165 self.light_pages = !self.light_pages;
1152 self.follow_color_scheme();1166 self.follow_color_scheme();
1153 self.save_settings();1167 self.save_settings();
crates/snowbound/src/desktop_linux.rs+9-2
...@@ -291,8 +291,15 @@ pub fn set_toplevel_icon(window: &Window) -> Option<()> {...@@ -291,8 +291,15 @@ pub fn set_toplevel_icon(window: &Window) -> Option<()> {
291 ]);291 ]);
292 }292 }
293 }293 }
294 // Safety: memfd_create returns a new descriptor this File then owns.294 // Safety: memfd_create returns a new descriptor this File then owns. A system call, as
295 let fd = unsafe { libc::memfd_create(c"snowbound-icon".as_ptr(), libc::MFD_CLOEXEC) };295 // glibc wraps it only from 2.27.
296 let fd = unsafe {
297 libc::syscall(
298 libc::SYS_memfd_create,
299 c"snowbound-icon".as_ptr(),
300 libc::MFD_CLOEXEC,
301 )
302 } as i32;
296 if fd < 0 {303 if fd < 0 {
297 return None;304 return None;
298 }305 }
crates/snowbound/src/guide.rs created+106
...@@ -0,0 +1,106 @@
1//! The Snowbound Guide, a notebook the app carries (`docs/guide-notebook`). It opens from a
2//! copy in the user's documents, so the one inside the app is never edited.
3
4use crate::{State, platform};
5use notebook::session::Notebook;
6use std::{error::Error, path::Path};
7
8const NAME: &str = "Snowbound Guide";
9/// Whether the welcome screen offers the guide; off until Clover has read it through.
10pub(crate) const OFFERED: bool = false;
11
12macro_rules! file {
13 ($name:literal) => {
14 (
15 $name,
16 include_bytes!(concat!(
17 "../../../docs/guide-notebook/Snowbound Guide/",
18 $name
19 ))
20 .as_slice(),
21 )
22 };
23}
24
25const FILES: [(&str, &[u8]); 6] = [
26 file!("Open Notebook.onetoc2"),
27 file!("Getting Started.one"),
28 file!("Writing & Formatting.one"),
29 file!("Tags & Search.one"),
30 file!("Drawing & Recording.one"),
31 file!("Sharing & Sync.one"),
32];
33
34impl State {
35 /// Opens the guide in the user's documents, copying it there first unless it is there.
36 pub(crate) fn open_guide(&mut self) -> Result<(), Box<dyn Error>> {
37 let documents = platform::documents_dir().ok_or("There is no Documents folder")?;
38 let folder = std::path::absolute(documents.join(NAME))?;
39 if !folder.exists() {
40 copy(&folder, &self.cache)?;
41 }
42 self.open_notebook(folder.to_string_lossy().into_owned(), None);
43 Ok(())
44 }
45}
46
47/// Writes the guide to `folder` under a temporary name, then gives it its own, so a copy cut
48/// short never passes for the guide.
49fn copy(folder: &Path, cache: &Path) -> Result<(), Box<dyn Error>> {
50 let partial = folder.with_file_name(format!("{NAME}.partial"));
51 if partial.exists() {
52 std::fs::remove_dir_all(&partial)?;
53 }
54 std::fs::create_dir_all(&partial)?;
55 for (name, bytes) in FILES {
56 std::fs::write(partial.join(name), bytes)?;
57 }
58 // The snowflake tag's art, kept in the notebook as Customize Tags keeps it.
59 Notebook::open(&partial, cache)?.map_tag_art(
60 "Snowflake",
61 34,
62 include_bytes!("../assets/tags/snowflake.svg"),
63 "svg",
64 )?;
65 std::fs::rename(&partial, folder)?;
66 Ok(())
67}
68
69#[cfg(test)]
70mod tests {
71 use super::*;
72
73 /// The carried files are the committed guide's, and the copy reads as that notebook with
74 /// its tag art.
75 #[test]
76 fn the_guide_copies_whole() {
77 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/guide-notebook");
78 let mut committed: Vec<String> = std::fs::read_dir(root.join(NAME))
79 .unwrap()
80 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
81 .filter(|name| !name.starts_with('.'))
82 .collect();
83 committed.sort();
84 let mut carried: Vec<String> = FILES.iter().map(|(name, _)| name.to_string()).collect();
85 carried.sort();
86 assert_eq!(carried, committed);
87
88 let temporary =
89 std::env::temp_dir().join(format!("snowbound-guide-{}", std::process::id()));
90 let _ = std::fs::remove_dir_all(&temporary);
91 let folder = temporary.join(NAME);
92 copy(&folder, &temporary.join("cache")).unwrap();
93 let notebook = Notebook::open(&folder, temporary.join("cache")).unwrap();
94 assert_eq!(notebook.catalog().sections.len(), 5);
95 let art = notebook.tag_art().unwrap();
96 assert_eq!(
97 art.iter()
98 .map(|tag| (tag.name.as_str(), tag.shape))
99 .collect::<Vec<_>>(),
100 [("Snowflake", 34)]
101 );
102 let committed = std::fs::read_to_string(root.join(NAME).join(".snowbound/tags.json"));
103 assert!(committed.unwrap().contains(&art[0].art));
104 std::fs::remove_dir_all(&temporary).unwrap();
105 }
106}
crates/snowbound/src/linux.rs+44-2
...@@ -75,6 +75,9 @@ pub fn corner_radius(window: &Window) -> f32 {...@@ -75,6 +75,9 @@ pub fn corner_radius(window: &Window) -> f32 {
75/// Whether the window erases its own bottom corners to transparent pixels: under winit's75/// Whether the window erases its own bottom corners to transparent pixels: under winit's
76/// Adwaita frame on GNOME's Wayland, which draws libadwaita's window edge. Mutter's X1176/// Adwaita frame on GNOME's Wayland, which draws libadwaita's window edge. Mutter's X11
77/// frames stay square at the bottom, as libadwaita's own `ssd-frame` style has them.77/// frames stay square at the bottom, as libadwaita's own `ssd-frame` style has them.
78/// Winit's theme already holds the window to the app's appearance.
79pub fn follow_appearance(_: &Window, _: Theme) {}
80
78pub fn cuts_corners() -> bool {81pub fn cuts_corners() -> bool {
79 sctk_adwaita::LIBADWAITA_EDGE.load(Ordering::Relaxed)82 sctk_adwaita::LIBADWAITA_EDGE.load(Ordering::Relaxed)
80}83}
...@@ -197,6 +200,26 @@ impl Clipboard {...@@ -197,6 +200,26 @@ impl Clipboard {
197 Self::X11(clipboard) => clipboard.get_text()?,200 Self::X11(clipboard) => clipboard.get_text()?,
198 })201 })
199 }202 }
203
204 pub fn get_files(&mut self) -> Vec<std::path::PathBuf> {
205 self.through_x11(|clipboard| clipboard.get().file_list().ok())
206 .unwrap_or_default()
207 }
208
209 pub fn get_picture(&mut self) -> Option<Vec<u8>> {
210 self.through_x11(|clipboard| crate::paste::bitmap(clipboard.get_image().ok()?))
211 }
212
213 /// Files and pictures come through X11 on Wayland too, whose clipboard Xwayland shares.
214 fn through_x11<T>(
215 &mut self,
216 read: impl FnOnce(&mut arboard::Clipboard) -> Option<T>,
217 ) -> Option<T> {
218 match self {
219 Self::Wayland(_) => read(&mut arboard::Clipboard::new().ok()?),
220 Self::X11(clipboard) => read(clipboard),
221 }
222 }
200}223}
201224
202/// Whether the system draws the title bar: the window manager, or on Wayland the compositor.225/// Whether the system draws the title bar: the window manager, or on Wayland the compositor.
...@@ -254,7 +277,7 @@ fn desktop() -> Desktop {...@@ -254,7 +277,7 @@ fn desktop() -> Desktop {
254}277}
255278
256/// The theme over the window's backdrop, as the kit draws it.279/// The theme over the window's backdrop, as the kit draws it.
257pub fn over_backdrop(theme: ui::Theme) -> ui::Theme {280pub fn over_backdrop(theme: ui::Theme, _: Theme) -> ui::Theme {
258 theme.over_backdrop()281 theme.over_backdrop()
259}282}
260283
...@@ -767,7 +790,7 @@ pub fn cache_dir() -> Option<PathBuf> {...@@ -767,7 +790,7 @@ pub fn cache_dir() -> Option<PathBuf> {
767pub fn smb_mount(path: &std::path::Path) -> Option<crate::library::Mount> {790pub fn smb_mount(path: &std::path::Path) -> Option<crate::library::Mount> {
768 let mounts = std::fs::read_to_string("/proc/self/mounts").ok()?;791 let mounts = std::fs::read_to_string("/proc/self/mounts").ok()?;
769 // The mount holding the path is the one at its longest ancestor.792 // The mount holding the path is the one at its longest ancestor.
770 let (source, point, options, within) = mounts793 let (source, _, options, within) = mounts
771 .lines()794 .lines()
772 .filter_map(|line| {795 .filter_map(|line| {
773 let mut fields = line.split_whitespace();796 let mut fields = line.split_whitespace();
...@@ -873,6 +896,25 @@ pub fn settings_dir() -> Option<PathBuf> {...@@ -873,6 +896,25 @@ pub fn settings_dir() -> Option<PathBuf> {
873 xdg_dir("XDG_CONFIG_HOME", ".config")896 xdg_dir("XDG_CONFIG_HOME", ".config")
874}897}
875898
899/// The XDG documents folder `user-dirs.dirs` names, or `~/Documents`.
900pub fn documents_dir() -> Option<PathBuf> {
901 let home = PathBuf::from(std::env::var_os("HOME")?);
902 let dirs = xdg_base("XDG_CONFIG_HOME", ".config")?.join("user-dirs.dirs");
903 let named = std::fs::read_to_string(dirs).ok().and_then(|text| {
904 text.lines().find_map(|line| {
905 let value = line
906 .strip_prefix("XDG_DOCUMENTS_DIR=")?
907 .trim()
908 .trim_matches('"');
909 match value.strip_prefix("$HOME") {
910 Some(rest) => Some(home.join(rest.trim_start_matches('/'))),
911 None => Some(PathBuf::from(value)).filter(|path| path.is_absolute()),
912 }
913 })
914 });
915 Some(named.unwrap_or_else(|| home.join("Documents")))
916}
917
876/// The app's folder in the XDG base directory `variable` names, or in `fallback` under home.918/// The app's folder in the XDG base directory `variable` names, or in `fallback` under home.
877fn xdg_dir(variable: &str, fallback: &str) -> Option<PathBuf> {919fn xdg_dir(variable: &str, fallback: &str) -> Option<PathBuf> {
878 Some(xdg_base(variable, fallback)?.join("snowbound"))920 Some(xdg_base(variable, fallback)?.join("snowbound"))
crates/snowbound/src/macos.rs+76-6
...@@ -109,6 +109,9 @@ pub fn corner_radius(_: &Window) -> f32 {...@@ -109,6 +109,9 @@ pub fn corner_radius(_: &Window) -> f32 {
109109
110/// AppKit clips the window's corners itself, except where the OpenGL surface covers110/// AppKit clips the window's corners itself, except where the OpenGL surface covers
111/// 10.6's textured window, whose rounded bottom corners the app leaves transparent.111/// 10.6's textured window, whose rounded bottom corners the app leaves transparent.
112/// Winit's theme already holds the window to the app's appearance.
113pub fn follow_appearance(_: &Window, _: winit::window::Theme) {}
114
112pub fn cuts_corners() -> bool {115pub fn cuts_corners() -> bool {
113 crate::aqua::before_lion()116 crate::aqua::before_lion()
114}117}
...@@ -185,6 +188,50 @@ impl Clipboard {...@@ -185,6 +188,50 @@ impl Clipboard {
185 pub fn get_text(&mut self) -> Result<String, arboard::Error> {188 pub fn get_text(&mut self) -> Result<String, arboard::Error> {
186 self.0.get_text()189 self.0.get_text()
187 }190 }
191
192 pub fn get_files(&mut self) -> Vec<std::path::PathBuf> {
193 self.0.get().file_list().unwrap_or_default()
194 }
195
196 /// The pasteboard's PNG as copied, keeping its resolution, or else its TIFF as a PNG.
197 pub fn get_picture(&mut self) -> Option<Vec<u8>> {
198 unsafe {
199 let board: Retained<AnyObject> =
200 msg_send_id![AnyClass::get("NSPasteboard")?, generalPasteboard];
201 let data = |kind: &str| -> Option<Retained<AnyObject>> {
202 msg_send_id![&board, dataForType: &*NSString::from_str(kind)]
203 };
204 let png = match data("public.png") {
205 Some(png) => png,
206 None => tiff_png(&*data("public.tiff")?)?,
207 };
208 Some(ns_data_bytes(&png))
209 }
210 }
211}
212
213/// TIFF `data` as PNG data, keeping its resolution.
214unsafe fn tiff_png(data: &AnyObject) -> Option<Retained<AnyObject>> {
215 const PNG: usize = 4; // NSBitmapImageFileTypePNG
216 unsafe {
217 let rep: Option<Retained<AnyObject>> =
218 msg_send_id![AnyClass::get("NSBitmapImageRep")?, imageRepWithData: data];
219 let rep = rep?;
220 let properties: Retained<AnyObject> =
221 msg_send_id![AnyClass::get("NSDictionary")?, dictionary];
222 msg_send_id![&rep, representationUsingType: PNG, properties: &*properties]
223 }
224}
225
226unsafe fn ns_data_bytes(data: &AnyObject) -> Vec<u8> {
227 unsafe {
228 let length: usize = msg_send![data, length];
229 if length == 0 {
230 return Vec::new();
231 }
232 let bytes: *const u8 = msg_send![data, bytes];
233 std::slice::from_raw_parts(bytes, length).to_vec()
234 }
188}235}
189236
190/// AppKit draws the traffic lights.237/// AppKit draws the traffic lights.
...@@ -246,9 +293,14 @@ pub fn represent(window: &Window, file: Option<&std::path::Path>) {...@@ -246,9 +293,14 @@ pub fn represent(window: &Window, file: Option<&std::path::Path>) {
246 }293 }
247}294}
248295
249/// The theme over the window's backdrop, as the kit draws it.296/// The theme over the window's backdrop, as the kit draws it. 10.6's textured gradient is
250pub fn over_backdrop(theme: ui::Theme) -> ui::Theme {297/// light only, so a dark theme paints over it.
251 theme.over_backdrop()298pub fn over_backdrop(theme: ui::Theme, appearance: winit::window::Theme) -> ui::Theme {
299 if crate::aqua::before_lion() && appearance == winit::window::Theme::Dark {
300 theme
301 } else {
302 theme.over_backdrop()
303 }
252}304}
253305
254/// None: the kit's own menus stand in for AppKit's.306/// None: the kit's own menus stand in for AppKit's.
...@@ -634,6 +686,10 @@ pub fn settings_dir() -> Option<std::path::PathBuf> {...@@ -634,6 +686,10 @@ pub fn settings_dir() -> Option<std::path::PathBuf> {
634 )686 )
635}687}
636688
689pub fn documents_dir() -> Option<std::path::PathBuf> {
690 Some(std::path::PathBuf::from(std::env::var_os("HOME")?).join("Documents"))
691}
692
637/// Asks for a notebook folder or a notebook file with the system's open panel, titled693/// Asks for a notebook folder or a notebook file with the system's open panel, titled
638/// `title`.694/// `title`.
639pub fn pick_notebook(title: &str) -> Option<std::path::PathBuf> {695pub fn pick_notebook(title: &str) -> Option<std::path::PathBuf> {
...@@ -899,7 +955,10 @@ pub fn edit_date(...@@ -899,7 +955,10 @@ pub fn edit_date(
899 DateField::Date => NSDatePickerElementFlags::NSDatePickerElementFlagYearMonthDay,955 DateField::Date => NSDatePickerElementFlags::NSDatePickerElementFlagYearMonthDay,
900 DateField::Time => NSDatePickerElementFlags::NSDatePickerElementFlagHourMinute,956 DateField::Time => NSDatePickerElementFlags::NSDatePickerElementFlagHourMinute,
901 });957 });
902 picker.setPresentsCalendarOverlay(true);958 // macOS 10.15's.
959 if msg_send![&picker, respondsToSelector: sel!(setPresentsCalendarOverlay:)] {
960 picker.setPresentsCalendarOverlay(true);
961 }
903 picker.setCalendar(Some(&calendar));962 picker.setCalendar(Some(&calendar));
904 picker.setTimeZone(Some(&calendar.timeZone()));963 picker.setTimeZone(Some(&calendar.timeZone()));
905 picker.setDateValue(&NSDate::dateWithTimeIntervalSince1970(964 picker.setDateValue(&NSDate::dateWithTimeIntervalSince1970(
...@@ -1106,10 +1165,21 @@ pub fn text_colors(window: &Window) -> [[f32; 4]; 3] {...@@ -1106,10 +1165,21 @@ pub fn text_colors(window: &Window) -> [[f32; 4]; 3] {
1106 let previous: Option<Retained<AnyObject>> = msg_send_id![class, currentAppearance];1165 let previous: Option<Retained<AnyObject>> = msg_send_id![class, currentAppearance];
1107 let _: () = msg_send![class, setCurrentAppearance: &*appearance];1166 let _: () = msg_send![class, setCurrentAppearance: &*appearance];
1108 let space = NSColorSpace::sRGBColorSpace();1167 let space = NSColorSpace::sRGBColorSpace();
1168 let responds =
1169 |selector: Sel| -> bool { msg_send![NSColor::class(), respondsToSelector: selector] };
1170 // macOS 14 and 10.14 introduced the first and last.
1109 let colors = [1171 let colors = [
1110 NSColor::textInsertionPointColor(),1172 if responds(sel!(textInsertionPointColor)) {
1173 NSColor::textInsertionPointColor()
1174 } else {
1175 NSColor::textColor()
1176 },
1111 NSColor::selectedTextBackgroundColor(),1177 NSColor::selectedTextBackgroundColor(),
1112 NSColor::unemphasizedSelectedTextBackgroundColor(),1178 if responds(sel!(unemphasizedSelectedTextBackgroundColor)) {
1179 NSColor::unemphasizedSelectedTextBackgroundColor()
1180 } else {
1181 msg_send_id![NSColor::class(), secondarySelectedControlColor]
1182 },
1113 ]1183 ]
1114 .map(|color| {1184 .map(|color| {
1115 let color = color1185 let color = color
crates/snowbound/src/main.rs+115-28
...@@ -10,6 +10,7 @@ mod conflict_render;...@@ -10,6 +10,7 @@ mod conflict_render;
10#[cfg(target_os = "linux")]10#[cfg(target_os = "linux")]
11#[path = "desktop_linux.rs"]11#[path = "desktop_linux.rs"]
12mod desktop;12mod desktop;
13mod guide;
13mod history;14mod history;
14#[cfg_attr(not(target_os = "macos"), path = "icloud_linux.rs")]15#[cfg_attr(not(target_os = "macos"), path = "icloud_linux.rs")]
15#[cfg_attr(target_os = "macos", path = "icloud_macos.rs")]16#[cfg_attr(target_os = "macos", path = "icloud_macos.rs")]
...@@ -29,10 +30,16 @@ mod navigation;...@@ -29,10 +30,16 @@ mod navigation;
29mod options;30mod options;
30mod palette;31mod palette;
31mod pane;32mod pane;
33mod paste;
32#[cfg_attr(target_os = "linux", path = "linux.rs")]34#[cfg_attr(target_os = "linux", path = "linux.rs")]
33#[cfg_attr(target_os = "macos", path = "macos.rs")]35#[cfg_attr(target_os = "macos", path = "macos.rs")]
34#[cfg_attr(windows, path = "windows.rs")]36#[cfg_attr(windows, path = "windows.rs")]
35mod platform;37mod platform;
38mod print;
39#[cfg_attr(target_os = "linux", path = "print_linux.rs")]
40#[cfg_attr(target_os = "macos", path = "print_macos.rs")]
41#[cfg_attr(windows, path = "print_windows.rs")]
42mod printer;
36mod recording;43mod recording;
37mod rename;44mod rename;
38mod screenshot;45mod screenshot;
...@@ -96,6 +103,9 @@ const TAB_ROW: f32 = 28.0;...@@ -96,6 +103,9 @@ const TAB_ROW: f32 = 28.0;
96/// Width of the section colour around the page, and the rows' margin at the window's sides.103/// Width of the section colour around the page, and the rows' margin at the window's sides.
97const FRAME: f32 = 6.0;104const FRAME: f32 = 6.0;
98const PAGE_LIST: f32 = 240.0;105const PAGE_LIST: f32 = 240.0;
106/// The least window: the page list and a page beside it. The window widens past it to the
107/// toolbar's narrowest row, the window's controls beside it.
108const MIN_SIZE: [f32; 2] = [600.0, 400.0];
99/// Extensions of the pictures Insert, Picture offers: those the page both stores and draws.109/// Extensions of the pictures Insert, Picture offers: those the page both stores and draws.
100const PICTURE_TYPES: [&str; 4] = ["png", "jpg", "jpeg", "gif"];110const PICTURE_TYPES: [&str; 4] = ["png", "jpg", "jpeg", "gif"];
101/// Font sizes the size box offers, OneNote's list in points.111/// Font sizes the size box offers, OneNote's list in points.
...@@ -163,7 +173,8 @@ const PILL_MARGIN: f32 = 3.0;...@@ -163,7 +173,8 @@ const PILL_MARGIN: f32 = 3.0;
163const DATE_UNCHOSEN: &str = "Choose another date or time.";173const DATE_UNCHOSEN: &str = "Choose another date or time.";
164const DATE_OUT_OF_RANGE: &str = "This date is outside the notebook's supported range.";174const DATE_OUT_OF_RANGE: &str = "This date is outside the notebook's supported range.";
165175
166#[derive(Debug)]176type Continuation = Box<dyn FnOnce(&mut State) -> Result<(), Box<dyn Error>> + Send>;
177
167enum UserEvent {178enum UserEvent {
168 Quit,179 Quit,
169 /// Text AppKit inserts outside key events, such as the character palette's.180 /// Text AppKit inserts outside key events, such as the character palette's.
...@@ -187,6 +198,8 @@ enum UserEvent {...@@ -187,6 +198,8 @@ enum UserEvent {
187 /// The desktop's colours changed, where the window system doesn't say so itself.198 /// The desktop's colours changed, where the window system doesn't say so itself.
188 #[cfg_attr(target_os = "macos", allow(dead_code))]199 #[cfg_attr(target_os = "macos", allow(dead_code))]
189 Appearance,200 Appearance,
201 /// What follows work a thread of its own finished, run on the event loop's.
202 Then(Continuation),
190 /// The iCloud account signed out, signed in or switched, or iCloud Drive was turned off.203 /// The iCloud account signed out, signed in or switched, or iCloud Drive was turned off.
191 #[cfg_attr(not(target_os = "macos"), allow(dead_code))]204 #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
192 ICloudAccount,205 ICloudAccount,
...@@ -209,11 +222,21 @@ impl Clipboard {...@@ -209,11 +222,21 @@ impl Clipboard {
209 Ok(())222 Ok(())
210 }223 }
211224
212 fn get_text(&mut self) -> Result<String, Box<dyn Error>> {225 fn pasted(&mut self) -> Option<paste::Pasted> {
213 Ok(match self {226 match self {
214 Self::System(clipboard) => clipboard.get_text()?,227 Self::System(clipboard) => {
215 Self::Memory(held) => held.clone(),228 let files = clipboard.get_files();
216 })229 if !files.is_empty() {
230 Some(paste::Pasted::Files(files))
231 } else if let Some(text) = clipboard.get_text().ok().filter(|text| !text.is_empty())
232 {
233 Some(paste::Pasted::Text(text))
234 } else {
235 clipboard.get_picture().map(paste::Pasted::Picture)
236 }
237 }
238 Self::Memory(held) => Some(paste::Pasted::Text(held.clone())),
239 }
217 }240 }
218}241}
219242
...@@ -226,6 +249,8 @@ impl std::task::Wake for Redraw {...@@ -226,6 +249,8 @@ impl std::task::Wake for Redraw {
226#[derive(Debug)]249#[derive(Debug)]
227enum Replay {250enum Replay {
228 Input(ui::Event),251 Input(ui::Event),
252 /// A trackpad pinch scaling the page by the factor.
253 Pinch(f32),
229 /// Paints the next frame into a PNG as well as the window.254 /// Paints the next frame into a PNG as well as the window.
230 Snapshot(PathBuf),255 Snapshot(PathBuf),
231 /// Writes the window's accessibility tree as text.256 /// Writes the window's accessibility tree as text.
...@@ -496,6 +521,8 @@ enum Command {...@@ -496,6 +521,8 @@ enum Command {
496 NewNotebook,521 NewNotebook,
497 /// Opens the notebooks in the app's iCloud Drive folder, making one there if it has none.522 /// Opens the notebooks in the app's iCloud Drive folder, making one there if it has none.
498 UseICloud,523 UseICloud,
524 /// Opens the Snowbound Guide from the user's documents, copying it there first.
525 OpenGuide,
499 /// Adds Snowbound to the app menu.526 /// Adds Snowbound to the app menu.
500 #[cfg(target_os = "linux")]527 #[cfg(target_os = "linux")]
501 Install,528 Install,
...@@ -622,6 +649,14 @@ struct State {...@@ -622,6 +649,14 @@ struct State {
622 hide_spelling: bool,649 hide_spelling: bool,
623 /// Options' "Use pen pressure sensitivity": a tablet pen's strokes follow its pressure.650 /// Options' "Use pen pressure sensitivity": a tablet pen's strokes follow its pressure.
624 pen_pressure: bool,651 pen_pressure: bool,
652 /// Options' Default font, which new text and titles take.
653 default_font: settings::DefaultFont,
654 /// Options' "Page tabs appear on the left".
655 page_tabs_left: bool,
656 /// Options' "Navigation bar appears on the left" off: the notebooks on the right.
657 navigation_bar_right: bool,
658 /// Servers Open Notebook from Server signed in to, latest first.
659 servers: Vec<String>,
625 /// The word the Spelling pane shows.660 /// The word the Spelling pane shows.
626 correction: Option<canvas::interaction::Correction>,661 correction: Option<canvas::interaction::Correction>,
627 /// The strip's fill with the window focused and not, continuing the system's title bar.662 /// The strip's fill with the window focused and not, continuing the system's title bar.
...@@ -681,6 +716,8 @@ struct State {...@@ -681,6 +716,8 @@ struct State {
681 initial_layouts: Vec<(onestore::ExGuid, onestore::document::Layout)>,716 initial_layouts: Vec<(onestore::ExGuid, onestore::document::Layout)>,
682 initial_date: Option<u64>,717 initial_date: Option<u64>,
683 occluded: bool,718 occluded: bool,
719 /// The least width last given the window.
720 min_width: f32,
684 ime_allowed: bool,721 ime_allowed: bool,
685 clipboard: Clipboard,722 clipboard: Clipboard,
686 access_adapter: accesskit_winit::Adapter,723 access_adapter: accesskit_winit::Adapter,
...@@ -746,8 +783,7 @@ impl State {...@@ -746,8 +783,7 @@ impl State {
746 Input::Notebooks { .. } => "Snowbound".into(),783 Input::Notebooks { .. } => "Snowbound".into(),
747 })784 })
748 .with_inner_size(LogicalSize::new(1180.0, 760.0))785 .with_inner_size(LogicalSize::new(1180.0, 760.0))
749 // The page list and a page beside it, under the toolbar's text group.786 .with_min_inner_size(LogicalSize::new(MIN_SIZE[0], MIN_SIZE[1])),
750 .with_min_inner_size(LogicalSize::new(600.0, 400.0)),
751 )?,787 )?,
752 );788 );
753 platform::install_title_bar(&window);789 platform::install_title_bar(&window);
...@@ -872,6 +908,7 @@ impl State {...@@ -872,6 +908,7 @@ impl State {
872 .color_scheme908 .color_scheme
873 .theme()909 .theme()
874 .unwrap_or_else(|| platform::appearance(&window));910 .unwrap_or_else(|| platform::appearance(&window));
911 platform::follow_appearance(&window, appearance);
875 let mut ui = Ui::new(912 let mut ui = Ui::new(
876 theme(appearance, stored.light_pages, backdrop),913 theme(appearance, stored.light_pages, backdrop),
877 platform::double_click_interval(),914 platform::double_click_interval(),
...@@ -975,6 +1012,7 @@ impl State {...@@ -975,6 +1012,7 @@ impl State {
975 initial_date,1012 initial_date,
976 initial_layouts,1013 initial_layouts,
977 occluded: false,1014 occluded: false,
1015 min_width: MIN_SIZE[0],
978 ime_allowed: true,1016 ime_allowed: true,
979 clipboard,1017 clipboard,
980 loads: mpsc::channel(),1018 loads: mpsc::channel(),
...@@ -991,8 +1029,13 @@ impl State {...@@ -991,8 +1029,13 @@ impl State {
991 spelling,1029 spelling,
992 hide_spelling: stored.hide_spelling,1030 hide_spelling: stored.hide_spelling,
993 pen_pressure: !stored.ignore_pen_pressure,1031 pen_pressure: !stored.ignore_pen_pressure,
1032 default_font: stored.default_font,
1033 page_tabs_left: stored.page_tabs_left,
1034 navigation_bar_right: stored.navigation_bar_right,
1035 servers: stored.servers,
994 correction: None,1036 correction: None,
995 };1037 };
1038 state.view.snap_to_grid = !stored.ignore_grid;
996 // A notebook opened from its server that couldn't sign in asks to, as the Finder does.1039 // A notebook opened from its server that couldn't sign in asks to, as the Finder does.
997 let unsigned = state.notebooks.iter().find(|library| {1040 let unsigned = state.notebooks.iter().find(|library| {
998 library.notebook.is_err() && library::server_address(&library.location).is_some()1041 library.notebook.is_err() && library::server_address(&library.location).is_some()
...@@ -1119,6 +1162,7 @@ impl State {...@@ -1119,6 +1162,7 @@ impl State {
11191162
1120 /// Takes `appearance`'s colours.1163 /// Takes `appearance`'s colours.
1121 fn set_appearance(&mut self, appearance: winit::window::Theme) {1164 fn set_appearance(&mut self, appearance: winit::window::Theme) {
1165 platform::follow_appearance(&self.window, appearance);
1122 self.ui.theme = theme(appearance, self.light_pages, self.surface.translucent());1166 self.ui.theme = theme(appearance, self.light_pages, self.surface.translucent());
1123 self.titlebar = platform::titlebar(appearance)1167 self.titlebar = platform::titlebar(appearance)
1124 .filter(|_| !self.surface.translucent())1168 .filter(|_| !self.surface.translucent())
...@@ -1619,11 +1663,11 @@ impl State {...@@ -1619,11 +1663,11 @@ impl State {
1619 role: Some(accesskit::Role::Toolbar),1663 role: Some(accesskit::Role::Toolbar),
1620 ..Spec::default()1664 ..Spec::default()
1621 };1665 };
1622 if strip_row {1666 let row = if strip_row {
1623 self.ui.open_as(strip(), spec);1667 self.ui.open_as(strip(), spec)
1624 } else {1668 } else {
1625 self.ui.open("toolbar", spec);1669 self.ui.open("toolbar", spec)
1626 }1670 };
1627 let lead = if title { platform::LEADING } else { FRAME };1671 let lead = if title { platform::LEADING } else { FRAME };
1628 self.ui.leaf(1672 self.ui.leaf(
1629 "lead",1673 "lead",
...@@ -1633,7 +1677,22 @@ impl State {...@@ -1633,7 +1677,22 @@ impl State {
1633 },1677 },
1634 );1678 );
1635 if tools {1679 if tools {
1680 // The groups fold in the room the window's controls leave, and whatever still
1681 // overflows is cut there, so the controls always show.
1682 let edge = (TITLE - ui::shell::TOOL) / 2.0;
1683 self.ui.open(
1684 "tools",
1685 Spec {
1686 flags: Flags::CLIP,
1687 size: [fill(), px(TITLE)],
1688 pad: [0.0, edge],
1689 offset: [0.0, -edge],
1690 gap: GAP,
1691 ..Spec::default()
1692 },
1693 );
1636 self.tools(theme);1694 self.tools(theme);
1695 self.ui.close();
1637 } else {1696 } else {
1638 self.ui.leaf(1697 self.ui.leaf(
1639 "space",1698 "space",
...@@ -1654,6 +1713,15 @@ impl State {...@@ -1654,6 +1713,15 @@ impl State {
1654 },1713 },
1655 );1714 );
1656 self.ui.close();1715 self.ui.close();
1716 let narrowest = self
1717 .ui
1718 .narrowest(row)
1719 .map_or(MIN_SIZE[0], |row| row.ceil().max(MIN_SIZE[0]));
1720 if narrowest != self.min_width {
1721 self.min_width = narrowest;
1722 self.window
1723 .set_min_inner_size(Some(LogicalSize::new(narrowest, MIN_SIZE[1])));
1724 }
1657 Ok(())1725 Ok(())
1658 }1726 }
16591727
...@@ -2146,13 +2214,17 @@ impl State {...@@ -2146,13 +2214,17 @@ impl State {
2146 )2214 )
2147 .or(choice);2215 .or(choice);
2148 // The Draw tab's tools, which Insert's menus list for when they fold, before Insert.2216 // The Draw tab's tools, which Insert's menus list for when they fold, before Insert.
2217 // OneNote's shape gallery ends with Snap To Grid.
2149 let shapes = [2218 let shapes = [
2150 ShapeKind::Line,2219 ShapeKind::Line,
2151 ShapeKind::Arrow,2220 ShapeKind::Arrow,
2152 ShapeKind::Rectangle,2221 ShapeKind::Rectangle,
2153 ShapeKind::Ellipse,2222 ShapeKind::Ellipse,
2154 ]2223 ]
2155 .map(|kind| Run(Cmd::Shape(kind)));2224 .map(|kind| Run(Cmd::Shape(kind)))
2225 .into_iter()
2226 .chain([Rule, Run(Cmd::SnapToGrid)])
2227 .collect::<Vec<_>>();
2156 let drawing = [2228 let drawing = [
2157 Run(Cmd::SelectType),2229 Run(Cmd::SelectType),
2158 Open("pen", Cmd::Pen),2230 Open("pen", Cmd::Pen),
...@@ -2161,7 +2233,7 @@ impl State {...@@ -2161,7 +2233,7 @@ impl State {
2161 Rule,2233 Rule,
2162 ]2234 ]
2163 .into_iter()2235 .into_iter()
2164 .chain(shapes);2236 .chain(shapes.iter().copied());
2165 let inserted = [Cmd::Picture, Cmd::Link, Cmd::Date, Cmd::Equation];2237 let inserted = [Cmd::Picture, Cmd::Link, Cmd::Date, Cmd::Equation];
2166 let more = [2238 let more = [
2167 Cmd::ScreenClipping,2239 Cmd::ScreenClipping,
...@@ -2320,7 +2392,7 @@ impl State {...@@ -2320,7 +2392,7 @@ impl State {
2320 let views = [2392 let views = [
2321 Run(Cmd::Sidebar),2393 Run(Cmd::Sidebar),
2322 Run(Cmd::PageList),2394 Run(Cmd::PageList),
2323 Run(Cmd::DarkPages),2395 Run(Cmd::PagesMatchTheme),
2324 Run(Cmd::FullPageView),2396 Run(Cmd::FullPageView),
2325 Rule,2397 Rule,
2326 Run(Cmd::HideSpelling),2398 Run(Cmd::HideSpelling),
...@@ -2702,6 +2774,7 @@ impl State {...@@ -2702,6 +2774,7 @@ impl State {
2702 Command::OpenFromServer(location) => self.open_server(location.as_deref()),2774 Command::OpenFromServer(location) => self.open_server(location.as_deref()),
2703 Command::NewNotebook => self.new_notebook()?,2775 Command::NewNotebook => self.new_notebook()?,
2704 Command::UseICloud => self.use_icloud()?,2776 Command::UseICloud => self.use_icloud()?,
2777 Command::OpenGuide => self.open_guide()?,
2705 #[cfg(target_os = "linux")]2778 #[cfg(target_os = "linux")]
2706 Command::Install => desktop::install(),2779 Command::Install => desktop::install(),
2707 Command::CloseNotebook(library) => self.close_notebook(&library),2780 Command::CloseNotebook(library) => self.close_notebook(&library),
...@@ -2723,12 +2796,7 @@ impl State {...@@ -2723,12 +2796,7 @@ impl State {
2723 Command::Template(choice) => self.apply_template(choice)?,2796 Command::Template(choice) => self.apply_template(choice)?,
2724 Command::Page(Request::EditDate(field)) => self.edit_date(field)?,2797 Command::Page(Request::EditDate(field)) => self.edit_date(field)?,
2725 Command::Page(Request::Copy(text)) => self.clipboard.set_text(text)?,2798 Command::Page(Request::Copy(text)) => self.clipboard.set_text(text)?,
2726 Command::Page(Request::Paste) => {2799 Command::Page(Request::Paste) => self.paste()?,
2727 let text = self.clipboard.get_text()?;
2728 let language = canvas::language::lcid(&platform::input_language());
2729 let response = self.view.paste(&text, language)?;
2730 self.respond(response);
2731 }
2732 Command::Page(Request::OpenLink(address)) => self.open_link(&address)?,2800 Command::Page(Request::OpenLink(address)) => self.open_link(&address)?,
2733 Command::Page(Request::OpenAttachment(file)) => self.open_attachment(&file)?,2801 Command::Page(Request::OpenAttachment(file)) => self.open_attachment(&file)?,
2734 Command::Page(Request::Play { file, at_ms }) => self.play(&file, at_ms)?,2802 Command::Page(Request::Play { file, at_ms }) => self.play(&file, at_ms)?,
...@@ -3608,7 +3676,14 @@ impl State {...@@ -3608,7 +3676,14 @@ impl State {
3608 /// Zooms the page by `factor` about the pointer, when it is over the page.3676 /// Zooms the page by `factor` about the pointer, when it is over the page.
3609 fn pinch(&mut self, factor: f32) -> Result<(), Box<dyn Error>> {3677 fn pinch(&mut self, factor: f32) -> Result<(), Box<dyn Error>> {
3610 if self.over_page() {3678 if self.over_page() {
3611 let response = self.view.pinch(factor)?;3679 // The pointer here, as the page's own lags by the moves queued for the next frame.
3680 let [left, top, ..] = self.ui.rect(page()).unwrap_or_default();
3681 let scale = self.ui.scale();
3682 let anchor = [
3683 (self.pointer[0] - left) * scale,
3684 (self.pointer[1] - top) * scale,
3685 ];
3686 let response = self.view.pinch(factor, anchor)?;
3612 self.respond(response);3687 self.respond(response);
3613 }3688 }
3614 Ok(())3689 Ok(())
...@@ -3706,7 +3781,7 @@ fn theme(appearance: winit::window::Theme, light_pages: bool, backdrop: bool) ->...@@ -3706,7 +3781,7 @@ fn theme(appearance: winit::window::Theme, light_pages: bool, backdrop: bool) ->
3706 [theme.paper, theme.paper_ink] = [light.paper, light.paper_ink];3781 [theme.paper, theme.paper_ink] = [light.paper, light.paper_ink];
3707 }3782 }
3708 if backdrop {3783 if backdrop {
3709 platform::over_backdrop(theme)3784 platform::over_backdrop(theme, appearance)
3710 } else {3785 } else {
3711 theme3786 theme
3712 }3787 }
...@@ -4921,6 +4996,15 @@ impl ApplicationHandler<UserEvent> for App {...@@ -4921,6 +4996,15 @@ impl ApplicationHandler<UserEvent> for App {
4921 self.close(event_loop);4996 self.close(event_loop);
4922 return;4997 return;
4923 }4998 }
4999 UserEvent::Then(then) => {
5000 if let Some(state) = &mut self.state {
5001 if let Err(error) = then(state) {
5002 eprintln!("{error}");
5003 }
5004 state.window.request_redraw();
5005 }
5006 return;
5007 }
4924 UserEvent::ICloudAccount => {5008 UserEvent::ICloudAccount => {
4925 if let Some(state) = &mut self.state {5009 if let Some(state) = &mut self.state {
4926 state.icloud_account_changed();5010 state.icloud_account_changed();
...@@ -4966,6 +5050,11 @@ impl ApplicationHandler<UserEvent> for App {...@@ -4966,6 +5050,11 @@ impl ApplicationHandler<UserEvent> for App {
4966 if let Some(state) = &mut self.state {5050 if let Some(state) = &mut self.state {
4967 match replay {5051 match replay {
4968 Replay::Input(event) => state.input(event),5052 Replay::Input(event) => state.input(event),
5053 Replay::Pinch(factor) => {
5054 if let Err(error) = state.pinch(factor) {
5055 eprintln!("{error}");
5056 }
5057 }
4969 Replay::Snapshot(path) => state.snapshot = Some(path),5058 Replay::Snapshot(path) => state.snapshot = Some(path),
4970 Replay::Accessibility(path) => {5059 Replay::Accessibility(path) => {
4971 if let Err(error) = state.write_accessibility(&path) {5060 if let Err(error) = state.write_accessibility(&path) {
...@@ -5202,16 +5291,13 @@ impl ApplicationHandler<UserEvent> for App {...@@ -5202,16 +5291,13 @@ impl ApplicationHandler<UserEvent> for App {
52025291
5203fn write_png(path: &Path, size: [u32; 2], pixels: &[u8]) -> Result<(), Box<dyn Error>> {5292fn write_png(path: &Path, size: [u32; 2], pixels: &[u8]) -> Result<(), Box<dyn Error>> {
5204 let partial = path.with_extension("partial");5293 let partial = path.with_extension("partial");
5205 let mut encoder = png::Encoder::new(std::fs::File::create(&partial)?, size[0], size[1]);5294 std::fs::write(&partial, paste::png(size, pixels)?)?;
5206 encoder.set_color(png::ColorType::Rgba);
5207 encoder.set_depth(png::BitDepth::Eight);
5208 encoder.write_header()?.write_image_data(pixels)?;
5209 std::fs::rename(partial, path)?;5295 std::fs::rename(partial, path)?;
5210 Ok(())5296 Ok(())
5211}5297}
52125298
5213/// Feeds a development script to the window from another thread, one command per line5299/// Feeds a development script to the window from another thread, one command per line
5214/// in logical pixels: `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `key NAME`, `type5300/// in logical pixels: `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `pinch FACTOR`, `key NAME`, `type
5215/// TEXT`, `modifiers [shift] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`,5301/// TEXT`, `modifiers [shift] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`,
5216/// `accessibility TEXT_PATH`, `appearance light|dark`, `resize WIDTH HEIGHT` and `quit`.5302/// `accessibility TEXT_PATH`, `appearance light|dark`, `resize WIDTH HEIGHT` and `quit`.
5217fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dyn Error>> {5303fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dyn Error>> {
...@@ -5241,6 +5327,7 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy...@@ -5241,6 +5327,7 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy
5241 "none" => None,5327 "none" => None,
5242 level => Some(level.parse()?),5328 level => Some(level.parse()?),
5243 }))),5329 }))),
5330 "pinch" => Ok(Replay::Pinch(rest.parse()?)),
5244 "press" => Ok(Replay::Input(button(true))),5331 "press" => Ok(Replay::Input(button(true))),
5245 "release" => Ok(Replay::Input(button(false))),5332 "release" => Ok(Replay::Input(button(false))),
5246 "key" => Ok(Replay::Input(ui::Event::Key {5333 "key" => Ok(Replay::Input(ui::Event::Key {
crates/snowbound/src/manage.rs+61-27
...@@ -7,7 +7,7 @@ use notebook::session::Notebook;...@@ -7,7 +7,7 @@ use notebook::session::Notebook;
7use onestore::{7use onestore::{
8 ExGuid, PageCreation, PageEdit,8 ExGuid, PageCreation, PageEdit,
9 op::{Edit, Op, PageOp, SectionOp},9 op::{Edit, Op, PageOp, SectionOp},
10 page::{Page, PageObject},10 page::{Image, Page, PageObject},
11};11};
12use std::{error::Error, sync::Arc};12use std::{error::Error, sync::Arc};
1313
...@@ -124,24 +124,59 @@ impl State {...@@ -124,24 +124,59 @@ impl State {
124 &mut self,124 &mut self,
125 choice: crate::templates::Choice,125 choice: crate::templates::Choice,
126 ) -> Result<(), Box<dyn Error>> {126 ) -> Result<(), Box<dyn Error>> {
127 self.persist()?;127 use crate::templates::Choice;
128 let session = self.session.as_ref().ok_or("No section is open")?;128 let template = match choice {
129 let ops = template_ops(&session.section.page(session.space)?, choice)?;129 Choice::Template(name) => {
130 self.edit_page(ops)130 Some(canvas::template::find(name).ok_or("That template is not available")?)
131 }
132 _ => None,
133 };
134 self.with_art(template, move |state, art| {
135 state.persist()?;
136 let session = state.session.as_ref().ok_or("No section is open")?;
137 let ops = template_ops(&session.section.page(session.space)?, choice, art)?;
138 state.edit_page(ops)
139 })
140 }
141
142 /// Calls `then` with `template`'s pictures, or none without one. Rasterizing and encoding
143 /// them takes seconds on a slow machine, so they are made on a thread of their own, and
144 /// `then` runs on this one after if the page they were chosen for is still open.
145 pub(crate) fn with_art(
146 &mut self,
147 template: Option<&'static Template>,
148 then: impl FnOnce(&mut State, Vec<Image>) -> Result<(), Box<dyn Error>> + Send + 'static,
149 ) -> Result<(), Box<dyn Error>> {
150 let Some(template) = template else {
151 return then(self, Vec::new());
152 };
153 let space = self.session.as_ref().ok_or("No section is open")?.space;
154 let proxy = self.proxy.clone();
155 std::thread::spawn(move || {
156 let art = template.pictures().map_err(|error| error.to_string());
157 let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| {
158 if state
159 .session
160 .as_ref()
161 .is_some_and(|open| open.space == space)
162 {
163 then(state, art?)
164 } else {
165 Ok(())
166 }
167 })));
168 });
169 Ok(())
131 }170 }
132171
133 /// View, Page Color and Rule Lines: gives the open page `color`, a COLORREF,172 /// View, Page Color and Rule Lines: gives the open page `color`, a COLORREF,
134 /// `rule_lines` and, when given, `template`'s art or none in place of the art it had, as173 /// `rule_lines` and, when given, `art` in place of the art it had, as one undo step.
135 /// one undo step.
136 pub(crate) fn paper_page(174 pub(crate) fn paper_page(
137 &mut self,175 &mut self,
138 color: Option<u32>,176 color: Option<u32>,
139 rule_lines: Option<onestore::page::RuleLines>,177 rule_lines: Option<onestore::page::RuleLines>,
140 art: Option<Option<&Template>>,178 art: Option<Vec<Image>>,
141 ) -> Result<(), Box<dyn Error>> {179 ) -> Result<(), Box<dyn Error>> {
142 let art = art
143 .map(|template| template.map_or(Ok(Vec::new()), Template::pictures))
144 .transpose()?;
145 let response = self.view.set_paper(color, rule_lines, art)?;180 let response = self.view.set_paper(color, rule_lines, art)?;
146 self.respond(response);181 self.respond(response);
147 Ok(())182 Ok(())
...@@ -507,14 +542,11 @@ impl State {...@@ -507,14 +542,11 @@ impl State {
507pub fn template_ops(542pub fn template_ops(
508 page: &Page,543 page: &Page,
509 choice: crate::templates::Choice,544 choice: crate::templates::Choice,
545 art: Vec<Image>,
510) -> Result<Vec<PageOp>, Box<dyn Error>> {546) -> Result<Vec<PageOp>, Box<dyn Error>> {
511 use crate::templates::Choice;547 use crate::templates::Choice;
512 let mut ops = match choice {548 let mut ops = match choice {
513 Choice::Template(name) => art_ops(549 Choice::Template(_) | Choice::Color(_) => art_ops(page, art),
514 page,
515 Some(canvas::template::find(name).ok_or("That template is not available")?),
516 )?,
517 Choice::Color(_) => art_ops(page, None)?,
518 Choice::More | Choice::Dismiss | Choice::Colors => return Ok(Vec::new()),550 Choice::More | Choice::Dismiss | Choice::Colors => return Ok(Vec::new()),
519 };551 };
520 match choice {552 match choice {
...@@ -538,9 +570,9 @@ pub fn template_ops(...@@ -538,9 +570,9 @@ pub fn template_ops(
538 Ok(ops)570 Ok(ops)
539}571}
540572
541/// The ops giving `page` `template`'s art (OneNote's pictures' places, our recreations'573/// The ops giving `page` `art`, a template's pictures, in place of the background pictures
542/// bytes), or none, in place of the background pictures it had.574/// it had.
543pub fn art_ops(page: &Page, template: Option<&Template>) -> Result<Vec<PageOp>, Box<dyn Error>> {575fn art_ops(page: &Page, art: Vec<Image>) -> Vec<PageOp> {
544 let mut ops: Vec<PageOp> = page576 let mut ops: Vec<PageOp> = page
545 .objects577 .objects
546 .iter()578 .iter()
...@@ -560,13 +592,11 @@ pub fn art_ops(page: &Page, template: Option<&Template>) -> Result<Vec<PageOp>,...@@ -560,13 +592,11 @@ pub fn art_ops(page: &Page, template: Option<&Template>) -> Result<Vec<PageOp>,
560 _ => true,592 _ => true,
561 })593 })
562 .map(PageObject::id);594 .map(PageObject::id);
563 for image in template.map_or(Ok(Vec::new()), Template::pictures)? {595 ops.extend(art.into_iter().map(|image| PageOp::Add {
564 ops.push(PageOp::Add {596 object: PageObject::Image(image),
565 object: PageObject::Image(image),597 before: under,
566 before: under,598 }));
567 });599 ops
568 }
569 Ok(ops)
570}600}
571601
572#[cfg(test)]602#[cfg(test)]
...@@ -695,7 +725,11 @@ mod tests {...@@ -695,7 +725,11 @@ mod tests {
695 unreachable!()725 unreachable!()
696 };726 };
697 let background = |space: ExGuid, choice: Choice| {727 let background = |space: ExGuid, choice: Choice| {
698 template_ops(&page(&file, space), choice)728 let art = match choice {
729 Choice::Template(name) => canvas::template::find(name).unwrap().pictures().unwrap(),
730 _ => Vec::new(),
731 };
732 template_ops(&page(&file, space), choice, art)
699 .unwrap()733 .unwrap()
700 .into_iter()734 .into_iter()
701 .map(|op| Op::Page { space, op })735 .map(|op| Op::Page { space, op })
crates/snowbound/src/media_macos.rs+7-2
...@@ -232,9 +232,14 @@ fn refused(device: &str, pane: &str) -> String {...@@ -232,9 +232,14 @@ fn refused(device: &str, pane: &str) -> String {
232}232}
233233
234/// Whether the system refused media of `kind`: AVAuthorizationStatusRestricted or Denied.234/// Whether the system refused media of `kind`: AVAuthorizationStatusRestricted or Denied.
235/// Before macOS 10.14 it never asks, and nothing is refused.
235fn denied(kind: &NSString) -> bool {236fn denied(kind: &NSString) -> bool {
236 let status: isize =237 let device = class("AVCaptureDevice");
237 unsafe { msg_send![class("AVCaptureDevice"), authorizationStatusForMediaType: kind] };238 let selector = sel!(authorizationStatusForMediaType:);
239 if !unsafe { msg_send![device, respondsToSelector: selector] } {
240 return false;
241 }
242 let status: isize = unsafe { msg_send![device, authorizationStatusForMediaType: kind] };
238 matches!(status, 1 | 2)243 matches!(status, 1 | 2)
239}244}
240245
crates/snowbound/src/menubar.rs+6-1
...@@ -66,6 +66,11 @@ const MENUS: &[Item] = &[...@@ -66,6 +66,11 @@ const MENUS: &[Item] = &[
66 C(Id::PageVersions),66 C(Id::PageVersions),
67 C(Id::CopyPageLink),67 C(Id::CopyPageLink),
68 C(Id::ShowNotebook),68 C(Id::ShowNotebook),
69 S,
70 C(Id::ExportPdf),
71 C(Id::ExportSectionPdf),
72 S,
73 C(Id::Print),
69 ],74 ],
70 ),75 ),
71 Item::Menu(76 Item::Menu(
...@@ -111,7 +116,7 @@ const MENUS: &[Item] = &[...@@ -111,7 +116,7 @@ const MENUS: &[Item] = &[
111 C(Id::PageList),116 C(Id::PageList),
112 S,117 S,
113 C(Id::PageColor),118 C(Id::PageColor),
114 C(Id::DarkPages),119 C(Id::PagesMatchTheme),
115 C(Id::HideSpelling),120 C(Id::HideSpelling),
116 C(Id::Spelling),121 C(Id::Spelling),
117 ],122 ],
crates/snowbound/src/options.rs+5-11
...@@ -164,13 +164,13 @@ impl State {...@@ -164,13 +164,13 @@ impl State {
164 match options.page {164 match options.page {
165 Page::General => {165 Page::General => {
166 heading(ui, &theme, "User Interface Options");166 heading(ui, &theme, "User Interface Options");
167 field(ui, "Color scheme:", |ui| {167 field(ui, "Appearance:", |ui| {
168 let combo = ui.id("combo");168 let combo = ui.id("combo");
169 let current = SCHEMES169 let current = SCHEMES
170 .iter()170 .iter()
171 .find(|(scheme, _)| *scheme == options.color_scheme)171 .find(|(scheme, _)| *scheme == options.color_scheme)
172 .map_or("", |(_, name)| name);172 .map_or("", |(_, name)| name);
173 ui::shell::combo(ui, "combo", "Color scheme", current, 140.0, schemes(), true);173 ui::shell::combo(ui, "combo", "Appearance", current, 140.0, schemes(), true);
174 let items = SCHEMES.map(|(scheme, name)| Item {174 let items = SCHEMES.map(|(scheme, name)| Item {
175 text: name,175 text: name,
176 checked: Some(scheme == options.color_scheme),176 checked: Some(scheme == options.color_scheme),
...@@ -244,16 +244,10 @@ impl State {...@@ -244,16 +244,10 @@ impl State {
244 }244 }
245 Page::Display => {245 Page::Display => {
246 heading(ui, &theme, "Display");246 heading(ui, &theme, "Display");
247 let dark = !options.light_pages;247 let matching = !options.light_pages;
248 if ui::check_box(248 if ui::check_box(ui, "pages-match-theme", "Pages match UI theme", matching).clicked
249 ui,
250 "dark-pages",
251 "Pages appear dark in the Dark color scheme",
252 dark,
253 )
254 .clicked
255 {249 {
256 options.light_pages = dark;250 options.light_pages = matching;
257 }251 }
258 }252 }
259 Page::Advanced => {253 Page::Advanced => {
crates/snowbound/src/paste.rs created+184
...@@ -0,0 +1,184 @@
1//! Pictures and files from the clipboard, and pictures inserted, as OneNote 2010 places them.
2
3use crate::{State, platform};
4use std::{error::Error, path::PathBuf};
5
6/// What Paste takes from the clipboard. Files come first, as Finder also offers their names
7/// as text and their icons as pictures; text before a picture, as Office and browsers offer
8/// a picture of copied text beside it.
9pub(crate) enum Pasted {
10 Files(Vec<PathBuf>),
11 Text(String),
12 Picture(Vec<u8>),
13}
14
15impl State {
16 pub(crate) fn paste(&mut self) -> Result<(), Box<dyn Error>> {
17 match self.clipboard.pasted() {
18 Some(Pasted::Files(paths)) => {
19 for path in paths {
20 // OneNote 2010 pastes a picture file as the picture (lab, 2026-09-30).
21 match std::fs::read(&path) {
22 Ok(bytes) if draw::RasterImage::measure(&bytes).is_ok() => {
23 self.insert_picture(bytes)?
24 }
25 _ => self.attach(&path, None)?,
26 }
27 }
28 }
29 Some(Pasted::Text(text)) => {
30 let language = canvas::language::lcid(&platform::input_language());
31 let response = self.view.paste(&text, language)?;
32 self.respond(response);
33 }
34 Some(Pasted::Picture(bytes)) => self.insert_picture(bytes)?,
35 None => {}
36 }
37 Ok(())
38 }
39
40 /// Puts an encoded picture at the caret at the size its resolution gives it, as OneNote
41 /// 2010 inserts and pastes one.
42 pub(crate) fn insert_picture(&mut self, bytes: Vec<u8>) -> Result<(), Box<dyn Error>> {
43 let Ok(pixels) = draw::RasterImage::measure(&bytes) else {
44 platform::alert(
45 "Couldn't insert the picture",
46 "Choose a PNG, JPEG or GIF picture.",
47 );
48 return Ok(());
49 };
50 let size = picture_size(&bytes, pixels);
51 let response = self.view.insert_picture(bytes, size)?;
52 self.respond(response);
53 Ok(())
54 }
55}
56
57/// A picture's size in points: its pixels at the resolution it declares, or at 96 dpi
58/// without one, as OneNote 2010 sizes inserted and pasted pictures and pasted bitmaps
59/// (lab, 2026-09-30).
60fn picture_size(encoded: &[u8], pixels: [u32; 2]) -> [f32; 2] {
61 let dpi = resolution(encoded).unwrap_or([96.0; 2]);
62 [0, 1].map(|axis| pixels[axis] as f32 * 72.0 / dpi[axis])
63}
64
65/// Dots per inch from a PNG's `pHYs` or a JPEG's JFIF density, where they give one.
66fn resolution(encoded: &[u8]) -> Option<[f32; 2]> {
67 let dpi = if let Some(mut chunks) = encoded.strip_prefix(b"\x89PNG\r\n\x1a\n") {
68 loop {
69 let length = u32::from_be_bytes(chunks.get(..4)?.try_into().ok()?) as usize;
70 let kind = chunks.get(4..8)?;
71 let data = chunks.get(8..8 + length)?;
72 if kind == b"IDAT" {
73 return None;
74 }
75 // Unit 1 is the metre.
76 if kind == b"pHYs" && data.len() == 9 && data[8] == 1 {
77 let per_metre =
78 |at: usize| u32::from_be_bytes(data[at..at + 4].try_into().unwrap());
79 break [per_metre(0), per_metre(4)].map(|dots| dots as f32 * 0.0254);
80 }
81 chunks = chunks.get(12 + length..)?;
82 }
83 } else {
84 // Length, identifier, version, units, then the densities.
85 let app0 = encoded.strip_prefix(b"\xff\xd8\xff\xe0")?.get(..14)?;
86 if &app0[2..7] != b"JFIF\0" {
87 return None;
88 }
89 let density = |at: usize| u16::from_be_bytes([app0[at], app0[at + 1]]) as f32;
90 let dots = [density(10), density(12)];
91 match app0[9] {
92 1 => dots,
93 2 => dots.map(|per_cm| per_cm * 2.54),
94 _ => return None,
95 }
96 };
97 dpi.iter().all(|dpi| *dpi > 0.0).then_some(dpi)
98}
99
100/// A bitmap from the clipboard as the PNG a page stores, without a resolution, so it takes
101/// 96 dpi as OneNote 2010 gives a pasted bitmap.
102#[cfg(not(target_os = "macos"))]
103pub(crate) fn bitmap(image: arboard::ImageData) -> Option<Vec<u8>> {
104 png(
105 [image.width, image.height].map(|side| side as u32),
106 &image.bytes,
107 )
108 .ok()
109}
110
111/// Straight RGBA pixels as a PNG without a resolution.
112pub(crate) fn png(size: [u32; 2], rgba: &[u8]) -> Result<Vec<u8>, png::EncodingError> {
113 let mut bytes = Vec::new();
114 let mut encoder = png::Encoder::new(&mut bytes, size[0], size[1]);
115 encoder.set_color(png::ColorType::Rgba);
116 encoder.set_depth(png::BitDepth::Eight);
117 encoder.write_header()?.write_image_data(rgba)?;
118 Ok(bytes)
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124
125 fn png_at(pixels_per_metre: Option<u32>) -> Vec<u8> {
126 let mut bytes = Vec::new();
127 let mut encoder = png::Encoder::new(&mut bytes, 200, 100);
128 encoder.set_color(png::ColorType::Rgba);
129 encoder.set_pixel_dims(pixels_per_metre.map(|dots| png::PixelDimensions {
130 xppu: dots,
131 yppu: dots,
132 unit: png::Unit::Meter,
133 }));
134 encoder
135 .write_header()
136 .unwrap()
137 .write_image_data(&[0; 200 * 100 * 4])
138 .unwrap();
139 bytes
140 }
141
142 fn jfif(units: u8, density: u16) -> Vec<u8> {
143 let mut bytes = b"\xff\xd8\xff\xe0\x00\x10JFIF\0\x01\x01".to_vec();
144 bytes.push(units);
145 bytes.extend(density.to_be_bytes());
146 bytes.extend(density.to_be_bytes());
147 bytes.extend([0, 0]);
148 bytes
149 }
150
151 fn close(size: [f32; 2], expected: [f32; 2]) -> bool {
152 (0..2).all(|axis| (size[axis] - expected[axis]).abs() < 0.01)
153 }
154
155 /// OneNote 2010 read these back from a 200 by 100 pixel picture that GDI+ saved at 72
156 /// and 48 dpi (lab, 2026-09-30).
157 #[test]
158 fn pictures_take_the_size_their_resolution_gives() {
159 let pixels = [200, 100];
160 let size = |bytes: &[u8]| picture_size(bytes, pixels);
161 assert!(close(size(&png_at(Some(2834))), [200.0456, 100.0228]));
162 assert!(close(size(&png_at(Some(1889))), [300.1212, 150.0606]));
163 assert!(close(size(&png_at(None)), [150.0, 75.0]));
164 assert!(close(size(&jfif(1, 48)), [300.0, 150.0]));
165 assert!(close(size(&jfif(2, 20)), [283.4646, 141.7323]));
166 assert!(close(size(&jfif(0, 1)), [150.0, 75.0]));
167 assert!(close(size(&jfif(1, 0)), [150.0, 75.0]));
168 assert!(close(size(b"GIF89a"), [150.0, 75.0]));
169 }
170
171 #[test]
172 fn a_pasted_bitmap_is_stored_as_a_png_at_96_dpi() {
173 let rgba: Vec<u8> = (0..3 * 2 * 4).map(|byte| byte as u8).collect();
174 let encoded = png([3, 2], &rgba).unwrap();
175 assert_eq!(draw::RasterImage::measure(&encoded).unwrap(), [3, 2]);
176 assert!(close(picture_size(&encoded, [3, 2]), [2.25, 1.5]));
177 let mut reader = png::Decoder::new(std::io::Cursor::new(&encoded))
178 .read_info()
179 .unwrap();
180 let mut decoded = vec![0; reader.output_buffer_size().unwrap()];
181 reader.next_frame(&mut decoded).unwrap();
182 assert_eq!(decoded, rgba);
183 }
184}
crates/snowbound/src/print.rs created+118
...@@ -0,0 +1,118 @@
1//! File, Print… and Export as PDF…: the open page, or the whole section, laid on paper as
2//! OneNote 2010 prints it (`canvas::print`), then handed to the system's printing or saved.
3
4use crate::{State, UserEvent, platform, printer};
5use onestore::page::Page;
6use std::{error::Error, path::PathBuf, sync::Arc};
7
8#[derive(Clone, Copy, PartialEq, Eq)]
9pub enum Scope {
10 Page,
11 Section,
12}
13
14impl State {
15 /// Prints `scope` through the system's print dialog, or with `export` saves it as a PDF
16 /// where the user chooses. Pages are laid out on a thread of their own.
17 pub(crate) fn print(&mut self, scope: Scope, export: bool) -> Result<(), Box<dyn Error>> {
18 let session = self.session.as_ref().ok_or("No section is open")?;
19 let section = session.tabs[session.tab].name.clone();
20 // The open page as the editor holds it; the section's others as stored.
21 let open = (session.space, self.view.editor.page()?);
22 let spaces: Vec<_> = match scope {
23 Scope::Page => vec![session.space],
24 Scope::Section => session.pages.iter().map(|(space, ..)| *space).collect(),
25 };
26 let title = match scope {
27 Scope::Page => open.1.title.clone(),
28 Scope::Section => section.clone(),
29 };
30 let destination = if export {
31 let name = format!("{}.pdf", file_name(&title));
32 let Some(mut path) = platform::pick_new("Export as PDF", &name, "Export", None) else {
33 return Ok(());
34 };
35 if path.extension().is_none() {
36 path.set_extension("pdf");
37 }
38 Some(path)
39 } else {
40 None
41 };
42 let replica = Arc::clone(session.section.replica());
43 let layouts = Arc::clone(&self.layouts);
44 let proxy = self.proxy.clone();
45 let paper = printer::paper();
46 std::thread::spawn(move || {
47 let made = (|| -> Result<Vec<u8>, Box<dyn Error>> {
48 let mut open = Some(open);
49 let pages = spaces
50 .into_iter()
51 .map(|space| match open.take_if(|(shown, _)| *shown == space) {
52 Some((_, page)) => Ok(page),
53 None => replica.page(space).map_err(Into::into),
54 })
55 .collect::<Result<Vec<Page>, Box<dyn Error>>>()?;
56 let mut engine = layouts.lock().map_err(|_| "Page layout failed")?;
57 Ok(canvas::print::pdf(pages, &mut engine, paper, &section)?)
58 })();
59 let done = made.and_then(|pdf| match &destination {
60 Some(path) => std::fs::write(path, pdf).map(|()| None).map_err(Into::into),
61 None => Ok(Some(pdf)),
62 });
63 let failed = if export {
64 "Couldn't export the PDF"
65 } else {
66 "Couldn't print"
67 };
68 let done = done.map_err(|error| error.to_string());
69 let _ = proxy.send_event(UserEvent::Then(Box::new(move |state| {
70 match done {
71 Ok(Some(pdf)) => printer::print(&state.window, &pdf, &title),
72 Ok(None) => Ok(()),
73 Err(error) => Err(error.into()),
74 }
75 .inspect_err(|error| platform::alert(failed, &error.to_string()))
76 })));
77 });
78 Ok(())
79 }
80}
81
82/// `title` as a file name: without the characters Windows and macOS refuse in one.
83fn file_name(title: &str) -> String {
84 let name: String = title
85 .chars()
86 .map(|c| match c {
87 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
88 c if c.is_control() => ' ',
89 c => c,
90 })
91 .collect();
92 match name.trim() {
93 "" => "Untitled".to_owned(),
94 name => name.to_owned(),
95 }
96}
97
98/// A PDF waiting for a system viewer or print service, in the cache folder under `title`.
99pub(crate) fn spooled(pdf: &[u8], title: &str) -> Result<PathBuf, Box<dyn Error>> {
100 let folder = platform::cache_dir()
101 .unwrap_or_else(std::env::temp_dir)
102 .join("Printing");
103 std::fs::create_dir_all(&folder)?;
104 let path = folder.join(format!("{}.pdf", file_name(title)));
105 std::fs::write(&path, pdf)?;
106 Ok(path)
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112
113 #[test]
114 fn titles_become_file_names() {
115 assert_eq!(file_name("Notes: 3/4"), "Notes- 3-4");
116 assert_eq!(file_name(" "), "Untitled");
117 }
118}
crates/snowbound/src/print_linux.rs created+110
...@@ -0,0 +1,110 @@
1//! Printing on Linux: the desktop's print dialog through the XDG print portal, which prints
2//! the PDF itself; without a portal the PDF opens in the desktop's viewer to print from.
3
4use std::{collections::HashMap, error::Error, time::Duration};
5use winit::window::Window;
6use zbus::zvariant::{Fd, OwnedValue, Value};
7
8/// Countries whose paper is US Letter; everywhere else prints on A4.
9const LETTER_COUNTRIES: [&str; 16] = [
10 "US", "CA", "MX", "PR", "PH", "CL", "CO", "VE", "GT", "CR", "PA", "DO", "SV", "NI", "HN", "BZ",
11];
12
13/// The paper of the locale's country, as `LC_PAPER` names it.
14pub fn paper() -> [f32; 2] {
15 let locale = ["LC_ALL", "LC_PAPER", "LANG"]
16 .into_iter()
17 .find_map(|name| std::env::var(name).ok().filter(|value| !value.is_empty()))
18 .unwrap_or_default();
19 paper_of(&locale)
20}
21
22/// The paper of `locale`'s country, such as `en_GB.UTF-8`'s.
23fn paper_of(locale: &str) -> [f32; 2] {
24 let country = locale
25 .split(['.', '@'])
26 .next()
27 .and_then(|language| language.split('_').nth(1))
28 .unwrap_or("US");
29 if LETTER_COUNTRIES.contains(&country) {
30 canvas::print::LETTER
31 } else {
32 canvas::print::A4
33 }
34}
35
36/// Asks the print portal for the print dialog, then prints `pdf` as it was set up; the
37/// dialog belongs to the portal, so a thread of its own waits on it.
38pub fn print(_window: &Window, pdf: &[u8], title: &str) -> Result<(), Box<dyn Error>> {
39 let path = crate::print::spooled(pdf, title)?;
40 let title = title.to_owned();
41 std::thread::spawn(move || {
42 if let Err(error) = portal(&path, &title) {
43 eprintln!("The print portal failed, opening the PDF instead: {error}");
44 crate::platform::reveal(&path);
45 }
46 });
47 Ok(())
48}
49
50fn portal(path: &std::path::Path, title: &str) -> Result<(), Box<dyn Error>> {
51 let connection = zbus::blocking::connection::Builder::session()?
52 .method_timeout(Duration::from_secs(5))
53 .build()?;
54 let portal = zbus::blocking::Proxy::new(
55 &connection,
56 "org.freedesktop.portal.Desktop",
57 "/org/freedesktop/portal/desktop",
58 "org.freedesktop.portal.Print",
59 )?;
60 // A request's path follows from the sender and the token, so its answer can be awaited
61 // before it is asked for.
62 let sender = connection
63 .unique_name()
64 .ok_or("No D-Bus name")?
65 .trim_start_matches(':')
66 .replace('.', "_");
67 let request = |token: &str| -> Result<_, Box<dyn Error>> {
68 let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}");
69 let request = zbus::blocking::Proxy::new(
70 &connection,
71 "org.freedesktop.portal.Desktop",
72 path,
73 "org.freedesktop.portal.Request",
74 )?;
75 Ok(request.receive_signal("Response")?)
76 };
77 let token = format!("snowbound{}", std::process::id());
78 let mut answers = request(&token)?;
79 let options = HashMap::from([("handle_token", Value::from(token.as_str()))]);
80 let empty: HashMap<&str, Value> = HashMap::new();
81 let _: zbus::zvariant::OwnedObjectPath =
82 portal.call("PreparePrint", &("", title, &empty, &empty, &options))?;
83 let answer = answers.next().ok_or("The print dialog did not answer")?;
84 let (response, results): (u32, HashMap<String, OwnedValue>) = answer.body().deserialize()?;
85 // 1 is cancelled.
86 if response != 0 {
87 return Ok(());
88 }
89 let prepared = results
90 .get("token")
91 .and_then(|token| u32::try_from(token).ok())
92 .ok_or("The print dialog gave no token")?;
93 let file = std::fs::File::open(path)?;
94 let options = HashMap::from([("token", Value::from(prepared))]);
95 let _: zbus::zvariant::OwnedObjectPath =
96 portal.call("Print", &("", title, Fd::from(&file), &options))?;
97 Ok(())
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 #[test]
105 fn countries_pick_their_paper() {
106 assert_eq!(paper_of("en_GB.UTF-8"), canvas::print::A4);
107 assert_eq!(paper_of("es_MX.UTF-8"), canvas::print::LETTER);
108 assert_eq!(paper_of("C"), canvas::print::LETTER);
109 }
110}
crates/snowbound/src/print_macos.rs created+153
...@@ -0,0 +1,153 @@
1//! Printing on macOS: AppKit's print panel as a sheet on the window, printing the PDF
2//! through PDFKit, which fits each sheet to the paper chosen. Mac OS X 10.6's PDFKit
3//! cannot print a document, so there the PDF opens in Preview.
4
5use objc2::{
6 msg_send, msg_send_id,
7 rc::Retained,
8 runtime::{AnyClass, AnyObject, Bool, Sel},
9 sel,
10};
11use objc2_foundation::{MainThreadMarker, NSSize, NSString};
12use std::error::Error;
13use winit::{
14 raw_window_handle::{HasWindowHandle, RawWindowHandle},
15 window::Window,
16};
17
18// PDFKit, inside Quartz on every macOS.
19#[link(name = "Quartz", kind = "framework")]
20unsafe extern "C" {}
21
22fn class(name: &str) -> &'static AnyClass {
23 AnyClass::get(name).expect("AppKit and PDFKit are linked")
24}
25
26/// The paper Page Setup defaults to, in points.
27pub fn paper() -> [f32; 2] {
28 MainThreadMarker::new().expect("Printing belongs to the main thread");
29 unsafe {
30 let info: Retained<AnyObject> = msg_send_id![class("NSPrintInfo"), sharedPrintInfo];
31 let size: NSSize = msg_send![&info, paperSize];
32 if size.width > 0.0 && size.height > 0.0 {
33 [size.width as f32, size.height as f32]
34 } else {
35 canvas::print::LETTER
36 }
37 }
38}
39
40/// PDFKit's print operation for `pdf` fitted to `info`'s paper, where PDFKit prints.
41fn operation(pdf: &[u8], info: &AnyObject) -> Option<Retained<AnyObject>> {
42 unsafe {
43 let data: Retained<AnyObject> = msg_send_id![
44 class("NSData"),
45 dataWithBytes: pdf.as_ptr().cast::<std::ffi::c_void>(),
46 length: pdf.len()
47 ];
48 let document: Option<Retained<AnyObject>> =
49 msg_send_id![msg_send_id![class("PDFDocument"), alloc], initWithData: &*data];
50 let document = document?;
51 let prints: bool = msg_send![
52 &document,
53 respondsToSelector: sel!(printOperationForPrintInfo:scalingMode:autoRotate:)
54 ];
55 if !prints {
56 return None;
57 }
58 // kPDFPrintPageScaleToFit.
59 msg_send_id![
60 &document,
61 printOperationForPrintInfo: info,
62 scalingMode: 1_isize,
63 autoRotate: Bool::YES
64 ]
65 }
66}
67
68/// Shows the print panel for `pdf` as a sheet on `window`.
69pub fn print(window: &Window, pdf: &[u8], title: &str) -> Result<(), Box<dyn Error>> {
70 MainThreadMarker::new().expect("Printing belongs to the main thread");
71 unsafe {
72 let shared: Retained<AnyObject> = msg_send_id![class("NSPrintInfo"), sharedPrintInfo];
73 let info: Retained<AnyObject> = msg_send_id![&shared, copy];
74 let Some(operation) = operation(pdf, &info) else {
75 crate::platform::reveal(crate::print::spooled(pdf, title)?);
76 return Ok(());
77 };
78 let _: () = msg_send![&operation, setJobTitle: &*NSString::from_str(title)];
79 let RawWindowHandle::AppKit(handle) = window.window_handle()?.as_raw() else {
80 unreachable!("A macOS window is AppKit's")
81 };
82 let view = &*handle.ns_view.as_ptr().cast::<AnyObject>();
83 let window: Retained<AnyObject> = msg_send_id![view, window];
84 let _: () = msg_send![
85 &operation,
86 runOperationModalForWindow: &*window,
87 delegate: std::ptr::null::<AnyObject>(),
88 didRunSelector: None::<Sel>,
89 contextInfo: std::ptr::null_mut::<std::ffi::c_void>()
90 ];
91 }
92 Ok(())
93}
94
95#[cfg(test)]
96mod tests {
97 use super::*;
98
99 /// The print operation `print` runs, saving to a file instead of showing the panel.
100 #[test]
101 fn pdfkit_prints_the_pages() {
102 let folder = std::env::temp_dir().join(format!("snowbound-print-{}", std::process::id()));
103 std::fs::create_dir_all(&folder).unwrap();
104 let printed = folder.join("printed.pdf");
105 let sheet = draw::Sheet {
106 size: canvas::print::LETTER,
107 layers: Vec::new(),
108 };
109 let pdf = draw::pdf(
110 "Two sheets",
111 &[
112 sheet,
113 draw::Sheet {
114 size: canvas::print::LETTER,
115 layers: Vec::new(),
116 },
117 ],
118 )
119 .unwrap();
120 unsafe {
121 let info: Retained<AnyObject> = msg_send_id![class("NSPrintInfo"), new];
122 let url: Retained<AnyObject> = msg_send_id![
123 class("NSURL"),
124 fileURLWithPath: &*NSString::from_str(printed.to_str().unwrap())
125 ];
126 let settings: Retained<AnyObject> = msg_send_id![&info, dictionary];
127 let _: () = msg_send![
128 &settings,
129 setObject: &*url,
130 forKey: &*NSString::from_str("NSJobSavingURL")
131 ];
132 let _: () = msg_send![&info, setJobDisposition: &*NSString::from_str("NSPrintSaveJob")];
133 let operation = operation(&pdf, &info).expect("PDFKit prints documents");
134 let _: () = msg_send![&operation, setShowsPrintPanel: false];
135 let _: () = msg_send![&operation, setShowsProgressPanel: false];
136 let ran: bool = msg_send![&operation, runOperation];
137 assert!(ran);
138 }
139 let bytes = std::fs::read(&printed).unwrap();
140 unsafe {
141 let data: Retained<AnyObject> = msg_send_id![
142 class("NSData"),
143 dataWithBytes: bytes.as_ptr().cast::<std::ffi::c_void>(),
144 length: bytes.len()
145 ];
146 let document: Retained<AnyObject> =
147 msg_send_id![msg_send_id![class("PDFDocument"), alloc], initWithData: &*data];
148 let pages: usize = msg_send![&document, pageCount];
149 assert_eq!(pages, 2);
150 }
151 std::fs::remove_dir_all(&folder).unwrap();
152 }
153}
crates/snowbound/src/print_windows.rs created+61
...@@ -0,0 +1,61 @@
1//! Printing on Windows: the PDF goes to the shell's print verb for PDFs, whose handler
2//! shows the print dialog; where none prints PDFs, it opens in the viewer to print from.
3
4use std::{error::Error, os::windows::ffi::OsStrExt};
5use windows_sys::Win32::{
6 Globalization::{GetLocaleInfoEx, LOCALE_IPAPERSIZE},
7 UI::{Shell::ShellExecuteW, WindowsAndMessaging::SW_SHOWNORMAL},
8};
9use winit::{
10 raw_window_handle::{HasWindowHandle, RawWindowHandle},
11 window::Window,
12};
13
14fn wide(text: impl AsRef<std::ffi::OsStr>) -> Vec<u16> {
15 text.as_ref().encode_wide().chain([0]).collect()
16}
17
18/// The paper the user's locale prints on: 1 is US Letter, 5 Legal, 9 A4.
19pub fn paper() -> [f32; 2] {
20 let mut value = [0_u16; 4];
21 // SAFETY: the buffer's length is passed with it; a null name is the user's locale.
22 let length = unsafe {
23 GetLocaleInfoEx(
24 std::ptr::null(),
25 LOCALE_IPAPERSIZE,
26 value.as_mut_ptr(),
27 value.len() as i32,
28 )
29 };
30 let size = String::from_utf16_lossy(&value[..(length.max(1) - 1) as usize]);
31 match size.as_str() {
32 "9" => canvas::print::A4,
33 _ => canvas::print::LETTER,
34 }
35}
36
37pub fn print(window: &Window, pdf: &[u8], title: &str) -> Result<(), Box<dyn Error>> {
38 let path = crate::print::spooled(pdf, title)?;
39 let owner = match window.window_handle()?.as_raw() {
40 RawWindowHandle::Win32(handle) => handle.hwnd.get() as _,
41 _ => std::ptr::null_mut(),
42 };
43 for verb in ["print", "open"] {
44 // SAFETY: the strings are NUL-terminated and outlive the call.
45 let done = unsafe {
46 ShellExecuteW(
47 owner,
48 wide(verb).as_ptr(),
49 wide(&path).as_ptr(),
50 std::ptr::null(),
51 std::ptr::null(),
52 SW_SHOWNORMAL,
53 )
54 };
55 // ShellExecute's success is a pseudo-handle above 32.
56 if done as isize > 32 {
57 return Ok(());
58 }
59 }
60 Err("No application on this computer prints or opens PDFs.".into())
61}
crates/snowbound/src/screenshot.rs+2-2
...@@ -40,8 +40,8 @@ pub(crate) fn prepare() {...@@ -40,8 +40,8 @@ pub(crate) fn prepare() {
4040
41impl State {41impl State {
42 /// Writes `PREFIX-light.png` and `PREFIX-dark.png`, the page unfocused so no caret shows;42 /// Writes `PREFIX-light.png` and `PREFIX-dark.png`, the page unfocused so no caret shows;
43 /// as a Snow Leopard window, `PREFIX-snow-leopard.png` and, not key,43 /// as a Snow Leopard window, at 10.6's one pixel per point, also `PREFIX-light-other.png`
44 /// `PREFIX-snow-leopard-other.png`, at 10.6's one pixel per point.44 /// and `PREFIX-dark-other.png` as the window looks when not key.
45 pub(crate) fn screenshot(&mut self, prefix: &Path) -> Result<(), Box<dyn Error>> {45 pub(crate) fn screenshot(&mut self, prefix: &Path) -> Result<(), Box<dyn Error>> {
46 let size = match std::env::var("SNOWBOUND_SCREENSHOT_SIZE") {46 let size = match std::env::var("SNOWBOUND_SCREENSHOT_SIZE") {
47 Ok(size) => size47 Ok(size) => size
crates/snowbound/src/screenshot/snow_leopard.rs+17-15
...@@ -30,21 +30,23 @@ pub(super) fn screenshot(...@@ -30,21 +30,23 @@ pub(super) fn screenshot(
30 size: [f32; 2],30 size: [f32; 2],
31) -> Result<(), Box<dyn Error>> {31) -> Result<(), Box<dyn Error>> {
32 let title = state.window.title();32 let title = state.window.title();
33 for (name, key) in [("snow-leopard", true), ("snow-leopard-other", false)] {33 for (name, appearance) in [("light", Appearance::Light), ("dark", Appearance::Dark)] {
34 state.window.set_theme(Some(Appearance::Light));34 for (suffix, key) in [("", true), ("-other", false)] {
35 state.set_appearance(Appearance::Light);35 state.window.set_theme(Some(appearance));
36 // Over the textured window's gradient, as `install_backdrop` leaves it on 10.6.36 state.set_appearance(appearance);
37 state.ui.theme = theme(Appearance::Light, state.light_pages, true);37 // Over the textured window's gradient, as `install_backdrop` leaves it on 10.6.
38 state.titlebar = [state.ui.theme.strip; 2];38 state.ui.theme = theme(appearance, state.light_pages, true);
39 state.ui.window_focused = key;39 state.titlebar = [state.ui.theme.strip; 2];
40 state.settle_frame(size, 1.0)?;40 state.ui.window_focused = key;
41 let content = state.capture()?;41 state.settle_frame(size, 1.0)?;
42 let bar = title_bar(state, &title, size[0], key)?;42 let content = state.capture()?;
43 let [width, height] = [size[0] as usize, size[1] as usize];43 let bar = title_bar(state, &title, size[0], key)?;
44 let window = window(&content, &bar, [width, height], key)?;44 let [width, height] = [size[0] as usize, size[1] as usize];
45 let (canvas, shadowed) = shadow(&window, [width, height + TITLE_LINE], key);45 let window = window(&content, &bar, [width, height], key)?;
46 let path = PathBuf::from(format!("{}-{name}.png", prefix.display()));46 let (canvas, shadowed) = shadow(&window, [width, height + TITLE_LINE], key);
47 write_png(&path, canvas.map(|side| side as u32), &shadowed)?;47 let path = PathBuf::from(format!("{}-{name}{suffix}.png", prefix.display()));
48 write_png(&path, canvas.map(|side| side as u32), &shadowed)?;
49 }
48 }50 }
49 Ok(())51 Ok(())
50}52}
crates/snowbound/src/settings.rs+43-1
...@@ -20,7 +20,7 @@ pub struct Settings {...@@ -20,7 +20,7 @@ pub struct Settings {
20 /// first saved.20 /// first saved.
21 pub user_name: Option<String>,21 pub user_name: Option<String>,
22 pub color_scheme: ColorScheme,22 pub color_scheme: ColorScheme,
23 /// Keeps pages white in the Dark color scheme.23 /// Keeps pages white in a dark appearance: Pages Match UI Theme off.
24 pub light_pages: bool,24 pub light_pages: bool,
25 /// Leaves misspelled words unmarked, as OneNote's Hide Spelling Errors.25 /// Leaves misspelled words unmarked, as OneNote's Hide Spelling Errors.
26 pub hide_spelling: bool,26 pub hide_spelling: bool,
...@@ -33,6 +33,35 @@ pub struct Settings {...@@ -33,6 +33,35 @@ pub struct Settings {
33 /// Draws a tablet pen's strokes at its width, as OneNote 2010 with "Use pen pressure33 /// Draws a tablet pen's strokes at its width, as OneNote 2010 with "Use pen pressure
34 /// sensitivity" off.34 /// sensitivity" off.
35 pub ignore_pen_pressure: bool,35 pub ignore_pen_pressure: bool,
36 /// Places and moves things where they are dropped, as OneNote's Snap To Grid off.
37 pub ignore_grid: bool,
38 /// Options' Default font: new text's font and size, and new titles' font.
39 pub default_font: DefaultFont,
40 /// OneNote's "Page tabs appear on the left".
41 pub page_tabs_left: bool,
42 /// OneNote's "Navigation bar appears on the left" turned off: the notebooks on the right.
43 pub navigation_bar_right: bool,
44 /// Servers Open Notebook from Server signed in to, latest first, as addresses without a
45 /// password.
46 pub servers: Vec<String>,
47}
48
49/// The font and size OneNote's Default font gives new text.
50#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
51#[serde(default)]
52pub struct DefaultFont {
53 pub face: String,
54 pub size: f32,
55}
56
57impl Default for DefaultFont {
58 /// OneNote 2010's: Calibri 11.
59 fn default() -> Self {
60 Self {
61 face: "Calibri".into(),
62 size: 11.0,
63 }
64 }
36}65}
3766
38/// What the toolbar's buttons apply from their menus' last picks.67/// What the toolbar's buttons apply from their menus' last picks.
...@@ -144,6 +173,11 @@ impl crate::State {...@@ -144,6 +173,11 @@ impl crate::State {
144 tags: (self.tags != canvas::editor::NoteTag::defaults()).then(|| self.tags.clone()),173 tags: (self.tags != canvas::editor::NoteTag::defaults()).then(|| self.tags.clone()),
145 manual_updates: !self.updates.automatic(),174 manual_updates: !self.updates.automatic(),
146 ignore_pen_pressure: !self.pen_pressure,175 ignore_pen_pressure: !self.pen_pressure,
176 ignore_grid: !self.view.snap_to_grid,
177 default_font: self.default_font.clone(),
178 page_tabs_left: self.page_tabs_left,
179 navigation_bar_right: self.navigation_bar_right,
180 servers: self.servers.clone(),
147 };181 };
148 if let Err(error) = settings.save(path) {182 if let Err(error) = settings.save(path) {
149 eprintln!("Cannot save the settings in {}: {error}", path.display());183 eprintln!("Cannot save the settings in {}: {error}", path.display());
...@@ -204,6 +238,14 @@ mod tests {...@@ -204,6 +238,14 @@ mod tests {
204 }]),238 }]),
205 manual_updates: true,239 manual_updates: true,
206 ignore_pen_pressure: true,240 ignore_pen_pressure: true,
241 ignore_grid: true,
242 default_font: DefaultFont {
243 face: "Georgia".into(),
244 size: 14.0,
245 },
246 page_tabs_left: true,
247 navigation_bar_right: true,
248 servers: vec!["smb://clover@nas/Notes".into()],
207 };249 };
208 settings.save(&path).unwrap();250 settings.save(&path).unwrap();
209 assert_eq!(Settings::load(&path), settings);251 assert_eq!(Settings::load(&path), settings);
crates/snowbound/src/sidebar.rs+7-1
...@@ -1074,7 +1074,7 @@ impl crate::State {...@@ -1074,7 +1074,7 @@ impl crate::State {
1074 }1074 }
10751075
1076 /// The window below the title bar on the first run, or once every notebook is closed:1076 /// The window below the title bar on the first run, or once every notebook is closed:
1077 /// what is missing, and the two ways to start.1077 /// what is missing, and the ways to start.
1078 pub(crate) fn welcome(&mut self, theme: &Theme) {1078 pub(crate) fn welcome(&mut self, theme: &Theme) {
1079 let id = self.ui.id("welcome");1079 let id = self.ui.id("welcome");
1080 #[cfg(target_os = "linux")]1080 #[cfg(target_os = "linux")]
...@@ -1121,6 +1121,12 @@ impl crate::State {...@@ -1121,6 +1121,12 @@ impl crate::State {
1121 crate::Command::OpenFromServer(None),1121 crate::Command::OpenFromServer(None),
1122 ),1122 ),
1123 ])1123 ])
1124 .chain(crate::guide::OFFERED.then_some((
1125 "guide",
1126 art::PAGE,
1127 "Open the Snowbound Guide",
1128 crate::Command::OpenGuide,
1129 )))
1124 .chain(install)1130 .chain(install)
1125 .collect(),1131 .collect(),
1126 );1132 );
crates/snowbound/src/update.rs+28-2
...@@ -31,15 +31,39 @@ fn running() -> Option<Version> {...@@ -31,15 +31,39 @@ fn running() -> Option<Version> {
31 Version::parse(option_env!("SNOWBOUND_BUILD")?)31 Version::parse(option_env!("SNOWBOUND_BUILD")?)
32}32}
3333
34/// This build's platform, as `latest.json` and `build.json` key their entries.34/// This build's platform, as `latest.json` and `build.json` key their entries; Apple
35/// silicon's for an Intel build Rosetta runs.
35fn platform() -> String {36fn platform() -> String {
36 if cfg!(target_os = "macos") && !cfg!(feature = "wgpu") {37 if translated() {
38 "macos-aarch64".to_owned()
39 } else if cfg!(target_os = "macos") && !cfg!(feature = "wgpu") {
37 "macos-10.6".to_owned()40 "macos-10.6".to_owned()
38 } else {41 } else {
39 format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH)42 format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH)
40 }43 }
41}44}
4245
46#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
47fn translated() -> bool {
48 let mut translated: libc::c_int = 0;
49 let mut size = std::mem::size_of_val(&translated);
50 let found = unsafe {
51 libc::sysctlbyname(
52 c"sysctl.proc_translated".as_ptr(),
53 (&raw mut translated).cast(),
54 &mut size,
55 std::ptr::null_mut(),
56 0,
57 )
58 };
59 found == 0 && translated == 1
60}
61
62#[cfg(not(all(target_os = "macos", target_arch = "x86_64")))]
63fn translated() -> bool {
64 false
65}
66
43/// A published build: the day of its commit on `main`, in Los Angeles, and how many of that67/// A published build: the day of its commit on `main`, in Los Angeles, and how many of that
44/// day's commits on `main` lead up to and include it. `2026-09-29-r10` names it in manifests.68/// day's commits on `main` lead up to and include it. `2026-09-29-r10` names it in manifests.
45#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]69#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
...@@ -302,6 +326,8 @@ fn check(...@@ -302,6 +326,8 @@ fn check(
302 progress: &dyn Fn(Status),326 progress: &dyn Fn(Status),
303) -> Status {327) -> Status {
304 let platform = platform();328 let platform = platform();
329 // Under Rosetta the native build of the same version is still the update.
330 let running = running.filter(|_| !translated());
305 let fetch = |path: &str, limit| {331 let fetch = |path: &str, limit| {
306 get(path, limit).map_err(|error| {332 get(path, limit).map_err(|error| {
307 eprintln!("Cannot fetch {BASE}{path}: {error}");333 eprintln!("Cannot fetch {BASE}{path}: {error}");
crates/snowbound/src/windows.rs+61-6
...@@ -175,6 +175,9 @@ pub fn window_attributes() -> WindowAttributes {...@@ -175,6 +175,9 @@ pub fn window_attributes() -> WindowAttributes {
175 .with_taskbar_icon(icon(32).ok())175 .with_taskbar_icon(icon(32).ok())
176 .with_decorations(caption() != Caption::Drawn)176 .with_decorations(caption() != Caption::Drawn)
177 .with_undecorated_shadow(true)177 .with_undecorated_shadow(true)
178 // What GDI paints under the material would show through the row; every 10 draws
179 // with Direct3D 12, which needs no such bitmap.
180 .with_no_redirection_bitmap(caption() == Caption::Drawn && version().0 >= 10)
178}181}
179182
180/// Takes the window's frame messages ahead of winit's: over glass or Mica, the caption183/// 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(...@@ -236,9 +239,6 @@ unsafe extern "system" fn frame_procedure(
236 wm::WM_SETTINGCHANGE | wm::WM_DWMCOLORIZATIONCOLORCHANGED => {239 wm::WM_SETTINGCHANGE | wm::WM_DWMCOLORIZATIONCOLORCHANGED => {
237 if message == wm::WM_SETTINGCHANGE && setting(lparam) == "ImmersiveColorSet" {240 if message == wm::WM_SETTINGCHANGE && setting(lparam) == "ImmersiveColorSet" {
238 refresh_color_policy();241 refresh_color_policy();
239 if ACRYLIC.load(Ordering::Relaxed) {
240 accent(hwnd, Accent::Acrylic);
241 }
242 }242 }
243 if let Some(proxy) = QUIT.get() {243 if let Some(proxy) = QUIT.get() {
244 let _ = proxy.send_event(crate::UserEvent::Appearance);244 let _ = proxy.send_event(crate::UserEvent::Appearance);
...@@ -445,6 +445,26 @@ pub fn cuts_corners() -> bool {...@@ -445,6 +445,26 @@ pub fn cuts_corners() -> bool {
445/// Whether Windows 10 shows acrylic under the window, which a drag swaps for plain blur.445/// Whether Windows 10 shows acrylic under the window, which a drag swaps for plain blur.
446static ACRYLIC: AtomicBool = AtomicBool::new(false);446static ACRYLIC: AtomicBool = AtomicBool::new(false);
447447
448/// Whether the app's appearance is dark, which Options can set apart from the system's.
449static DARK: AtomicBool = AtomicBool::new(false);
450
451/// Holds the frame, its caption buttons and its material to the app's `appearance`.
452pub fn follow_appearance(window: &Window, appearance: Theme) {
453 let dark = appearance == Theme::Dark;
454 DARK.store(dark, Ordering::Relaxed);
455 let (major, _, build) = version();
456 if major < 10 {
457 return;
458 }
459 let hwnd = hwnd(window);
460 // DWMWA_USE_IMMERSIVE_DARK_MODE, 19 before build 18985.
461 let attribute = if build >= 18985 { 20 } else { 19 };
462 set_attribute(hwnd, attribute, &BOOL::from(dark));
463 if ACRYLIC.load(Ordering::Relaxed) {
464 accent(hwnd, Accent::Acrylic);
465 }
466}
467
448enum Accent {468enum Accent {
449 Blur,469 Blur,
450 /// Acrylic in the shell's own tints: Windows 10's light and dark flyouts.470 /// 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 {...@@ -482,8 +502,8 @@ fn accent(hwnd: HWND, accent: Accent) -> bool {
482 Accent::Acrylic => Policy {502 Accent::Acrylic => Policy {
483 state: 4,503 state: 4,
484 flags: 2,504 flags: 2,
485 // 0xAABBGGRR, after the apps' colour mode.505 // 0xAABBGGRR, after the app's appearance.
486 tint: if registry_dword(PERSONALIZE, "AppsUseLightTheme") == Some(0) {506 tint: if DARK.load(Ordering::Relaxed) {
487 0xcc20_2020507 0xcc20_2020
488 } else {508 } else {
489 0xccf3_f3f3509 0xccf3_f3f3
...@@ -577,7 +597,7 @@ pub fn titlebar(appearance: Theme) -> Option<[[f32; 4]; 2]> {...@@ -577,7 +597,7 @@ pub fn titlebar(appearance: Theme) -> Option<[[f32; 4]; 2]> {
577/// The theme over the window's material. Over Windows 7's glass, fields and tool buttons597/// The theme over the window's material. Over Windows 7's glass, fields and tool buttons
578/// take white faces, as Internet Explorer's do there, so their text and icons stay legible,598/// take white faces, as Internet Explorer's do there, so their text and icons stay legible,
579/// and the sidebar stays opaque.599/// and the sidebar stays opaque.
580pub fn over_backdrop(theme: ui::Theme) -> ui::Theme {600pub fn over_backdrop(theme: ui::Theme, _: Theme) -> ui::Theme {
581 let sidebar = theme.sidebar;601 let sidebar = theme.sidebar;
582 let theme = theme.over_backdrop();602 let theme = theme.over_backdrop();
583 if caption() != Caption::Glass || eleven() {603 if caption() != Caption::Glass || eleven() {
...@@ -795,6 +815,15 @@ impl Clipboard {...@@ -795,6 +815,15 @@ impl Clipboard {
795 pub fn get_text(&mut self) -> Result<String, arboard::Error> {815 pub fn get_text(&mut self) -> Result<String, arboard::Error> {
796 self.0.get_text()816 self.0.get_text()
797 }817 }
818
819 pub fn get_files(&mut self) -> Vec<std::path::PathBuf> {
820 self.0.get().file_list().unwrap_or_default()
821 }
822
823 /// The clipboard's PNG, or else its device-independent bitmap.
824 pub fn get_picture(&mut self) -> Option<Vec<u8>> {
825 crate::paste::bitmap(self.0.get_image().ok()?)
826 }
798}827}
799828
800/// The DWORD `value` under `key` in the user's registry hive.829/// The DWORD `value` under `key` in the user's registry hive.
...@@ -884,6 +913,32 @@ pub fn settings_dir() -> Option<PathBuf> {...@@ -884,6 +913,32 @@ pub fn settings_dir() -> Option<PathBuf> {
884 Some(PathBuf::from(std::env::var_os("APPDATA")?).join("Snowbound"))913 Some(PathBuf::from(std::env::var_os("APPDATA")?).join("Snowbound"))
885}914}
886915
916/// The user's Documents known folder, wherever it was moved.
917pub fn documents_dir() -> Option<PathBuf> {
918 use std::os::windows::ffi::OsStringExt;
919 use windows_sys::Win32::{
920 System::Com::CoTaskMemFree,
921 UI::Shell::{FOLDERID_Documents, KF_FLAG_DEFAULT, SHGetKnownFolderPath},
922 };
923 let mut path = std::ptr::null_mut();
924 let found = unsafe {
925 SHGetKnownFolderPath(
926 &FOLDERID_Documents,
927 KF_FLAG_DEFAULT as u32,
928 std::ptr::null_mut(),
929 &mut path,
930 )
931 } == 0;
932 let folder = found.then(|| unsafe {
933 let length = (0..).take_while(|&at| *path.add(at) != 0).count();
934 PathBuf::from(std::ffi::OsString::from_wide(std::slice::from_raw_parts(
935 path, length,
936 )))
937 });
938 unsafe { CoTaskMemFree(path as _) };
939 folder
940}
941
887/// None: Windows reaches a share by its UNC path with the share modes and byte-range942/// None: Windows reaches a share by its UNC path with the share modes and byte-range
888/// locks OneNote takes, so a notebook there opens as a folder, as OneNote opens it.943/// locks OneNote takes, so a notebook there opens as a folder, as OneNote opens it.
889/// Snowbound's own client serves Open Notebook from Server….944/// Snowbound's own client serves Open Notebook from Server….
crates/ui/src/layout.rs+34
...@@ -265,6 +265,40 @@ fn flow(nodes: &[Built], index: usize, axis: usize) -> f32 {...@@ -265,6 +265,40 @@ fn flow(nodes: &[Built], index: usize, axis: usize) -> f32 {
265 }265 }
266}266}
267267
268/// The least width `index`'s children fit in, with its padding: each row's groups folded, and
269/// each box sized on its own yielding all its strictness lets it.
270pub(crate) fn narrowest(nodes: &[Built], index: usize) -> f32 {
271 let node = &nodes[index];
272 let sizes = node
273 .children
274 .iter()
275 .filter(|child| {
276 !nodes[**child].flags.contains(Flags::FLOAT) && nodes[**child].anchor.is_none()
277 })
278 .map(|child| least(nodes, *child));
279 let content = if along(node, 0) {
280 let (sum, count) = sizes.fold((0.0, 0), |(sum, count), size| (sum + size, count + 1));
281 sum + node.gap * (count.max(1) - 1) as f32
282 } else {
283 sizes.fold(0.0, f32::max)
284 };
285 content + 2.0 * node.pad[0]
286}
287
288/// The least width `index` takes without clipping its children.
289fn least(nodes: &[Built], index: usize) -> f32 {
290 let node = &nodes[index];
291 if node.fold.is_some() {
292 return least(nodes, node.children[1]) + 2.0 * node.pad[0];
293 }
294 let own = match node.size[0].size {
295 Size::Pixels(pixels) => pixels,
296 Size::Text => node.content_width() + 2.0 * node.pad[0],
297 Size::Fraction(_) | Size::Children => return narrowest(nodes, index),
298 };
299 own * node.size[0].strictness.clamp(0.0, 1.0)
300}
301
268fn fit_labels(nodes: &mut [Built], texts: &mut Texts, frame: u64) {302fn fit_labels(nodes: &mut [Built], texts: &mut Texts, frame: u64) {
269 for node in nodes.iter_mut() {303 for node in nodes.iter_mut() {
270 let Some(label) = node.label.clone() else {304 let Some(label) = node.label.clone() else {
crates/ui/src/lib.rs+7
...@@ -827,6 +827,13 @@ impl Ui {...@@ -827,6 +827,13 @@ impl Ui {
827 self.hit(point, Flags::CLICKABLE | Flags::CUSTOM)827 self.hit(point, Flags::CLICKABLE | Flags::CUSTOM)
828 }828 }
829829
830 /// The least width the box's children fit in, with its padding, as built this frame or
831 /// last: each row's groups folded, and each box yielding all its strictness lets it.
832 pub fn narrowest(&self, id: Id) -> Option<f32> {
833 let index = self.nodes.iter().position(|node| node.id == id)?;
834 Some(layout::narrowest(&self.nodes, index))
835 }
836
830 /// The box's rectangle from the latest layout.837 /// The box's rectangle from the latest layout.
831 pub fn rect(&self, id: Id) -> Option<[f32; 4]> {838 pub fn rect(&self, id: Id) -> Option<[f32; 4]> {
832 self.states.get(&id).map(|state| state.rect)839 self.states.get(&id).map(|state| state.rect)
crates/ui/src/tests.rs+11
...@@ -216,6 +216,17 @@ fn a_row_squeezes_its_boxes_only_once_every_group_is_folded() {...@@ -216,6 +216,17 @@ fn a_row_squeezes_its_boxes_only_once_every_group_is_folded() {
216 assert_eq!(rects[4], Some([80.0, 0.0, 100.0, 20.0]));216 assert_eq!(rects[4], Some([80.0, 0.0, 100.0, 20.0]));
217}217}
218218
219#[test]
220fn a_row_is_narrowest_with_every_group_folded_and_every_box_squeezed() {
221 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
222 folding_row(&mut ui, 300.0, &[0, 1]);
223 let row = Id::ROOT.child("row");
224 // The space gives all, the loose box 60 of 80, each group 80 of 100.
225 assert_eq!(ui.narrowest(row), Some(60.0));
226 let rects = folding_row(&mut ui, 60.0, &[0, 1]);
227 assert_eq!(rects[4], Some([40.0, 0.0, 60.0, 20.0]));
228}
229
219#[test]230#[test]
220fn a_box_sized_by_its_children_yields_what_they_yield() {231fn a_box_sized_by_its_children_yields_what_they_yield() {
221 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);232 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
crates/ui/src/text.rs+1
...@@ -149,6 +149,7 @@ impl Glyphs for Label {...@@ -149,6 +149,7 @@ impl Glyphs for Label {
149 if let PositionedLayoutItem::GlyphRun(run) = item {149 if let PositionedLayoutItem::GlyphRun(run) = item {
150 draw::paint_parley_run(150 draw::paint_parley_run(
151 &run,151 &run,
152 "",
152 metrics.baseline,153 metrics.baseline,
153 0.0,154 0.0,
154 [metrics.block_min_coord, metrics.line_height],155 [metrics.block_min_coord, metrics.line_height],
docs/guide-notebook/Snowbound Guide/.snowbound/tags.json created+8
...@@ -0,0 +1,8 @@
1[
2 {
3 "name": "Snowflake",
4 "shape": 34,
5 "art": "308029094b76e765ca4025c6e8978ff3f1e5f696601ca46fed3803a81c37792c.svg",
6 "mapped": 134352774263488320
7 }
8]
\ No newline at end of file
docs/guide-notebook/Snowbound Guide/.snowbound/tags/308029094b76e765ca4025c6e8978ff3f1e5f696601ca46fed3803a81c37792c.svg created+14
...@@ -0,0 +1,14 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e8f6ff"/><stop offset="1" stop-color="#58a8e8"/></linearGradient>
4 </defs>
5 <g fill="none" stroke-linecap="round" stroke="#2a6cb0" stroke-width="2.4">
6 <path d="M8 1.2V14.8M2.1 4.6L13.9 11.4M2.1 11.4L13.9 4.6"/>
7 <path d="M6.2 2.6L8 4.2L9.8 2.6M6.2 13.4L8 11.8L9.8 13.4M2.4 7L4.6 7.6L4 9.8M13.6 7L11.4 7.6L12 9.8M2.4 9L4.6 8.4L4 6.2M13.6 9L11.4 8.4L12 6.2"/>
8 </g>
9 <g fill="none" stroke-linecap="round" stroke="url(#g0)" stroke-width="1.2">
10 <path d="M8 1.2V14.8M2.1 4.6L13.9 11.4M2.1 11.4L13.9 4.6"/>
11 <path d="M6.2 2.6L8 4.2L9.8 2.6M6.2 13.4L8 11.8L9.8 13.4M2.4 7L4.6 7.6L4 9.8M13.6 7L11.4 7.6L12 9.8M2.4 9L4.6 8.4L4 6.2M13.6 9L11.4 8.4L12 6.2"/>
12 </g>
13 <circle cx="8" cy="8" r="1.6" fill="#ffffff" stroke="#2a6cb0" stroke-width="0.6"/>
14</svg>
docs/guide-notebook/Snowbound Guide/Drawing & Recording.one created
Binary files /dev/null and b/docs/guide-notebook/Snowbound Guide/Drawing & Recording.one differ
docs/guide-notebook/Snowbound Guide/Getting Started.one created
Binary files /dev/null and b/docs/guide-notebook/Snowbound Guide/Getting Started.one differ
docs/guide-notebook/Snowbound Guide/Open Notebook.onetoc2 created
Binary files /dev/null and b/docs/guide-notebook/Snowbound Guide/Open Notebook.onetoc2 differ
docs/guide-notebook/Snowbound Guide/Sharing & Sync.one created
Binary files /dev/null and b/docs/guide-notebook/Snowbound Guide/Sharing & Sync.one differ
docs/guide-notebook/Snowbound Guide/Tags & Search.one created
Binary files /dev/null and b/docs/guide-notebook/Snowbound Guide/Tags & Search.one differ
docs/guide-notebook/Snowbound Guide/Writing & Formatting.one created
Binary files /dev/null and b/docs/guide-notebook/Snowbound Guide/Writing & Formatting.one differ
platform/snow-leopard/rt/appkit.c-8
...@@ -127,11 +127,6 @@ static CGFloat red(id self, SEL _cmd) { return component(self, 0); }...@@ -127,11 +127,6 @@ static CGFloat red(id self, SEL _cmd) { return component(self, 0); }
127static CGFloat green(id self, SEL _cmd) { return component(self, 1); }127static CGFloat green(id self, SEL _cmd) { return component(self, 1); }
128static CGFloat blue(id self, SEL _cmd) { return component(self, 2); }128static CGFloat blue(id self, SEL _cmd) { return component(self, 2); }
129129
130static id black(id self, SEL _cmd) { return SEND(objc_getClass("NSColor"), "blackColor"); }
131static id secondary_selection(id self, SEL _cmd) {
132 return SEND(objc_getClass("NSColor"), "secondarySelectedControlColor");
133}
134
135#define RECT "{CGRect={CGPoint=dd}{CGSize=dd}}"130#define RECT "{CGRect={CGPoint=dd}{CGSize=dd}}"
136131
137static void add(const char *class_name, int meta, const char *selector, void *imp, const char *types) {132static 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) {...@@ -153,7 +148,6 @@ __attribute__((constructor)) static void polyfill(void) {
153 add("NSWindow", 0, "setTitlebarAppearsTransparent:", ignore, "v@:c");148 add("NSWindow", 0, "setTitlebarAppearsTransparent:", ignore, "v@:c");
154 add("NSView", 0, "setWantsBestResolutionOpenGLSurface:", ignore, "v@:c");149 add("NSView", 0, "setWantsBestResolutionOpenGLSurface:", ignore, "v@:c");
155 add("NSView", 0, "setAccessibilityLabel:", set_accessibility_label, "v@:@");150 add("NSView", 0, "setAccessibilityLabel:", set_accessibility_label, "v@:@");
156 add("NSDatePicker", 0, "setPresentsCalendarOverlay:", ignore, "v@:c");
157 add("NSEvent", 0, "hasPreciseScrollingDeltas", no, "c@:");151 add("NSEvent", 0, "hasPreciseScrollingDeltas", no, "c@:");
158 add("NSEvent", 0, "scrollingDeltaX", delta_x, "d@:");152 add("NSEvent", 0, "scrollingDeltaX", delta_x, "d@:");
159 add("NSEvent", 0, "scrollingDeltaY", delta_y, "d@:");153 add("NSEvent", 0, "scrollingDeltaY", delta_y, "d@:");
...@@ -163,8 +157,6 @@ __attribute__((constructor)) static void polyfill(void) {...@@ -163,8 +157,6 @@ __attribute__((constructor)) static void polyfill(void) {
163 add("NSColorSpaceColor", 0, "greenComponent", green, "d@:");157 add("NSColorSpaceColor", 0, "greenComponent", green, "d@:");
164 add("NSColorSpaceColor", 0, "blueComponent", blue, "d@:");158 add("NSColorSpaceColor", 0, "blueComponent", blue, "d@:");
165 add("NSColor", 1, "colorWithSRGBRed:green:blue:alpha:", srgb_color, "@@:dddd");159 add("NSColor", 1, "colorWithSRGBRed:green:blue:alpha:", srgb_color, "@@:dddd");
166 add("NSColor", 1, "textInsertionPointColor", black, "@@:");
167 add("NSColor", 1, "unemphasizedSelectedTextBackgroundColor", secondary_selection, "@@:");
168160
169 // One appearance, Aqua, which windows have and nothing changes.161 // One appearance, Aqua, which windows have and nothing changes.
170 appearance_class = (id)objc_getClass("NSAppearance");162 appearance_class = (id)objc_getClass("NSAppearance");
tools/RELEASE.md+30-7
...@@ -18,16 +18,17 @@ development builds, which never update themselves....@@ -18,16 +18,17 @@ development builds, which never update themselves.
18## The published folder18## The published folder
1919
20```text20```text
21latest.json {"macos-aarch64": "2026-09-29-r10", "macos-10.6": ..., "linux-x86_64": ..., "windows-x86_64": ..., ...}21latest.json {"macos-aarch64": "2026-09-29-r10", "macos-x86_64": ..., "macos-10.6": ..., "linux-x86_64": ..., ...}
222026-09-29.r10/222026-09-29.r10/
23 build.json version, commit, and per platform: file, size, sha256, signature23 build.json version, commit, and per platform: file, size, sha256, signature
24 build.json.sig ed25519 signature of build.json, hex24 build.json.sig ed25519 signature of build.json, hex
25 Snowbound-2026-09-29-r10-macos-aarch64.zip25 Snowbound-2026-09-29-r10-macos-aarch64.zip
26 Snowbound-2026-09-29-r10-macos-x86_64.zip
26 Snowbound-2026-09-29-r10-macos-10.6.zip27 Snowbound-2026-09-29-r10-macos-10.6.zip
27 snowbound-2026-09-29-r10-linux-x86_64 the executable itself28 snowbound-2026-09-29-r10-linux-x86_64 the executable itself
28 snowbound-2026-09-29-r10-linux-aarch6429 snowbound-2026-09-29-r10-linux-aarch64
29 snowbound-2026-09-29-r10-windows-x86_64.exe Windows 7 SP1 to 1130 snowbound-2026-09-29-r10-windows-x86_64.exe
30 snowbound-2026-09-29-r10-windows-aarch64.exe Windows 11 on Arm31 snowbound-2026-09-29-r10-windows-aarch64.exe
31```32```
3233
33A build folder is written once, under a hidden `.2026-09-29.r10.partial` name renamed into34A build folder is written once, under a hidden `.2026-09-29.r10.partial` name renamed into
...@@ -82,7 +83,7 @@ quarantine and open directly....@@ -82,7 +83,7 @@ quarantine and open directly.
82## Publishing83## Publishing
8384
84```sh85```sh
85python3 tools/release.py # all six platforms86python3 tools/release.py # all seven platforms
86python3 tools/release.py --ad-hoc # the macOS app without Developer ID87python3 tools/release.py --ad-hoc # the macOS app without Developer ID
87python3 tools/release.py --dry-run # the same into a new temporary folder88python3 tools/release.py --dry-run # the same into a new temporary folder
88python3 tools/release.py --platforms macos-aarch64 linux-x86_6489python3 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...@@ -99,9 +100,30 @@ publishes as above. Run again for the same commit, it only brings
99`latest.json` up to date; a different commit that derives the same version is100`latest.json` up to date; a different commit that derives the same version is
100refused. The 10.6 build needs the SDK and nightly toolchain101refused. The 10.6 build needs the SDK and nightly toolchain
101`platform/snow-leopard/cargo.sh` names; the Linux builds need `zig`, as the102`platform/snow-leopard/cargo.sh` names; the Linux builds need `zig`, as the
102cross linker against glibc 2.31; the Windows builds need llvm-mingw, which103cross linker against glibc 2.17; the Windows builds need llvm-mingw, which
103`platform/windows/toolchain.sh` fetches, and nightly with `rust-src` for104`platform/windows/toolchain.sh` fetches, and nightly with `rust-src` for
104x86_64. All six build from an Apple silicon Mac.105x86_64. All seven build from an Apple silicon Mac.
106
107## Minimum systems
108
109Each build runs as far back as its dependencies allow, and newer calls are
110checked at run time (`respondsToSelector:`, `available!`, `dlsym`).
111
112| Platform | Oldest system | Set by |
113| --- | --- | --- |
114| `macos-aarch64` | macOS 11 | Apple silicon's first; rustc's default |
115| `macos-x86_64` | macOS 10.13 | wgpu's Metal floor; `MACOSX_DEPLOYMENT_TARGET` |
116| `macos-10.6` | Mac OS X 10.6 | its own SDK, runtime shims and OpenGL renderer |
117| `linux-*` | glibc 2.17 (RHEL 7, Debian 8, Ubuntu 14.04) | Rust's floor; zig's glibc target |
118| `windows-x86_64` | Windows 7 SP1 | nightly's `x86_64-win7-windows-gnu` |
119| `windows-aarch64` | Windows 11 on Arm | `aarch64-pc-windows-gnullvm` |
120
121`build_macos.py` writes the minimum as `LSMinimumSystemVersion`; the binary
122carries it as `LC_BUILD_VERSION` `minos` (`LC_VERSION_MIN_MACOSX` below 10.14),
123which `otool -l` shows. `objdump -T` of a Linux executable names no `GLIBC_`
124version above 2.17. The Linux executable links only libc, libm, libpthread and
125libdl; Wayland, X11, xkbcommon, EGL, Vulkan, fontconfig, GStreamer and Enchant
126are loaded at run time.
105127
106## In the app128## In the app
107129
...@@ -111,7 +133,8 @@ Work Offline is on; Check for Updates… (the app menu on macOS, the command...@@ -111,7 +133,8 @@ Work Offline is on; Check for Updates… (the app menu on macOS, the command
111palette elsewhere) checks at once and reports what it found. A check reads133palette elsewhere) checks at once and reports what it found. A check reads
112`latest.json`, then the named build's `build.json` and signature, and134`latest.json`, then the named build's `build.json` and signature, and
113downloads the archive for this platform, verifying size, SHA-256 and135downloads the archive for this platform, verifying size, SHA-256 and
114signature. It stages the update beside the install, so the swap is a rename:136signature. An Intel build that Rosetta runs takes `macos-aarch64`'s, even at
137its own version. It stages the update beside the install, so the swap is a rename:
115the app unpacked into `.Snowbound.app.update` next to the bundle on macOS, the138the app unpacked into `.Snowbound.app.update` next to the bundle on macOS, the
116executable into `.snowbound.update` next to it on Linux (`.snowbound.exe.update` on139executable into `.snowbound.update` next to it on Linux (`.snowbound.exe.update` on
117Windows). The sync status icon gets a dot, and its popup offers140Windows). The sync status icon gets a dot, and its popup offers
tools/canvas/README.md+1-1
...@@ -54,7 +54,7 @@ Launched without a file, the app reopens the notebooks open when it last closed,...@@ -54,7 +54,7 @@ Launched without a file, the app reopens the notebooks open when it last closed,
5454
55The `ui` crate builds every frame from the application's state. A cache keyed by stable box ids keeps hover, press, focus, scroll and animation values, and the previous frame's layout routes the frame's input before building, so a frame answers input one layout late and paints with its own. Sizes are solved per axis after building: fixed, label-sized, a fraction of an ancestor, or the sum of children, with overflow shared out by each box's strictness. The page is a custom box: `ui` routes it the pointer, wheel, key and input-method events that land on it, in order, and the host hands them to `PageView` and paints the page in a clipped layer of the same frame. Page scrollbars are `ui` widgets over that box; the canvas reports only its scroll bounds. Text fields edit through `draw::edit`, as the page does, so keys, clicks and drags select and move the same way in both. Work the frame asks for (page requests, saving) runs after the frame is painted and requests the frame that shows it. Opening a section or page reads and lays it out on a thread of its own while the current page stays live; the newest replaces the page once the pictures it shows first are drawn, or after 200 ms.55The `ui` crate builds every frame from the application's state. A cache keyed by stable box ids keeps hover, press, focus, scroll and animation values, and the previous frame's layout routes the frame's input before building, so a frame answers input one layout late and paints with its own. Sizes are solved per axis after building: fixed, label-sized, a fraction of an ancestor, or the sum of children, with overflow shared out by each box's strictness. The page is a custom box: `ui` routes it the pointer, wheel, key and input-method events that land on it, in order, and the host hands them to `PageView` and paints the page in a clipped layer of the same frame. Page scrollbars are `ui` widgets over that box; the canvas reports only its scroll bounds. Text fields edit through `draw::edit`, as the page does, so keys, clicks and drags select and move the same way in both. Work the frame asks for (page requests, saving) runs after the frame is painted and requests the frame that shows it. Opening a section or page reads and lays it out on a thread of its own while the current page stays live; the newest replaces the page once the pictures it shows first are drawn, or after 200 ms.
5656
57A covered window receives no redraws, so interaction can be scripted: `SNOWBOUND_REPLAY` names a file of `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `key NAME`, `type TEXT`, `modifiers [shift] [command]`, `wait MS`, `snapshot PNG_PATH`, `accessibility TEXT_PATH` (the window's accessibility tree, a node a line, as the platform shows it), `appearance light|dark`, `resize WIDTH HEIGHT` and `quit` lines in logical pixels, and each step and every 16 ms of a wait draws a frame. With `--screenshot` as well, the window stays hidden and only the replay's snapshots capture it. Snapshots wait for the page's pictures, file icons and background art to finish rasterizing; `SNOWBOUND_FRAMES` frames never wait, so art appears in the first frame drawn after it is ready. Snapshots omit the window's traffic lights, which AppKit draws. Replays edit and save like a user, so point them at a copy of a notebook. Their edits carry the settings' user name, so give fixture runs a `--settings` file holding `{"user_name": "Snowbound Test"}`.57A covered window receives no redraws, so interaction can be scripted: `SNOWBOUND_REPLAY` names a file of `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `pinch FACTOR`, `key NAME`, `type TEXT`, `modifiers [shift] [command]`, `wait MS`, `snapshot PNG_PATH`, `accessibility TEXT_PATH` (the window's accessibility tree, a node a line, as the platform shows it), `appearance light|dark`, `resize WIDTH HEIGHT` and `quit` lines in logical pixels, and each step and every 16 ms of a wait draws a frame. With `--screenshot` as well, the window stays hidden and only the replay's snapshots capture it. Snapshots wait for the page's pictures, file icons and background art to finish rasterizing; `SNOWBOUND_FRAMES` frames never wait, so art appears in the first frame drawn after it is ready. Snapshots omit the window's traffic lights, which AppKit draws. Replays edit and save like a user, so point them at a copy of a notebook. Their edits carry the settings' user name, so give fixture runs a `--settings` file holding `{"user_name": "Snowbound Test"}`.
5858
59```sh59```sh
60cp -RL SOURCE_NOTEBOOK /tmp/notebook-copy && chmod u+w /tmp/notebook-copy/*.one60cp -RL SOURCE_NOTEBOOK /tmp/notebook-copy && chmod u+w /tmp/notebook-copy/*.one
tools/canvas/build_macos.py+17-4
...@@ -16,6 +16,8 @@ parser = argparse.ArgumentParser(description=__doc__)...@@ -16,6 +16,8 @@ parser = argparse.ArgumentParser(description=__doc__)
16parser.add_argument('--release', action='store_true')16parser.add_argument('--release', action='store_true')
17parser.add_argument('--output', type=Path, help='Create a separate bundle at a new .app path')17parser.add_argument('--output', type=Path, help='Create a separate bundle at a new .app path')
18parser.add_argument('--bundle-id', help="Bundle identifier for the separate app; the app's own otherwise")18parser.add_argument('--bundle-id', help="Bundle identifier for the separate app; the app's own otherwise")
19host = 'aarch64' if platform.machine() == 'arm64' else 'x86_64'
20parser.add_argument('--arch', choices=['aarch64', 'x86_64'], default=host, help="The app's architecture; this Mac's by default")
19parser.add_argument('--snow-leopard', action='store_true',21parser.add_argument('--snow-leopard', action='store_true',
20 help='Build with platform/snow-leopard/cargo.sh into target/snow-leopard')22 help='Build with platform/snow-leopard/cargo.sh into target/snow-leopard')
21parser.add_argument('--sign-identity', metavar='SHA1',23parser.add_argument('--sign-identity', metavar='SHA1',
...@@ -29,6 +31,9 @@ if args.bundle_id and not args.output:...@@ -29,6 +31,9 @@ if args.bundle_id and not args.output:
29 parser.error('Use --bundle-id with --output.')31 parser.error('Use --bundle-id with --output.')
30if args.sign and args.snow_leopard:32if args.sign and args.snow_leopard:
31 parser.error('The 10.6 bundle stays unsigned.')33 parser.error('The 10.6 bundle stays unsigned.')
34# The oldest macOS each build runs on: 10.6's own build, wgpu's floor on Intel, and Apple
35# silicon's first, which rustc also defaults to.
36minimum = '10.6' if args.snow_leopard else {'x86_64': '10.13', 'aarch64': '11.0'}[args.arch]
32if args.output and (args.output.suffix != '.app' or args.output.exists()):37if args.output and (args.output.suffix != '.app' or args.output.exists()):
33 parser.error('Choose a new output path ending in .app.')38 parser.error('Choose a new output path ending in .app.')
34root = Path(__file__).resolve().parents[2]39root = Path(__file__).resolve().parents[2]
...@@ -83,9 +88,13 @@ if args.snow_leopard:...@@ -83,9 +88,13 @@ if args.snow_leopard:
83 target = root / 'target/snow-leopard'88 target = root / 'target/snow-leopard'
84 built = target / 'x86_64-apple-macosx10.6'89 built = target / 'x86_64-apple-macosx10.6'
85else:90else:
86 subprocess.run(['cargo', 'build', '-p', 'snowbound'] + profile, cwd=root, check=True)
87 metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--format-version=1', '--no-deps'], cwd=root))91 metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--format-version=1', '--no-deps'], cwd=root))
88 target = built = Path(metadata['target_directory'])92 target = built = Path(metadata['target_directory'])
93 cross = [] if args.arch == host else ['--target', f'{args.arch}-apple-darwin']
94 if cross:
95 built = target / cross[1]
96 subprocess.run(['cargo', 'build', '-p', 'snowbound'] + profile + cross, cwd=root, check=True,
97 env={**os.environ, 'MACOSX_DEPLOYMENT_TARGET': minimum} if args.arch == 'x86_64' else None)
89bundle = args.output.resolve() if args.output else target / 'Snowbound.app'98bundle = args.output.resolve() if args.output else target / 'Snowbound.app'
90if args.output:99if args.output:
91 bundle.mkdir(parents=True, exist_ok=False)100 bundle.mkdir(parents=True, exist_ok=False)
...@@ -107,8 +116,10 @@ if args.snow_leopard:...@@ -107,8 +116,10 @@ if args.snow_leopard:
107 '--out', iconset / f'icon_{side}x{side}.png'], check=True, capture_output=True)116 '--out', iconset / f'icon_{side}x{side}.png'], check=True, capture_output=True)
108 subprocess.run(['iconutil', '-c', 'icns', iconset, '-o', resources / 'Snowbound.icns'], check=True)117 subprocess.run(['iconutil', '-c', 'icns', iconset, '-o', resources / 'Snowbound.icns'], check=True)
109else:118else:
110 icon = 'Snowbound-Tahoe' if int(platform.mac_ver()[0].split('.')[0] or 0) >= 26 else 'Snowbound-Sequoia'119 # macOS 26 draws the Liquid Glass icon from the catalog; it holds no flattened renditions
111 shutil.copy2(icons / f'{icon}.icns', resources / 'Snowbound.icns')120 # (see the icon folder's README), so earlier versions fall back to the icns.
121 shutil.copy2(icons / 'Snowbound-Sequoia.icns', resources / 'Snowbound.icns')
122 shutil.copy2(icons / 'Assets.car', resources)
112# tools/release.py names the build, as 2026-09-29-r10, which About shows.123# tools/release.py names the build, as 2026-09-29-r10, which About shows.
113build = os.environ.get('SNOWBOUND_BUILD')124build = os.environ.get('SNOWBOUND_BUILD')
114versions = {}125versions = {}
...@@ -126,6 +137,7 @@ if build:...@@ -126,6 +137,7 @@ if build:
126 'NSHighResolutionCapable': True,137 'NSHighResolutionCapable': True,
127 'NSPrincipalClass': 'NSApplication',138 'NSPrincipalClass': 'NSApplication',
128 'CFBundleIconFile': 'Snowbound',139 'CFBundleIconFile': 'Snowbound',
140 'CFBundleIconName': 'Snowbound-Tahoe',
129 'NSLocalNetworkUsageDescription': 'Snowbound connects to file servers on your network to open and sync shared notebooks.',141 'NSLocalNetworkUsageDescription': 'Snowbound connects to file servers on your network to open and sync shared notebooks.',
130 'NSMicrophoneUsageDescription':'Snowbound records audio into your notes when you choose Record Audio or Record Video.',142 'NSMicrophoneUsageDescription':'Snowbound records audio into your notes when you choose Record Audio or Record Video.',
131 'NSCameraUsageDescription': 'Snowbound records video into your notes when you choose Record Video.',143 'NSCameraUsageDescription': 'Snowbound records video into your notes when you choose Record Video.',
...@@ -134,7 +146,8 @@ if build:...@@ -134,7 +146,8 @@ if build:
134 'NSUbiquitousContainerName': 'Snowbound',146 'NSUbiquitousContainerName': 'Snowbound',
135 'NSUbiquitousContainerSupportedFolderLevels': 'Any',147 'NSUbiquitousContainerSupportedFolderLevels': 'Any',
136 }},148 }},
137} | versions | ({'LSMinimumSystemVersion': '10.6'} if args.snow_leopard else {})))149 'LSMinimumSystemVersion': minimum,
150} | versions))
138# 10.6 runs the bundle unsigned.151# 10.6 runs the bundle unsigned.
139if not args.snow_leopard:152if not args.snow_leopard:
140 identity = args.sign != 'ad-hoc' and (args.sign_identity or developer_id())153 identity = args.sign != 'ad-hoc' and (args.sign_identity or developer_id())
tools/native/picture-objects.ps1 created+37
...@@ -0,0 +1,37 @@
1param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost)
2# A page of pictures and files, in an outline and on the page, for tagging, linking and OCR
3# by hand in the clone: notebook/photo.png, notebook/ocr.png and notebook/notes.txt ride along
4# with the section.
5Set-StrictMode -Version Latest
6$ErrorActionPreference = 'Stop'
7& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost
8$namespace = 'http://schemas.microsoft.com/office/onenote/2010/onenote'
9$notebook = Join-Path $Root 'notebook'
10$data = { param($name) [Convert]::ToBase64String([IO.File]::ReadAllBytes((Join-Path $notebook $name))) }
11$file = Join-Path $notebook 'notes.txt'
12$app = New-Object -ComObject OneNote.Application
13$notebookId = ''
14try {
15 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
16 $sectionId = ''
17 $app.OpenHierarchy('files.one', $notebookId, [ref]$sectionId, 0)
18 $pageId = ''
19 $app.CreateNewPage($sectionId, [ref]$pageId, 0)
20 $body = '<one:Title><one:OE><one:T><![CDATA[Pictures and files]]></one:T></one:OE></one:Title>'
21 $body += '<one:Outline><one:Position x="36" y="86"/><one:Size width="300" height="200"/><one:OEChildren>'
22 $body += '<one:OE><one:T><![CDATA[Picture below]]></one:T></one:OE>'
23 $body += '<one:OE><one:Image><one:Size width="120" height="90"/><one:Data>' + (& $data 'photo.png') + '</one:Data></one:Image></one:OE>'
24 $body += '<one:OE><one:InsertedFile pathSource="' + $file + '" preferredName="notes.txt"/></one:OE>'
25 $body += '<one:OE><one:T><![CDATA[Text after]]></one:T></one:OE>'
26 $body += '</one:OEChildren></one:Outline>'
27 $body += '<one:Image><one:Position x="400" y="86"/><one:Size width="120" height="90"/><one:Data>' + (& $data 'photo.png') + '</one:Data></one:Image>'
28 $body += '<one:InsertedFile pathSource="' + $file + '" preferredName="notes.txt"><one:Position x="560" y="86"/></one:InsertedFile>'
29 $body += '<one:Image><one:Position x="400" y="260"/><one:Size width="320" height="80"/><one:Data>' + (& $data 'ocr.png') + '</one:Data></one:Image>'
30 $xml = '<one:Page xmlns:one="' + $namespace + '" ID="' + $pageId + '">' + $body + '</one:Page>'
31 $app.UpdatePageContent($xml, [DateTime]::MinValue, 1, $true)
32 $app.SyncHierarchy($notebookId)
33} finally {
34 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
35 [GC]::Collect()
36 [GC]::WaitForPendingFinalizers()
37}
tools/native_runner.py+1-1
...@@ -151,7 +151,7 @@ def capture(notebook, output, expected_pages=-1, author=None, screenshots=False,...@@ -151,7 +151,7 @@ def capture(notebook, output, expected_pages=-1, author=None, screenshots=False,
151 'author_timeout_seconds': author_timeout,151 'author_timeout_seconds': author_timeout,
152 'inspect': inspect,152 'inspect': inspect,
153 'collect_notebook': collect_notebook,153 'collect_notebook': collect_notebook,
154 'base': json.loads(vm.BASE_MANIFEST.read_text()),154 'base': json.loads(vm.base_manifest('win7').read_text()),
155 'scripts': {name: hashlib.sha256((scripts / name).read_bytes()).hexdigest()155 'scripts': {name: hashlib.sha256((scripts / name).read_bytes()).hexdigest()
156 for name in sorted(p.name for p in scripts.iterdir())},156 for name in sorted(p.name for p in scripts.iterdir())},
157 }, indent=2) + '\n')157 }, indent=2) + '\n')
tools/release.py+5-3
...@@ -17,8 +17,8 @@ PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound')...@@ -17,8 +17,8 @@ PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound')
17URL = 'https://file.paperclover.net/shr/snowbound/'17URL = 'https://file.paperclover.net/shr/snowbound/'
18KEY = Path.home() / '.config/snowbound/release-key'18KEY = Path.home() / '.config/snowbound/release-key'
19ZONE = ZoneInfo('America/Los_Angeles')19ZONE = ZoneInfo('America/Los_Angeles')
20PLATFORMS = ['macos-aarch64', 'macos-10.6', 'linux-x86_64', 'linux-aarch64', 'windows-x86_64',20PLATFORMS = ['macos-aarch64', 'macos-x86_64', 'macos-10.6', 'linux-x86_64', 'linux-aarch64',
21 'windows-aarch64']21 'windows-x86_64', 'windows-aarch64']
22# Windows 7 to 11 on x86_64 (nightly's tier-3 win7 target), and Windows 11 on Arm.22# Windows 7 to 11 on x86_64 (nightly's tier-3 win7 target), and Windows 11 on Arm.
23WINDOWS = {'x86_64': 'x86_64-win7-windows-gnu', 'aarch64': 'aarch64-pc-windows-gnullvm'}23WINDOWS = {'x86_64': 'x86_64-win7-windows-gnu', 'aarch64': 'aarch64-pc-windows-gnullvm'}
24CHECKS = [24CHECKS = [
...@@ -114,6 +114,8 @@ def build_mac(platform, folder, developer_id, notarize):...@@ -114,6 +114,8 @@ def build_mac(platform, folder, developer_id, notarize):
114 signing = ['--sign', 'developer-id', '--sign-identity', IDENTITY]114 signing = ['--sign', 'developer-id', '--sign-identity', IDENTITY]
115 else:115 else:
116 signing = ['--sign', 'ad-hoc']116 signing = ['--sign', 'ad-hoc']
117 if platform != 'macos-10.6':
118 signing += ['--arch', platform.removeprefix('macos-')]
117 run([sys.executable, ROOT / 'tools/canvas/build_macos.py', '--release', '--output', bundle, *signing])119 run([sys.executable, ROOT / 'tools/canvas/build_macos.py', '--release', '--output', bundle, *signing])
118 archive = folder / 'archive.zip'120 archive = folder / 'archive.zip'
119 if notarize and platform != 'macos-10.6':121 if notarize and platform != 'macos-10.6':
...@@ -150,7 +152,7 @@ def main():...@@ -150,7 +152,7 @@ def main():
150 parser.add_argument('--ad-hoc', action='store_true',152 parser.add_argument('--ad-hoc', action='store_true',
151 help='Sign the macOS app ad hoc instead of with Developer ID, unnotarized')153 help='Sign the macOS app ad hoc instead of with Developer ID, unnotarized')
152 args = parser.parse_args()154 args = parser.parse_args()
153 developer_id = not args.ad_hoc and any(platform == 'macos-aarch64' for platform in args.platforms)155 developer_id = not args.ad_hoc and any(platform in ('macos-aarch64', 'macos-x86_64') for platform in args.platforms)
154 identities = subprocess.run(['security', 'find-identity', '-v', '-p', 'codesigning'],156 identities = subprocess.run(['security', 'find-identity', '-v', '-p', 'codesigning'],
155 capture_output=True, text=True).stdout157 capture_output=True, text=True).stdout
156 if developer_id and IDENTITY not in identities:158 if developer_id and IDENTITY not in identities:
tools/test_equation_select.py created+40
...@@ -0,0 +1,40 @@
1from pathlib import Path
2import re
3import runpy
4import shutil
5from tempfile import TemporaryDirectory
6import unittest
7
8ROOT = Path(__file__).resolve().parent.parent
9FIXTURE = ROOT / 'corpus/equation-select'
10compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
11
12
13def mathml(path):
14 """Each equation's MathML in a COM read, trailing spaces dropped."""
15 xml = path.read_text(encoding='utf-8-sig')
16 return [re.sub(r'(<mml:mo>&nbsp;</mml:mo>)+</mml:math>', '</mml:math>', m).replace(' display="block"', '')
17 for m in re.findall(r'<mml:math[^!]*?</mml:math>', xml)]
18
19
20class EquationSelectTest(unittest.TestCase):
21 def test_edits_into_equation_objects_reopen_as_onenote_made_them(self):
22 with TemporaryDirectory() as temporary:
23 read = Path(temporary) / 'read'
24 shutil.copytree(FIXTURE / 'cold/read', read)
25 compare(FIXTURE / 'candidate', read)
26 onenote = mathml(FIXTURE / 'lab/after.xml')
27 cold = mathml(FIXTURE / 'cold/read/page-000.xml')
28 # The lab page ends with "x" typed over a placeholder, which the candidate has no
29 # placeholder left for.
30 self.assertEqual(onenote[-1], '<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML"><mml:mi>x</mml:mi></mml:math>')
31 self.assertEqual(cold, onenote[:-1])
32 before = mathml(FIXTURE / 'lab/before.xml')
33 self.assertIn('Type&nbsp;equation&nbsp;here.', ''.join(before))
34 self.assertTrue(any('<mml:msqrt>' in m for m in before))
35 self.assertFalse(any('<mml:msqrt>' in m or 'Type&nbsp;equation' in m for m in cold))
36 self.assertTrue(any('<mml:mtable>' in m and 'lim' in m for m in cold))
37
38
39if __name__ == '__main__':
40 unittest.main()
tools/test_release.py+5-2
...@@ -50,9 +50,12 @@ class ReleaseTest(unittest.TestCase):...@@ -50,9 +50,12 @@ class ReleaseTest(unittest.TestCase):
50 [command[1] for command in commands[1:]])50 [command[1] for command in commands[1:]])
5151
52 self.assertEqual(signing('macos-aarch64', True, ['--key-id', 'K']),52 self.assertEqual(signing('macos-aarch64', True, ['--key-id', 'K']),
53 (['--sign', 'developer-id', '--sign-identity', release['IDENTITY']],53 (['--sign', 'developer-id', '--sign-identity', release['IDENTITY'], '--arch', 'aarch64'],
54 ['-c', 'notarytool', 'stapler', '-c']))54 ['-c', 'notarytool', 'stapler', '-c']))
55 self.assertEqual(signing('macos-aarch64', False, None), (['--sign', 'ad-hoc'], ['-c']))55 self.assertEqual(signing('macos-x86_64', True, ['--key-id', 'K']),
56 (['--sign', 'developer-id', '--sign-identity', release['IDENTITY'], '--arch', 'x86_64'],
57 ['-c', 'notarytool', 'stapler', '-c']))
58 self.assertEqual(signing('macos-aarch64', False, None), (['--sign', 'ad-hoc', '--arch', 'aarch64'], ['-c']))
56 self.assertEqual(signing('macos-10.6', True, ['--key-id', 'K']), (['--snow-leopard'], ['-c']))59 self.assertEqual(signing('macos-10.6', True, ['--key-id', 'K']), (['--snow-leopard'], ['-c']))
57 finally:60 finally:
58 scope['run'] = run61 scope['run'] = run