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"
228228checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
229229dependencies = [
230230 "clipboard-win",
231 "image",
231232 "log",
232233 "objc2 0.6.4",
233234 "objc2-app-kit 0.3.2",
235 "objc2-core-foundation",
236 "objc2-core-graphics",
234237 "objc2-foundation 0.3.2",
235238 "parking_lot",
236239 "percent-encoding",
......@@ -633,6 +636,7 @@ dependencies = [
633636 "draw",
634637 "icu_normalizer",
635638 "image",
639 "miniz_oxide 0.8.9",
636640 "onestore",
637641 "parley",
638642 "png",
......@@ -974,10 +978,13 @@ dependencies = [
974978 "bytemuck",
975979 "image",
976980 "linebender_resource_handle",
981 "miniz_oxide 0.8.9",
977982 "parley",
983 "pdf-writer",
978984 "png",
979985 "pollster",
980986 "roxmltree",
987 "subsetter",
981988 "swash",
982989 "wgpu",
983990]
......@@ -1971,7 +1978,7 @@ dependencies = [
19711978 "naga-types",
19721979 "num-traits",
19731980 "once_cell",
1974 "rustc-hash",
1981 "rustc-hash 1.1.0",
19751982 "spirv",
19761983 "thiserror 2.0.20",
19771984 "unicode-ident",
......@@ -1985,7 +1992,7 @@ checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49"
19851992dependencies = [
19861993 "hashbrown 0.17.1",
19871994 "indexmap",
1988 "rustc-hash",
1995 "rustc-hash 1.1.0",
19891996 "thiserror 2.0.20",
19901997]
19911998
......@@ -2046,6 +2053,7 @@ dependencies = [
20462053 "tempfile",
20472054 "thiserror 2.0.20",
20482055 "tokio",
2056 "windows-sys 0.61.2",
20492057 "zeroize",
20502058]
20512059
......@@ -2544,6 +2552,18 @@ dependencies = [
25442552 "hmac",
25452553]
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
25472567[[package]]
25482568name = "percent-encoding"
25492569version = "2.3.2"
......@@ -2951,6 +2971,12 @@ version = "1.1.0"
29512971source = "registry+https://github.com/rust-lang/crates.io-index"
29522972checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
29532973
2974[[package]]
2975name = "rustc-hash"
2976version = "2.1.3"
2977source = "registry+https://github.com/rust-lang/crates.io-index"
2978checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
2979
29542980[[package]]
29552981name = "rustc_version"
29562982version = "0.4.1"
......@@ -3027,6 +3053,12 @@ version = "1.0.23"
30273053source = "registry+https://github.com/rust-lang/crates.io-index"
30283054checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
30293055
3056[[package]]
3057name = "ryu"
3058version = "1.0.23"
3059source = "registry+https://github.com/rust-lang/crates.io-index"
3060checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
3061
30303062[[package]]
30313063name = "same-file"
30323064version = "1.0.6"
......@@ -3414,6 +3446,15 @@ dependencies = [
34143446 "float-cmp",
34153447]
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
34173458[[package]]
34183459name = "subtle"
34193460version = "2.6.1"
......@@ -4157,7 +4198,7 @@ dependencies = [
41574198 "portable-atomic",
41584199 "profiling",
41594200 "raw-window-handle",
4160 "rustc-hash",
4201 "rustc-hash 1.1.0",
41614202 "smallvec",
41624203 "thiserror 2.0.20",
41634204 "wgpu-core-deps-apple",
apps/ios/Snowbound.xcodeproj/project.pbxproj+10-2
......@@ -8,14 +8,18 @@
88
99/* Begin PBXBuildFile section */
1010 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 */; };
1113/* End PBXBuildFile section */
1214
1315/* Begin PBXFileReference section */
1416 5B0000000000000000000200 /* Snowbound.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Snowbound.app; sourceTree = BUILT_PRODUCTS_DIR; };
1517 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; };
1619 5B0000000000000000000202 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
1720 5B0000000000000000000203 /* Snowbound-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Snowbound-Bridging-Header.h"; sourceTree = "<group>"; };
1821 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; };
1923/* End PBXFileReference section */
2024
2125/* Begin PBXFileSystemSynchronizedRootGroup section */
......@@ -42,6 +46,8 @@
4246 children = (
4347 5B0000000000000000000300 /* Snowbound */,
4448 5B0000000000000000000201 /* Sample */,
49 5B0000000000000000000226 /* Snowbound Guide */,
50 5B0000000000000000000205 /* Snowbound-Tahoe.icon */,
4551 5B0000000000000000000202 /* Info.plist */,
4652 5B0000000000000000000203 /* Snowbound-Bridging-Header.h */,
4753 5B0000000000000000000204 /* build-rust.sh */,
......@@ -121,6 +127,8 @@
121127 buildActionMask = 2147483647;
122128 files = (
123129 5B0000000000000000000101 /* Sample in Resources */,
130 5B0000000000000000000126 /* Snowbound Guide in Resources */,
131 5B0000000000000000000102 /* Snowbound-Tahoe.icon in Resources */,
124132 );
125133 runOnlyForDeploymentPostprocessing = 0;
126134 };
......@@ -198,7 +206,7 @@
198206 isa = XCBuildConfiguration;
199207 buildSettings = {
200208 ARCHS = arm64;
201 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
209 ASSETCATALOG_COMPILER_APPICON_NAME = "Snowbound-Tahoe";
202210 CODE_SIGN_ENTITLEMENTS = Snowbound.entitlements;
203211 CURRENT_PROJECT_VERSION = 1;
204212 GENERATE_INFOPLIST_FILE = YES;
......@@ -235,7 +243,7 @@
235243 isa = XCBuildConfiguration;
236244 buildSettings = {
237245 ARCHS = arm64;
238 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
246 ASSETCATALOG_COMPILER_APPICON_NAME = "Snowbound-Tahoe";
239247 CODE_SIGN_ENTITLEMENTS = Snowbound.entitlements;
240248 CURRENT_PROJECT_VERSION = 1;
241249 GENERATE_INFOPLIST_FILE = YES;
apps/ios/Snowbound/App.swift+27
......@@ -44,6 +44,32 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
4444 }
4545}
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
4773/// Where the reader was, reopened on the next launch as Notes reopens its last note.
4874private struct Place: Codable {
4975 let notebook: String
......@@ -71,6 +97,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro
7197 func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {
7298 guard let scene = scene as? UIWindowScene else { return }
7399 let window = UIWindow(windowScene: scene)
100 window.overrideUserInterfaceStyle = Appearance.style
74101 split.delegate = self
75102 split.scene = self
76103 split.preferredDisplayMode = .oneBesideSecondary
apps/ios/Snowbound/CanvasView.swift+30-5
......@@ -771,7 +771,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
771771 case #selector(copy(_:)), #selector(cut(_:)), #selector(delete(_:)),
772772 #selector(toggleBoldface(_:)), #selector(toggleItalics(_:)), #selector(toggleUnderline(_:)):
773773 return lo < hi
774 case #selector(paste(_:)): return UIPasteboard.general.hasStrings
774 case #selector(paste(_:)): return UIPasteboard.general.hasStrings || UIPasteboard.general.hasImages
775775 case #selector(select(_:)): return lo == hi && hasText
776776 case #selector(selectAll(_:)): return hasText && (lo > 0 || hi < endOfDocument.offset)
777777 default: return super.canPerformAction(action, withSender: sender)
......@@ -799,10 +799,15 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
799799 edit(external: true) { replacing(range, with: "") }
800800 }
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.
803804 override func paste(_ sender: Any?) {
804 guard let handle, let text = UIPasteboard.general.string else { return }
805 edit(external: true) { sb_paste(handle, text, textInputMode?.primaryLanguage ?? "") }
805 guard let handle else { return }
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 }
806811 }
807812
808813 override func select(_ sender: Any?) {
......@@ -958,7 +963,27 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
958963 return focused
959964 }
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.
962987 func insertPicture(_ data: Data, size: CGSize) {
963988 guard let handle else { return }
964989 edit(external: true) {
apps/ios/Snowbound/Lists.swift+35-1
......@@ -22,6 +22,8 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
2222 /// Opening, or why the notebook cannot be.
2323 case status(notebook: String)
2424 case newNotebook, openFolder, connect
25 /// Copies the Snowbound Guide to On My iPhone and opens it, until it is there.
26 case guide
2527 /// New Notebook in iCloud Drive, and the way to it while iCloud Drive is off.
2628 case newInCloud, turnOnICloud
2729 }
......@@ -101,6 +103,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
101103 guard let self else { return }
102104 Author.ask(from: self) {}
103105 })
106 more.append(Appearance.menu())
104107 provide(actions + [UIMenu(options: .displayInline, children: more)])
105108 }
106109 ])
......@@ -128,13 +131,15 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
128131
129132 func reload() {
130133 guard isViewLoaded else { return }
134 let copied = Notebooks.onDevice.contains { $0.source == .documents(path: Notebooks.guide) }
135 let guide: [Item] = Notebooks.guideOffered && !copied ? [.guide] : []
131136 let cloud: [(Location, [Notebook], [Item])] =
132137 ICloud.documents != nil
133138 ? [(.icloud, Notebooks.inCloud, [.newInCloud])]
134139 : ICloud.signedIn ? [] : [(.icloud, [], [.turnOnICloud])]
135140 let locations: [(Location, [Notebook], [Item])] =
136141 cloud + (Notebooks.showsOnDevice ? [(.onDevice, Notebooks.onDevice, [.newNotebook])] : [])
137 + [(.elsewhere, Notebooks.elsewhere, [.openFolder, .connect])]
142 + [(.elsewhere, Notebooks.elsewhere, [.openFolder, .connect] + guide)]
138143 var sections = NSDiffableDataSourceSnapshot<Location, Item>()
139144 sections.appendSections(locations.map(\.0))
140145 dataSource.apply(sections, animatingDifferences: false)
......@@ -260,6 +265,10 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
260265 content.text = "New Notebook…"
261266 content.image = UIImage(systemName: "plus")
262267 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
263272 case .newNotebook, .openFolder, .connect:
264273 content.text =
265274 item == .newNotebook ? "New Notebook…" : item == .openFolder ? "Open Folder…" : "Connect to Server…"
......@@ -511,6 +520,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
511520 if let settings = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(settings) }
512521 case .openFolder: openFolder()
513522 case .connect: connect()
523 case .guide: openGuide()
514524 case .location: break
515525 }
516526 return false
......@@ -586,6 +596,30 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
586596 }
587597 }
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
589623 private func refuse(_ title: String, _ message: String) {
590624 let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
591625 alert.addAction(UIAlertAction(title: "OK", style: .default))
apps/ios/Snowbound/Notebooks.swift+4
......@@ -391,6 +391,10 @@ enum Notebooks {
391391
392392 private static let key = "notebooks"
393393 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
394398 private static let scripted = ProcessInfo.processInfo.environment["SNOWBOUND_NOTEBOOK"]
395399 /// The folders and sections in Documents, as On My iPhone lists them.
396400 private(set) static var onDevice: [Notebook] = []
apps/ios/Snowbound/PageViewController.swift+2-22
......@@ -330,7 +330,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
330330 guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return }
331331 provider.loadObject(ofClass: UIImage.self) { [weak self] image, _ in
332332 guard let image = image as? UIImage else { return }
333 DispatchQueue.main.async { self?.insert(image) }
333 DispatchQueue.main.async { self?.canvas.insertPicture(image) }
334334 }
335335 }
336336
......@@ -338,27 +338,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
338338 _ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
339339 ) {
340340 picker.dismiss(animated: true)
341 if let image = info[.originalImage] as? UIImage { insert(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)
341 if let image = info[.originalImage] as? UIImage { canvas.insertPicture(image) }
362342 }
363343}
364344
arc/canvas.md+21
......@@ -117,6 +117,14 @@ package. The page draws the PNG OneNote rendered of each
117117pictures. Snowbound does not write XPS, so a printout page deleted and brought
118118back 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
120128## Recordings
121129
122130Record 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
222230Spelling pane (F7) walks the page's marked words. A correction is an ordinary
223231edit, 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
225246## Search, dates, conflicts
226247
227248Smaller 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
88gpu = ["draw/render", "dep:color_quant", "dep:image", "dep:png", "dep:resvg", "dep:sha2"]
99# How a page responds to input and presents itself to assistive technology.
1010interaction = ["gpu", "dep:accesskit"]
11# Pages printed as PDF, as OneNote prints them.
12pdf = ["interaction", "draw/pdf"]
1113
1214[dependencies]
1315draw = { path = "../draw", default-features = false }
......@@ -35,6 +37,7 @@ path = "src/bin/page_probe.rs"
3537
3638[dev-dependencies]
3739draw = { path = "../draw", features = ["wgpu"] }
40miniz_oxide = "0.8"
3841accesskit_consumer = "0.39.0"
3942pollster = "0.4"
4043wgpu.workspace = true
......@@ -42,3 +45,7 @@ wgpu.workspace = true
4245[[example]]
4346name = "render_page"
4447required-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 {
21782178 Ok(true)
21792179 }
21802180
2181 /// Puts `image` in the active outline's flow after the caret's paragraph, as OneNote
2182 /// inserts a picture into an outline; the caret stays.
2181 /// Puts `image` at the caret as OneNote 2010 pastes and inserts a picture: in an outline
2182 /// as [`Self::insert_attachment`] puts a file, and on blank page at the caret with the
2183 /// caret moving to the grid row below it, where typing starts a new outline.
21832184 pub fn insert_picture(
21842185 &mut self,
21852186 engine: &mut TextEngine,
2186 image: onestore::page::Image,
2187 mut image: onestore::page::Image,
21872188 ) -> Result<(), EditorError> {
2188 let selection = self.selection();
2189 let caret = selection.positions[0].max(selection.positions[1]);
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()
2189 let Focus::Caret { outline, .. } = &self.active else {
2190 return self.insert_in_flow(engine, ParagraphContent::Image(image));
22032191 };
2204 self.commit(
2205 engine,
2206 DocumentEdit {
2207 columns: BTreeMap::new(),
2208 container,
2209 range: index + 1..index + 1,
2210 replacement: vec![picture],
2211 },
2212 selection,
2213 )
2192 let [x, y] = outline.origin();
2193 [image.layout.x, image.layout.y] = [Some(x), Some(y)];
2194 image.layout.width_set_by_user = Some(false);
2195 let height = image.layout.max_height.unwrap_or_default();
2196 self.finish_composition();
2197 let index = self.objects.len();
2198 self.add_picture(index, image);
2199 self.undo.push(History::Picture { index, image: None });
2200 self.redo.clear();
2201 let margin = self.margin_origin()[1];
2202 let row = ((y + height - margin) / 18.0).floor() + 1.0;
2203 self.place_caret(engine, [x, margin + row * 18.0], DEFAULT_OUTLINE_WIDTH)
22142204 }
22152205
22162206 /// Attaches `file` at the caret as OneNote 2010 does (`corpus/attachment-insert`): the
......@@ -2241,11 +2231,21 @@ impl CanvasEditor {
22412231 self.redo.clear();
22422232 return Ok(());
22432233 }
2244 let file = match self.across(engine, |editor, engine| {
2245 editor.insert_attachment(engine, file.clone())
2234 self.insert_in_flow(engine, ParagraphContent::Attachment(file))
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())
22462246 })? {
22472247 Some(()) => return Ok(()),
2248 None => file,
2248 None => content,
22492249 };
22502250 let [anchor, focus] = self.selection().positions;
22512251 let (start, end) = (anchor.min(focus), anchor.max(focus));
......@@ -2260,7 +2260,7 @@ impl CanvasEditor {
22602260 };
22612261 let node = PageParagraph {
22622262 id: onestore::page::text::new_id()?,
2263 content: ParagraphContent::Attachment(file),
2263 content,
22642264 style: None,
22652265 lists: Vec::new(),
22662266 tags: Vec::new(),
......@@ -3209,6 +3209,7 @@ impl CanvasEditor {
32093209 engine: &mut TextEngine,
32103210 replacement: Vec<Paragraph>,
32113211 ) -> Result<(), EditorError> {
3212 self.take_objects()?;
32123213 let start = self.active_outline().selection.positions[0]
32133214 .min(self.active_outline().selection.positions[1]);
32143215 let end = self.active_outline().selection.positions[0]
......@@ -3308,6 +3309,7 @@ impl CanvasEditor {
33083309 editor.paste(engine, text, language)
33093310 });
33103311 }
3312 self.take_objects()?;
33113313 let lines = text
33123314 .split('\n')
33133315 .map(|line| line.strip_suffix('\r').unwrap_or(line))
......@@ -3569,6 +3571,7 @@ impl CanvasEditor {
35693571 self.grouped(|editor| editor.remove_page(engine, false))?;
35703572 return Ok(true);
35713573 }
3574 self.take_objects()?;
35723575 let selection = self.active_outline().selection;
35733576 let [anchor, focus] = selection.positions;
35743577 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>> {
3636 current.filter(|zone| zone.start <= offset && offset <= zone.end)
3737}
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
39140/// The format text typed after an equation in `text` takes: the paragraph's last text's, or
40141/// its first run's without the equation editor's.
41142pub(super) fn text_after(text: &Paragraph) -> Format {
......@@ -70,6 +171,20 @@ impl CanvasEditor {
70171 Some((caret.paragraph, zone(text, caret.offset)?))
71172 }
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
73188 /// Whether the caret is in an equation, which Linear and Professional act on.
74189 pub fn in_equation(&self) -> bool {
75190 self.caret_zone().is_some()
......@@ -373,13 +488,10 @@ impl CanvasEditor {
373488 for part in parts {
374489 broken.append(part)?;
375490 }
376 let Some(linear) = Math::parse(&broken)
377 .ok()
378 .and_then(|nodes| Math::linear(&nodes))
379 else {
491 let Ok(nodes) = Math::parse(&broken) else {
380492 return Ok(false);
381493 };
382 let built = Math::paragraph(&Math::from_linear(&linear), &math.spans()[0].format);
494 let built = Math::paragraph(&nodes, &math.spans()[0].format);
383495 // The caret starts the new row: after as many characters other than spaces as came
384496 // before it.
385497 let before = broken
crates/canvas/src/editor/table.rs+1
......@@ -398,6 +398,7 @@ impl CanvasEditor {
398398 if soft {
399399 return self.insert(engine, "\u{000b}");
400400 }
401 self.take_objects()?;
401402 if self.active_outline().title {
402403 return self.leave_title(engine);
403404 }
crates/canvas/src/gpu/mod.rs+1
......@@ -110,6 +110,7 @@ impl Glyphs for TextLayout {
110110 };
111111 draw::paint_parley_run(
112112 &run,
113 &self.text,
113114 bounds.baseline,
114115 run.style().brush.rise,
115116 [bounds.top, bounds.height],
crates/canvas/src/gpu/page.rs+85
......@@ -766,6 +766,91 @@ impl PageScene {
766766 None
767767 }
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
769854 /// The offset is in document points.
770855 pub fn append_primitives<'a>(
771856 &'a self,
crates/canvas/src/interaction/ink.rs+1-9
......@@ -2,7 +2,7 @@
22//! dragged out on the grid, the stroke eraser, and the lasso whose drawings move and delete,
33//! as OneNote 2010 has them (`corpus/ink-tools`).
44
5use super::{PageView, Response, Result, snap_to_grid};
5use super::{PageView, Response, Result};
66use crate::editor::{FAVORITES, Pen};
77use crate::gpu::colorref;
88use draw::{
......@@ -184,14 +184,6 @@ impl PageView {
184184 Ok(Some(self.changed()?))
185185 }
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
195187 /// The pointer moved to page point `point` during a gesture.
196188 pub(super) fn ink_moved(&mut self, point: [f32; 2]) -> Result<Response> {
197189 let pressure = self.ink.pressure;
crates/canvas/src/interaction/mod.rs+50-24
......@@ -258,6 +258,9 @@ pub struct PageView {
258258 /// A platform scroll view owns the viewport: changes neither clamp it nor reveal the
259259 /// caret, which the host does knowing its bars and keyboard.
260260 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,
261264 /// Matches of the search shown on the page, marked under their text.
262265 pub found: Vec<crate::search::PageMatch>,
263266 /// The note playing, highlighted as See Playback highlights it.
......@@ -271,6 +274,9 @@ pub struct PageView {
271274 /// When the caret last moved, which restarts its blink.
272275 blink_from: Instant,
273276 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]>,
274280}
275281
276282/// Room, in OneNote pixels, the view leaves beyond content it scrolls to.
......@@ -326,6 +332,7 @@ impl PageView {
326332 focused: true,
327333 touch: false,
328334 host_viewport: false,
335 snap_to_grid: true,
329336 found: Vec::new(),
330337 played: None,
331338 spelling: None,
......@@ -333,6 +340,7 @@ impl PageView {
333340 caret: 1.0,
334341 blink_from: Instant::now(),
335342 ink: Default::default(),
343 reach: None,
336344 };
337345 view.place_opened();
338346 view
......@@ -376,6 +384,7 @@ impl PageView {
376384 place: Option<Place>,
377385 ) {
378386 self.replace(editor, scene);
387 self.reach = None;
379388 let Some(place) = place else {
380389 return self.place_opened();
381390 };
......@@ -461,14 +470,7 @@ impl PageView {
461470 }
462471 let point = self.viewport.document_point(self.pointer);
463472 let position = [point[0] - grab[0], point[1] - grab[1]];
464 Some((
465 *id,
466 if self.modifiers.option {
467 position
468 } else {
469 snap_to_grid(position, self.editor.margin_origin())
470 },
471 ))
473 Some((*id, self.grid(position)))
472474 }
473475
474476 /// The dragged outline and how far it would move.
......@@ -505,12 +507,16 @@ impl PageView {
505507 return Some((*id, origin, size));
506508 }
507509 let origin = [origin[0] + delta[0], origin[1] + delta[1]];
508 let origin = if self.modifiers.option {
509 origin
510 Some((*id, self.grid(origin), size))
511 }
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())
510517 } else {
511 snap_to_grid(origin, self.editor.margin_origin())
512 };
513 Some((*id, origin, size))
518 point
519 }
514520 }
515521
516522 fn pixel(&self) -> f32 {
......@@ -786,14 +792,27 @@ impl PageView {
786792 self.moved()
787793 }
788794
789 /// Scales the view by `factor` about the pointer, as a trackpad pinch does.
790 pub fn pinch(&mut self, factor: f32) -> Result<Response> {
791 self.zoom_about(factor, self.pointer);
795 /// Scales the view by `factor` about `anchor`, in device pixels from the view's
796 /// top-left, as a trackpad pinch does about the pointer.
797 pub fn pinch(&mut self, factor: f32, anchor: [f32; 2]) -> Result<Response> {
798 self.zoom_about(factor, anchor);
792799 self.moved()
793800 }
794801
795802 /// Scales the view by `factor`, keeping the document point under `anchor` in place.
796803 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 }));
797816 let point = self.viewport.document_point(anchor);
798817 let dpr = self.display_scale;
799818 self.viewport.scale = (self.viewport.scale * factor).clamp(dpr / 3.0, dpr * 16.0 / 3.0);
......@@ -841,7 +860,16 @@ impl PageView {
841860 )
842861 })
843862 });
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
845873 }
846874
847875 /// Scrolls the view's corner `offset` device pixels from the page origin along `axis`,
......@@ -1263,11 +1291,7 @@ impl PageView {
12631291 /// A fresh click places text 7 px above the pointer, on the grid.
12641292 fn place_caret(&mut self, point: [f32; 2]) -> Result<()> {
12651293 let position = [point[0], point[1] - 7.0 * self.pixel()];
1266 let position = if self.modifiers.option {
1267 position
1268 } else {
1269 snap_to_grid(position, self.editor.margin_origin())
1270 };
1294 let position = self.grid(position);
12711295 self.editor
12721296 .place_caret(&mut self.engine, position, DEFAULT_OUTLINE_WIDTH)?;
12731297 Ok(())
......@@ -1662,7 +1686,8 @@ impl PageView {
16621686 self.edited()
16631687 }
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`].
16661691 pub fn insert_picture(&mut self, bytes: Vec<u8>, size: [f32; 2]) -> Result<Response> {
16671692 if !self.accepts_text() || self.editor.marked_range().is_some() {
16681693 return Ok(Response::default());
......@@ -1682,6 +1707,7 @@ impl PageView {
16821707 printout: None,
16831708 };
16841709 self.editor.insert_picture(&mut self.engine, image)?;
1710 self.follow_pictures()?;
16851711 self.edited()
16861712 }
16871713
......@@ -2269,7 +2295,7 @@ fn page_hit(
22692295/// The page's rule lines over `visible`, a document rectangle, as OneNote 2010 draws them:
22702296/// 1/96 inch wide, or a device pixel where that is wider, horizontal lines from the margin
22712297/// 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(
22732299 lines: Option<onestore::page::RuleLines>,
22742300 [x, y]: [f32; 2],
22752301 [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() {
17531753 click(&mut view, [400.0, 300.0], now + Duration::from_secs(3));
17541754 let caret = view.editor.caret_outline().unwrap().origin();
17551755 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]);
17561762}
17571763
17581764#[test]
......@@ -2471,6 +2477,8 @@ fn the_page_selection_does_not_return_with_its_text_selection() {
24712477 assert_eq!(view.editor.whole(), None);
24722478}
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.
24742482#[test]
24752483fn pinch_zooms_about_the_pointer_within_range() {
24762484 let mut engine = TextEngine::default();
......@@ -2488,17 +2496,28 @@ fn pinch_zooms_about_the_pointer_within_range() {
24882496 1.0,
24892497 Duration::from_millis(500),
24902498 );
2491 // The host's own scrolling leaves the anchor unclamped, as iOS's does.
2492 view.host_viewport = true;
2493 let pointer = [300.0, 200.0];
2494 let _ = view.pointer_moved(pointer).unwrap();
2499 let pointer = [700.0, 500.0];
24952500 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 };
24962509 let zoom = view.zoom();
2497 assert!(view.pinch(1.5).unwrap().moved);
2498 assert!((view.zoom() / zoom - 1.5).abs() < 1e-5);
2499 let after = view.viewport.document_point(pointer);
2500 assert!((after[0] - under[0]).abs() < 1e-3 && (after[1] - under[1]).abs() < 1e-3);
2501 let _ = view.pinch(100.0).unwrap();
2510 for factor in [1.05, 1.0 / 1.05, 1.05] {
2511 for _ in 0..10 {
2512 assert!(view.pinch(factor, pointer).unwrap().moved);
2513 stays(&view);
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();
25022521 assert!((view.zoom() - 4.0).abs() < 1e-5);
25032522}
25042523
crates/canvas/src/layout.rs+3
......@@ -61,6 +61,8 @@ pub struct LineBox {
6161pub struct TextLayout {
6262 id: u64,
6363 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>,
6466 lines: Vec<LineBox>,
6567 /// Where each inline space lies: its index in the spaces laid out, its x, and its line.
6668 spaces: Vec<(usize, f32, usize)>,
......@@ -518,6 +520,7 @@ impl TextEngine {
518520 Ok(TextLayout {
519521 id,
520522 shaped: Arc::new(shaped),
523 text: text.into(),
521524 lines,
522525 spaces: placed,
523526 })
crates/canvas/src/lib.rs+2
......@@ -14,6 +14,8 @@ pub mod date;
1414pub mod gpu;
1515#[cfg(feature = "interaction")]
1616pub mod interaction;
17#[cfg(feature = "pdf")]
18pub mod print;
1719
1820/// Parley's caret affinity mapped onto the page model's hidden-field affinity.
1921pub 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() {
797797 assert_eq!(canvas::editor::shown_urls(&url), vec![5..22]);
798798 assert!(url.spans()[0].format.hyperlink.is_none());
799799}
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"]
1111# Windows 7 has no Direct3D 12.
1212render = ["dep:base64", "dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:swash"]
1313wgpu = ["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
1517[dependencies]
1618parley.workspace = true
......@@ -19,7 +21,10 @@ base64 = { version = "0.22.1", optional = true }
1921bytemuck = { version = "1.25", features = ["derive"], optional = true }
2022image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png", "tiff"], optional = true }
2123linebender_resource_handle = { version = "0.1.1", optional = true }
24miniz_oxide = { version = "0.8", optional = true }
25pdf-writer = { version = "0.15", optional = true }
2226roxmltree = { version = "0.21", optional = true }
27subsetter = { version = "0.2.6", default-features = false, optional = true }
2328swash = { version = "0.2.10", optional = true }
2429wgpu = { workspace = true, optional = true }
2530
crates/draw/src/render.rs+5
......@@ -3,6 +3,8 @@ mod dual;
33#[cfg(any(windows, not(feature = "wgpu")))]
44mod gl;
55mod icon;
6#[cfg(feature = "pdf")]
7mod pdf;
68mod text;
79#[cfg(feature = "wgpu")]
810mod translucent;
......@@ -20,6 +22,8 @@ pub use backend::Target;
2022#[cfg(all(feature = "wgpu", windows))]
2123pub use gl::Target as GlTarget;
2224pub use icon::{Palette, picture_icon};
25#[cfg(feature = "pdf")]
26pub use pdf::{Sheet, pdf};
2327pub use text::{Decoration, Glyph, GlyphRun, Glyphs, paint_parley_run};
2428#[cfg(feature = "wgpu")]
2529pub use translucent::Translucent;
......@@ -1736,6 +1740,7 @@ mod tests {
17361740 if let PositionedLayoutItem::GlyphRun(run) = item {
17371741 paint_parley_run(
17381742 &run,
1743 "",
17391744 metrics.baseline,
17401745 0.0,
17411746 [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 @@
11use crate::RenderError;
22use parley::fontique::Blob;
3use std::ops::Range;
34
45/// Text the renderer can paint, as glyph runs in layer units. Owners recover their
56/// concrete text from a primitive through `Any`.
......@@ -32,6 +33,11 @@ pub struct GlyphRun<'a> {
3233 pub glyphs: &'a mut dyn Iterator<Item = Glyph>,
3334 /// Underline and strikethrough, painted after the glyphs.
3435 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)>,
3541}
3642
3743/// A glyph's baseline position.
......@@ -53,10 +59,12 @@ pub struct Decoration {
5359 pub color: Option<[f32; 4]>,
5460}
5561
56/// Paints one parley glyph run with its line's baseline moved to `baseline`, its glyphs
57/// raised by `rise` and `backdrop` behind them.
62/// Paints one parley glyph run of `text` laid out, with its line's baseline moved to
63/// `baseline`, its glyphs raised by `rise` and `backdrop` behind them.
64#[allow(clippy::too_many_arguments)]
5865pub fn paint_parley_run<B: parley::Brush>(
5966 run: &parley::GlyphRun<'_, B>,
67 text: &str,
6068 baseline: f32,
6169 rise: f32,
6270 line: [f32; 2],
......@@ -104,6 +112,17 @@ pub fn paint_parley_run<B: parley::Brush>(
104112 x: glyph.x,
105113 y: glyph.y + baseline - line_baseline - rise,
106114 });
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 });
107126 paint(GlyphRun {
108127 font: &font.data,
109128 index: font.index,
......@@ -116,5 +135,7 @@ pub fn paint_parley_run<B: parley::Brush>(
116135 line,
117136 glyphs: &mut glyphs,
118137 decorations: &decorations,
138 text,
139 clusters: &mut clusters,
119140 })
120141}
crates/mobile/src/lib.rs+5-21
......@@ -29,7 +29,7 @@ use draw::edit::{Key, NamedKey, SelectionUnit};
2929use onestore::{
3030 ExGuid,
3131 op::{Edit, Op, PageOp},
32 page::{Image, Page, ink::ShapeKind},
32 page::{Page, ink::ShapeKind},
3333};
3434use parley::{Affinity, BoundingBox};
3535use std::{
......@@ -503,25 +503,9 @@ impl Canvas {
503503 Ok(moved(self.page.set_paper(color, rules, Some(art))?))
504504 }
505505
506 /// Puts a picture of `size` points after the caret's paragraph.
506 /// Puts a picture of `size` points at the caret.
507507 fn insert_picture(&mut self, bytes: &[u8], size: [f32; 2]) -> Result<bool> {
508 let image = Image {
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)
508 Ok(moved(self.page.insert_picture(bytes.to_vec(), size)?))
525509 }
526510
527511 /// 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 {
12571241 owned(view.canvas.page_text())
12581242}
12591243
1260/// Puts the picture file `bytes` (JPEG or PNG), `width` × `height` points, after the
1261/// caret's paragraph.
1244/// Puts the picture file `bytes` (JPEG or PNG), `width` × `height` points, at the caret:
1245/// splitting its paragraph, or on the page at a caret on blank page.
12621246///
12631247/// # Safety
12641248/// `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() {
430430}
431431
432432#[test]
433fn a_picture_goes_after_the_caret_paragraph_and_undoes() {
433fn a_picture_goes_in_at_the_caret_and_undoes() {
434434 let (_directory, section) = features();
435435 let mut canvas = canvas(&section, "Paragraph controls");
436436 focus(&mut canvas, "Collapsed parent");
crates/notebook/Cargo.toml+3
......@@ -23,6 +23,9 @@ zeroize = { version = "1.9.0", optional = true }
2323[target.'cfg(target_vendor = "apple")'.dependencies]
2424nix = { version = "0.31", default-features = false, features = ["fs"] }
2525
26[target.'cfg(windows)'.dependencies]
27windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
28
2629[dev-dependencies]
2730libc = "0.2"
2831serde_json = "1"
crates/notebook/src/lib.rs+2-1
......@@ -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)]
23#![doc = include_str!("../README.md")]
34
45pub mod discover;
crates/notebook/src/session.rs+26-2
......@@ -168,7 +168,9 @@ impl Storage for Directory {
168168 Ok(std::fs::create_dir(self.path(path))?)
169169 }
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))]
172174 fn hide(&self, path: &str) -> Result<()> {
173175 #[cfg(target_vendor = "apple")]
174176 {
......@@ -181,7 +183,29 @@ impl Storage for Directory {
181183 .map_err(io::Error::from)?;
182184 }
183185 }
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)))]
185209 let _ = path;
186210 Ok(())
187211 }
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"]
1010wgpu = ["draw/wgpu", "dep:wgpu"]
1111
1212[dependencies]
13canvas = { path = "../canvas", features = ["interaction"] }
13canvas = { path = "../canvas", features = ["interaction", "pdf"] }
1414draw = { path = "../draw", default-features = false, features = ["render"] }
1515ui = { path = "../ui" }
1616png = "0.18"
......@@ -39,6 +39,10 @@ objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAl
3939objc2-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"] }
4040block2 = "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
4246[target.'cfg(windows)'.dependencies]
4347windows-sys = { version = "0.61", features = [
4448 "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.
3030
3131## Requirements
3232
33- x86_64 or aarch64 Linux with glibc 2.31 or newer (Debian 11, Ubuntu 20.04,
34 Fedora 32 and later).
33- x86_64 or aarch64 Linux with glibc 2.17 or newer (RHEL 7, Debian 8,
34 Ubuntu 14.04 and later).
3535- A Vulkan driver (Mesa's are standard) or, failing that, OpenGL ES 3 through
3636 EGL. `WGPU_BACKEND=gl ./bin/snowbound ...` forces OpenGL.
3737- fontconfig, and X11 or Wayland with libxkbcommon.
crates/snowbound/linux/package.sh+3-3
......@@ -17,15 +17,15 @@ for arch do
1717 triple=$arch-unknown-linux-gnu
1818 variable=$(echo "$triple" | tr 'a-z-' 'A-Z_')
1919 linker="$tools/cc-$arch"
20 # glibc 2.31 reaches back to Debian 11 and Ubuntu 20.04; zig rejects rustc's
21 # --target, --no-undefined-version and -O1.
20 # glibc 2.17, Rust's own floor, reaches back to RHEL 7, Debian 8 and Ubuntu 14.04; zig
21 # rejects rustc's --target, --no-undefined-version and -O1.
2222 cat > "$linker" <<EOF
2323#!/bin/sh
2424for arg do
2525 shift
2626 case "\$arg" in --target=*|-Wl,--no-undefined-version|-Wl,-O1) ;; *) set -- "\$@" "\$arg" ;; esac
2727done
28exec zig cc -target $arch-linux-gnu.2.31 "\$@"
28exec zig cc -target $arch-linux-gnu.2.17 "\$@"
2929EOF
3030 chmod +x "$linker"
3131 rustup target add "$triple" >/dev/null
crates/snowbound/src/commands.rs+47-33
......@@ -23,6 +23,9 @@ pub enum Id {
2323 PageVersions,
2424 CopyPageLink,
2525 ShowNotebook,
26 ExportPdf,
27 ExportSectionPdf,
28 Print,
2629 Undo,
2730 Redo,
2831 Cut,
......@@ -44,7 +47,7 @@ pub enum Id {
4447 ActualSize,
4548 Sidebar,
4649 PageList,
47 DarkPages,
50 PagesMatchTheme,
4851 FullPageView,
4952 /// OneNote's Hide Spelling Errors, which leaves misspelled words unmarked.
5053 HideSpelling,
......@@ -71,6 +74,7 @@ pub enum Id {
7174 Eraser,
7275 Lasso,
7376 Shape(ShapeKind),
77 SnapToGrid,
7478 Toggle(Toggle),
7579 Highlight,
7680 FontColor,
......@@ -235,6 +239,15 @@ pub const COMMANDS: &[Command] = &[
235239 NONE,
236240 NONE,
237241 ),
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 ),
238251 row!(Id::Undo, "Undo", &[cmd('z')], &[cmd('z')]),
239252 row!(
240253 Id::Redo,
......@@ -296,7 +309,7 @@ pub const COMMANDS: &[Command] = &[
296309 ),
297310 row!(Id::Sidebar, "Notebook List", &[cmd('s').control()], NONE),
298311 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),
300313 row!(Id::HideSpelling, "Hide Spelling Errors", NONE, NONE),
301314 row!(
302315 Id::Spelling,
......@@ -387,6 +400,7 @@ pub const COMMANDS: &[Command] = &[
387400 row!(Id::Shape(ShapeKind::Arrow), "Arrow", NONE, NONE),
388401 row!(Id::Shape(ShapeKind::Rectangle), "Rectangle", NONE, NONE),
389402 row!(Id::Shape(ShapeKind::Ellipse), "Oval", NONE, NONE),
403 row!(Id::SnapToGrid, "Snap To Grid", NONE, NONE),
390404 row!(Id::Toggle(Toggle::Bold), "Bold", &[cmd('b')], &[cmd('b')]),
391405 row!(
392406 Id::Toggle(Toggle::Italic),
......@@ -773,9 +787,14 @@ impl State {
773787 .notebook()
774788 .is_some_and(|library| library.catalog().is_some()),
775789 ),
776 Id::NewPage | Id::NewSubpage | Id::CopyPageLink | Id::Find | Id::Search => {
777 enabled(!modal && session.is_some())
778 }
790 Id::NewPage
791 | Id::NewSubpage
792 | Id::CopyPageLink
793 | Id::Find
794 | Id::Search
795 | Id::ExportPdf
796 | Id::ExportSectionPdf
797 | Id::Print => enabled(!modal && session.is_some()),
779798 Id::PageVersions => session.filter(|_| !modal).map_or(
780799 Status {
781800 enabled: false,
......@@ -821,10 +840,14 @@ impl State {
821840 Some(crate::pane::Pane::Tags { .. })
822841 )),
823842 },
824 Id::DarkPages => Status {
843 Id::PagesMatchTheme => Status {
825844 enabled: !modal,
826845 checked: Some(!self.light_pages),
827846 },
847 Id::SnapToGrid => Status {
848 enabled: !modal,
849 checked: Some(self.view.snap_to_grid),
850 },
828851 Id::HideSpelling => Status {
829852 enabled: !modal && self.spelling.is_some(),
830853 checked: Some(self.hide_spelling),
......@@ -917,26 +940,6 @@ impl State {
917940 Ok(())
918941 }
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
940943 /// The notebook the open section, or the notebook showing none, belongs to.
941944 fn notebook(&self) -> Option<&Arc<crate::Library>> {
942945 self.session
......@@ -967,12 +970,10 @@ impl State {
967970 return self.paper_page(color, self.view.editor.rule_lines(), None);
968971 }
969972 Choice::Art(name) => {
970 let editor = &self.view.editor;
971 return self.paper_page(
972 editor.page_color(),
973 editor.rule_lines(),
974 Some(name.and_then(canvas::template::find)),
975 );
973 return self.with_art(name.and_then(canvas::template::find), |state, art| {
974 let editor = &state.view.editor;
975 state.paper_page(editor.page_color(), editor.rule_lines(), Some(art))
976 });
976977 }
977978 Choice::RuleLines(lines) => {
978979 let lines = lines.map(|index| canvas::template::RULE_LINES[index].1);
......@@ -1031,6 +1032,14 @@ impl State {
10311032 platform::reveal(&self.notebook().ok_or("No notebook is open")?.location);
10321033 return Ok(());
10331034 }
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 }
10341043 Id::Undo | Id::Redo => {
10351044 let response = self.view.undo(id == Id::Redo)?;
10361045 self.respond(response);
......@@ -1147,7 +1156,12 @@ impl State {
11471156 self.save_settings();
11481157 return Ok(());
11491158 }
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 => {
11511165 self.light_pages = !self.light_pages;
11521166 self.follow_color_scheme();
11531167 self.save_settings();
crates/snowbound/src/desktop_linux.rs+9-2
......@@ -291,8 +291,15 @@ pub fn set_toplevel_icon(window: &Window) -> Option<()> {
291291 ]);
292292 }
293293 }
294 // Safety: memfd_create returns a new descriptor this File then owns.
295 let fd = unsafe { libc::memfd_create(c"snowbound-icon".as_ptr(), libc::MFD_CLOEXEC) };
294 // Safety: memfd_create returns a new descriptor this File then owns. A system call, as
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;
296303 if fd < 0 {
297304 return None;
298305 }
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 {
7575/// Whether the window erases its own bottom corners to transparent pixels: under winit's
7676/// Adwaita frame on GNOME's Wayland, which draws libadwaita's window edge. Mutter's X11
7777/// 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
7881pub fn cuts_corners() -> bool {
7982 sctk_adwaita::LIBADWAITA_EDGE.load(Ordering::Relaxed)
8083}
......@@ -197,6 +200,26 @@ impl Clipboard {
197200 Self::X11(clipboard) => clipboard.get_text()?,
198201 })
199202 }
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 }
200223}
201224
202225/// Whether the system draws the title bar: the window manager, or on Wayland the compositor.
......@@ -254,7 +277,7 @@ fn desktop() -> Desktop {
254277}
255278
256279/// 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 {
258281 theme.over_backdrop()
259282}
260283
......@@ -767,7 +790,7 @@ pub fn cache_dir() -> Option<PathBuf> {
767790pub fn smb_mount(path: &std::path::Path) -> Option<crate::library::Mount> {
768791 let mounts = std::fs::read_to_string("/proc/self/mounts").ok()?;
769792 // The mount holding the path is the one at its longest ancestor.
770 let (source, point, options, within) = mounts
793 let (source, _, options, within) = mounts
771794 .lines()
772795 .filter_map(|line| {
773796 let mut fields = line.split_whitespace();
......@@ -873,6 +896,25 @@ pub fn settings_dir() -> Option<PathBuf> {
873896 xdg_dir("XDG_CONFIG_HOME", ".config")
874897}
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
876918/// The app's folder in the XDG base directory `variable` names, or in `fallback` under home.
877919fn xdg_dir(variable: &str, fallback: &str) -> Option<PathBuf> {
878920 Some(xdg_base(variable, fallback)?.join("snowbound"))
crates/snowbound/src/macos.rs+76-6
......@@ -109,6 +109,9 @@ pub fn corner_radius(_: &Window) -> f32 {
109109
110110/// AppKit clips the window's corners itself, except where the OpenGL surface covers
111111/// 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
112115pub fn cuts_corners() -> bool {
113116 crate::aqua::before_lion()
114117}
......@@ -185,6 +188,50 @@ impl Clipboard {
185188 pub fn get_text(&mut self) -> Result<String, arboard::Error> {
186189 self.0.get_text()
187190 }
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 }
188235}
189236
190237/// AppKit draws the traffic lights.
......@@ -246,9 +293,14 @@ pub fn represent(window: &Window, file: Option<&std::path::Path>) {
246293 }
247294}
248295
249/// The theme over the window's backdrop, as the kit draws it.
250pub fn over_backdrop(theme: ui::Theme) -> ui::Theme {
251 theme.over_backdrop()
296/// The theme over the window's backdrop, as the kit draws it. 10.6's textured gradient is
297/// light only, so a dark theme paints over it.
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 }
252304}
253305
254306/// None: the kit's own menus stand in for AppKit's.
......@@ -634,6 +686,10 @@ pub fn settings_dir() -> Option<std::path::PathBuf> {
634686 )
635687}
636688
689pub fn documents_dir() -> Option<std::path::PathBuf> {
690 Some(std::path::PathBuf::from(std::env::var_os("HOME")?).join("Documents"))
691}
692
637693/// Asks for a notebook folder or a notebook file with the system's open panel, titled
638694/// `title`.
639695pub fn pick_notebook(title: &str) -> Option<std::path::PathBuf> {
......@@ -899,7 +955,10 @@ pub fn edit_date(
899955 DateField::Date => NSDatePickerElementFlags::NSDatePickerElementFlagYearMonthDay,
900956 DateField::Time => NSDatePickerElementFlags::NSDatePickerElementFlagHourMinute,
901957 });
902 picker.setPresentsCalendarOverlay(true);
958 // macOS 10.15's.
959 if msg_send![&picker, respondsToSelector: sel!(setPresentsCalendarOverlay:)] {
960 picker.setPresentsCalendarOverlay(true);
961 }
903962 picker.setCalendar(Some(&calendar));
904963 picker.setTimeZone(Some(&calendar.timeZone()));
905964 picker.setDateValue(&NSDate::dateWithTimeIntervalSince1970(
......@@ -1106,10 +1165,21 @@ pub fn text_colors(window: &Window) -> [[f32; 4]; 3] {
11061165 let previous: Option<Retained<AnyObject>> = msg_send_id![class, currentAppearance];
11071166 let _: () = msg_send![class, setCurrentAppearance: &*appearance];
11081167 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.
11091171 let colors = [
1110 NSColor::textInsertionPointColor(),
1172 if responds(sel!(textInsertionPointColor)) {
1173 NSColor::textInsertionPointColor()
1174 } else {
1175 NSColor::textColor()
1176 },
11111177 NSColor::selectedTextBackgroundColor(),
1112 NSColor::unemphasizedSelectedTextBackgroundColor(),
1178 if responds(sel!(unemphasizedSelectedTextBackgroundColor)) {
1179 NSColor::unemphasizedSelectedTextBackgroundColor()
1180 } else {
1181 msg_send_id![NSColor::class(), secondarySelectedControlColor]
1182 },
11131183 ]
11141184 .map(|color| {
11151185 let color = color
crates/snowbound/src/main.rs+115-28
......@@ -10,6 +10,7 @@ mod conflict_render;
1010#[cfg(target_os = "linux")]
1111#[path = "desktop_linux.rs"]
1212mod desktop;
13mod guide;
1314mod history;
1415#[cfg_attr(not(target_os = "macos"), path = "icloud_linux.rs")]
1516#[cfg_attr(target_os = "macos", path = "icloud_macos.rs")]
......@@ -29,10 +30,16 @@ mod navigation;
2930mod options;
3031mod palette;
3132mod pane;
33mod paste;
3234#[cfg_attr(target_os = "linux", path = "linux.rs")]
3335#[cfg_attr(target_os = "macos", path = "macos.rs")]
3436#[cfg_attr(windows, path = "windows.rs")]
3537mod 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;
3643mod recording;
3744mod rename;
3845mod screenshot;
......@@ -96,6 +103,9 @@ const TAB_ROW: f32 = 28.0;
96103/// Width of the section colour around the page, and the rows' margin at the window's sides.
97104const FRAME: f32 = 6.0;
98105const 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];
99109/// Extensions of the pictures Insert, Picture offers: those the page both stores and draws.
100110const PICTURE_TYPES: [&str; 4] = ["png", "jpg", "jpeg", "gif"];
101111/// Font sizes the size box offers, OneNote's list in points.
......@@ -163,7 +173,8 @@ const PILL_MARGIN: f32 = 3.0;
163173const DATE_UNCHOSEN: &str = "Choose another date or time.";
164174const 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
167178enum UserEvent {
168179 Quit,
169180 /// Text AppKit inserts outside key events, such as the character palette's.
......@@ -187,6 +198,8 @@ enum UserEvent {
187198 /// The desktop's colours changed, where the window system doesn't say so itself.
188199 #[cfg_attr(target_os = "macos", allow(dead_code))]
189200 Appearance,
201 /// What follows work a thread of its own finished, run on the event loop's.
202 Then(Continuation),
190203 /// The iCloud account signed out, signed in or switched, or iCloud Drive was turned off.
191204 #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
192205 ICloudAccount,
......@@ -209,11 +222,21 @@ impl Clipboard {
209222 Ok(())
210223 }
211224
212 fn get_text(&mut self) -> Result<String, Box<dyn Error>> {
213 Ok(match self {
214 Self::System(clipboard) => clipboard.get_text()?,
215 Self::Memory(held) => held.clone(),
216 })
225 fn pasted(&mut self) -> Option<paste::Pasted> {
226 match self {
227 Self::System(clipboard) => {
228 let files = clipboard.get_files();
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 }
217240 }
218241}
219242
......@@ -226,6 +249,8 @@ impl std::task::Wake for Redraw {
226249#[derive(Debug)]
227250enum Replay {
228251 Input(ui::Event),
252 /// A trackpad pinch scaling the page by the factor.
253 Pinch(f32),
229254 /// Paints the next frame into a PNG as well as the window.
230255 Snapshot(PathBuf),
231256 /// Writes the window's accessibility tree as text.
......@@ -496,6 +521,8 @@ enum Command {
496521 NewNotebook,
497522 /// Opens the notebooks in the app's iCloud Drive folder, making one there if it has none.
498523 UseICloud,
524 /// Opens the Snowbound Guide from the user's documents, copying it there first.
525 OpenGuide,
499526 /// Adds Snowbound to the app menu.
500527 #[cfg(target_os = "linux")]
501528 Install,
......@@ -622,6 +649,14 @@ struct State {
622649 hide_spelling: bool,
623650 /// Options' "Use pen pressure sensitivity": a tablet pen's strokes follow its pressure.
624651 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>,
625660 /// The word the Spelling pane shows.
626661 correction: Option<canvas::interaction::Correction>,
627662 /// The strip's fill with the window focused and not, continuing the system's title bar.
......@@ -681,6 +716,8 @@ struct State {
681716 initial_layouts: Vec<(onestore::ExGuid, onestore::document::Layout)>,
682717 initial_date: Option<u64>,
683718 occluded: bool,
719 /// The least width last given the window.
720 min_width: f32,
684721 ime_allowed: bool,
685722 clipboard: Clipboard,
686723 access_adapter: accesskit_winit::Adapter,
......@@ -746,8 +783,7 @@ impl State {
746783 Input::Notebooks { .. } => "Snowbound".into(),
747784 })
748785 .with_inner_size(LogicalSize::new(1180.0, 760.0))
749 // The page list and a page beside it, under the toolbar's text group.
750 .with_min_inner_size(LogicalSize::new(600.0, 400.0)),
786 .with_min_inner_size(LogicalSize::new(MIN_SIZE[0], MIN_SIZE[1])),
751787 )?,
752788 );
753789 platform::install_title_bar(&window);
......@@ -872,6 +908,7 @@ impl State {
872908 .color_scheme
873909 .theme()
874910 .unwrap_or_else(|| platform::appearance(&window));
911 platform::follow_appearance(&window, appearance);
875912 let mut ui = Ui::new(
876913 theme(appearance, stored.light_pages, backdrop),
877914 platform::double_click_interval(),
......@@ -975,6 +1012,7 @@ impl State {
9751012 initial_date,
9761013 initial_layouts,
9771014 occluded: false,
1015 min_width: MIN_SIZE[0],
9781016 ime_allowed: true,
9791017 clipboard,
9801018 loads: mpsc::channel(),
......@@ -991,8 +1029,13 @@ impl State {
9911029 spelling,
9921030 hide_spelling: stored.hide_spelling,
9931031 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,
9941036 correction: None,
9951037 };
1038 state.view.snap_to_grid = !stored.ignore_grid;
9961039 // A notebook opened from its server that couldn't sign in asks to, as the Finder does.
9971040 let unsigned = state.notebooks.iter().find(|library| {
9981041 library.notebook.is_err() && library::server_address(&library.location).is_some()
......@@ -1119,6 +1162,7 @@ impl State {
11191162
11201163 /// Takes `appearance`'s colours.
11211164 fn set_appearance(&mut self, appearance: winit::window::Theme) {
1165 platform::follow_appearance(&self.window, appearance);
11221166 self.ui.theme = theme(appearance, self.light_pages, self.surface.translucent());
11231167 self.titlebar = platform::titlebar(appearance)
11241168 .filter(|_| !self.surface.translucent())
......@@ -1619,11 +1663,11 @@ impl State {
16191663 role: Some(accesskit::Role::Toolbar),
16201664 ..Spec::default()
16211665 };
1622 if strip_row {
1623 self.ui.open_as(strip(), spec);
1666 let row = if strip_row {
1667 self.ui.open_as(strip(), spec)
16241668 } else {
1625 self.ui.open("toolbar", spec);
1626 }
1669 self.ui.open("toolbar", spec)
1670 };
16271671 let lead = if title { platform::LEADING } else { FRAME };
16281672 self.ui.leaf(
16291673 "lead",
......@@ -1633,7 +1677,22 @@ impl State {
16331677 },
16341678 );
16351679 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 );
16361694 self.tools(theme);
1695 self.ui.close();
16371696 } else {
16381697 self.ui.leaf(
16391698 "space",
......@@ -1654,6 +1713,15 @@ impl State {
16541713 },
16551714 );
16561715 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 }
16571725 Ok(())
16581726 }
16591727
......@@ -2146,13 +2214,17 @@ impl State {
21462214 )
21472215 .or(choice);
21482216 // 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.
21492218 let shapes = [
21502219 ShapeKind::Line,
21512220 ShapeKind::Arrow,
21522221 ShapeKind::Rectangle,
21532222 ShapeKind::Ellipse,
21542223 ]
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<_>>();
21562228 let drawing = [
21572229 Run(Cmd::SelectType),
21582230 Open("pen", Cmd::Pen),
......@@ -2161,7 +2233,7 @@ impl State {
21612233 Rule,
21622234 ]
21632235 .into_iter()
2164 .chain(shapes);
2236 .chain(shapes.iter().copied());
21652237 let inserted = [Cmd::Picture, Cmd::Link, Cmd::Date, Cmd::Equation];
21662238 let more = [
21672239 Cmd::ScreenClipping,
......@@ -2320,7 +2392,7 @@ impl State {
23202392 let views = [
23212393 Run(Cmd::Sidebar),
23222394 Run(Cmd::PageList),
2323 Run(Cmd::DarkPages),
2395 Run(Cmd::PagesMatchTheme),
23242396 Run(Cmd::FullPageView),
23252397 Rule,
23262398 Run(Cmd::HideSpelling),
......@@ -2702,6 +2774,7 @@ impl State {
27022774 Command::OpenFromServer(location) => self.open_server(location.as_deref()),
27032775 Command::NewNotebook => self.new_notebook()?,
27042776 Command::UseICloud => self.use_icloud()?,
2777 Command::OpenGuide => self.open_guide()?,
27052778 #[cfg(target_os = "linux")]
27062779 Command::Install => desktop::install(),
27072780 Command::CloseNotebook(library) => self.close_notebook(&library),
......@@ -2723,12 +2796,7 @@ impl State {
27232796 Command::Template(choice) => self.apply_template(choice)?,
27242797 Command::Page(Request::EditDate(field)) => self.edit_date(field)?,
27252798 Command::Page(Request::Copy(text)) => self.clipboard.set_text(text)?,
2726 Command::Page(Request::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 }
2799 Command::Page(Request::Paste) => self.paste()?,
27322800 Command::Page(Request::OpenLink(address)) => self.open_link(&address)?,
27332801 Command::Page(Request::OpenAttachment(file)) => self.open_attachment(&file)?,
27342802 Command::Page(Request::Play { file, at_ms }) => self.play(&file, at_ms)?,
......@@ -3608,7 +3676,14 @@ impl State {
36083676 /// Zooms the page by `factor` about the pointer, when it is over the page.
36093677 fn pinch(&mut self, factor: f32) -> Result<(), Box<dyn Error>> {
36103678 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)?;
36123687 self.respond(response);
36133688 }
36143689 Ok(())
......@@ -3706,7 +3781,7 @@ fn theme(appearance: winit::window::Theme, light_pages: bool, backdrop: bool) ->
37063781 [theme.paper, theme.paper_ink] = [light.paper, light.paper_ink];
37073782 }
37083783 if backdrop {
3709 platform::over_backdrop(theme)
3784 platform::over_backdrop(theme, appearance)
37103785 } else {
37113786 theme
37123787 }
......@@ -4921,6 +4996,15 @@ impl ApplicationHandler<UserEvent> for App {
49214996 self.close(event_loop);
49224997 return;
49234998 }
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 }
49245008 UserEvent::ICloudAccount => {
49255009 if let Some(state) = &mut self.state {
49265010 state.icloud_account_changed();
......@@ -4966,6 +5050,11 @@ impl ApplicationHandler<UserEvent> for App {
49665050 if let Some(state) = &mut self.state {
49675051 match replay {
49685052 Replay::Input(event) => state.input(event),
5053 Replay::Pinch(factor) => {
5054 if let Err(error) = state.pinch(factor) {
5055 eprintln!("{error}");
5056 }
5057 }
49695058 Replay::Snapshot(path) => state.snapshot = Some(path),
49705059 Replay::Accessibility(path) => {
49715060 if let Err(error) = state.write_accessibility(&path) {
......@@ -5202,16 +5291,13 @@ impl ApplicationHandler<UserEvent> for App {
52025291
52035292fn write_png(path: &Path, size: [u32; 2], pixels: &[u8]) -> Result<(), Box<dyn Error>> {
52045293 let partial = path.with_extension("partial");
5205 let mut encoder = png::Encoder::new(std::fs::File::create(&partial)?, size[0], size[1]);
5206 encoder.set_color(png::ColorType::Rgba);
5207 encoder.set_depth(png::BitDepth::Eight);
5208 encoder.write_header()?.write_image_data(pixels)?;
5294 std::fs::write(&partial, paste::png(size, pixels)?)?;
52095295 std::fs::rename(partial, path)?;
52105296 Ok(())
52115297}
52125298
52135299/// 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`, `type
5300/// in logical pixels: `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `pinch FACTOR`, `key NAME`, `type
52155301/// TEXT`, `modifiers [shift] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`,
52165302/// `accessibility TEXT_PATH`, `appearance light|dark`, `resize WIDTH HEIGHT` and `quit`.
52175303fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dyn Error>> {
......@@ -5241,6 +5327,7 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy
52415327 "none" => None,
52425328 level => Some(level.parse()?),
52435329 }))),
5330 "pinch" => Ok(Replay::Pinch(rest.parse()?)),
52445331 "press" => Ok(Replay::Input(button(true))),
52455332 "release" => Ok(Replay::Input(button(false))),
52465333 "key" => Ok(Replay::Input(ui::Event::Key {
crates/snowbound/src/manage.rs+61-27
......@@ -7,7 +7,7 @@ use notebook::session::Notebook;
77use onestore::{
88 ExGuid, PageCreation, PageEdit,
99 op::{Edit, Op, PageOp, SectionOp},
10 page::{Page, PageObject},
10 page::{Image, Page, PageObject},
1111};
1212use std::{error::Error, sync::Arc};
1313
......@@ -124,24 +124,59 @@ impl State {
124124 &mut self,
125125 choice: crate::templates::Choice,
126126 ) -> Result<(), Box<dyn Error>> {
127 self.persist()?;
128 let session = self.session.as_ref().ok_or("No section is open")?;
129 let ops = template_ops(&session.section.page(session.space)?, choice)?;
130 self.edit_page(ops)
127 use crate::templates::Choice;
128 let template = match choice {
129 Choice::Template(name) => {
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(())
131170 }
132171
133172 /// 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, as
135 /// one undo step.
173 /// `rule_lines` and, when given, `art` in place of the art it had, as one undo step.
136174 pub(crate) fn paper_page(
137175 &mut self,
138176 color: Option<u32>,
139177 rule_lines: Option<onestore::page::RuleLines>,
140 art: Option<Option<&Template>>,
178 art: Option<Vec<Image>>,
141179 ) -> Result<(), Box<dyn Error>> {
142 let art = art
143 .map(|template| template.map_or(Ok(Vec::new()), Template::pictures))
144 .transpose()?;
145180 let response = self.view.set_paper(color, rule_lines, art)?;
146181 self.respond(response);
147182 Ok(())
......@@ -507,14 +542,11 @@ impl State {
507542pub fn template_ops(
508543 page: &Page,
509544 choice: crate::templates::Choice,
545 art: Vec<Image>,
510546) -> Result<Vec<PageOp>, Box<dyn Error>> {
511547 use crate::templates::Choice;
512548 let mut ops = match choice {
513 Choice::Template(name) => art_ops(
514 page,
515 Some(canvas::template::find(name).ok_or("That template is not available")?),
516 )?,
517 Choice::Color(_) => art_ops(page, None)?,
549 Choice::Template(_) | Choice::Color(_) => art_ops(page, art),
518550 Choice::More | Choice::Dismiss | Choice::Colors => return Ok(Vec::new()),
519551 };
520552 match choice {
......@@ -538,9 +570,9 @@ pub fn template_ops(
538570 Ok(ops)
539571}
540572
541/// The ops giving `page` `template`'s art (OneNote's pictures' places, our recreations'
542/// bytes), or none, in place of the background pictures it had.
543pub fn art_ops(page: &Page, template: Option<&Template>) -> Result<Vec<PageOp>, Box<dyn Error>> {
573/// The ops giving `page` `art`, a template's pictures, in place of the background pictures
574/// it had.
575fn art_ops(page: &Page, art: Vec<Image>) -> Vec<PageOp> {
544576 let mut ops: Vec<PageOp> = page
545577 .objects
546578 .iter()
......@@ -560,13 +592,11 @@ pub fn art_ops(page: &Page, template: Option<&Template>) -> Result<Vec<PageOp>,
560592 _ => true,
561593 })
562594 .map(PageObject::id);
563 for image in template.map_or(Ok(Vec::new()), Template::pictures)? {
564 ops.push(PageOp::Add {
565 object: PageObject::Image(image),
566 before: under,
567 });
568 }
569 Ok(ops)
595 ops.extend(art.into_iter().map(|image| PageOp::Add {
596 object: PageObject::Image(image),
597 before: under,
598 }));
599 ops
570600}
571601
572602#[cfg(test)]
......@@ -695,7 +725,11 @@ mod tests {
695725 unreachable!()
696726 };
697727 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)
699733 .unwrap()
700734 .into_iter()
701735 .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 {
232232}
233233
234234/// Whether the system refused media of `kind`: AVAuthorizationStatusRestricted or Denied.
235/// Before macOS 10.14 it never asks, and nothing is refused.
235236fn denied(kind: &NSString) -> bool {
236 let status: isize =
237 unsafe { msg_send![class("AVCaptureDevice"), authorizationStatusForMediaType: kind] };
237 let device = class("AVCaptureDevice");
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] };
238243 matches!(status, 1 | 2)
239244}
240245
crates/snowbound/src/menubar.rs+6-1
......@@ -66,6 +66,11 @@ const MENUS: &[Item] = &[
6666 C(Id::PageVersions),
6767 C(Id::CopyPageLink),
6868 C(Id::ShowNotebook),
69 S,
70 C(Id::ExportPdf),
71 C(Id::ExportSectionPdf),
72 S,
73 C(Id::Print),
6974 ],
7075 ),
7176 Item::Menu(
......@@ -111,7 +116,7 @@ const MENUS: &[Item] = &[
111116 C(Id::PageList),
112117 S,
113118 C(Id::PageColor),
114 C(Id::DarkPages),
119 C(Id::PagesMatchTheme),
115120 C(Id::HideSpelling),
116121 C(Id::Spelling),
117122 ],
crates/snowbound/src/options.rs+5-11
......@@ -164,13 +164,13 @@ impl State {
164164 match options.page {
165165 Page::General => {
166166 heading(ui, &theme, "User Interface Options");
167 field(ui, "Color scheme:", |ui| {
167 field(ui, "Appearance:", |ui| {
168168 let combo = ui.id("combo");
169169 let current = SCHEMES
170170 .iter()
171171 .find(|(scheme, _)| *scheme == options.color_scheme)
172172 .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);
174174 let items = SCHEMES.map(|(scheme, name)| Item {
175175 text: name,
176176 checked: Some(scheme == options.color_scheme),
......@@ -244,16 +244,10 @@ impl State {
244244 }
245245 Page::Display => {
246246 heading(ui, &theme, "Display");
247 let dark = !options.light_pages;
248 if ui::check_box(
249 ui,
250 "dark-pages",
251 "Pages appear dark in the Dark color scheme",
252 dark,
253 )
254 .clicked
247 let matching = !options.light_pages;
248 if ui::check_box(ui, "pages-match-theme", "Pages match UI theme", matching).clicked
255249 {
256 options.light_pages = dark;
250 options.light_pages = matching;
257251 }
258252 }
259253 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() {
4040
4141impl State {
4242 /// 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,
44 /// `PREFIX-snow-leopard-other.png`, at 10.6's one pixel per point.
43 /// as a Snow Leopard window, at 10.6's one pixel per point, also `PREFIX-light-other.png`
44 /// and `PREFIX-dark-other.png` as the window looks when not key.
4545 pub(crate) fn screenshot(&mut self, prefix: &Path) -> Result<(), Box<dyn Error>> {
4646 let size = match std::env::var("SNOWBOUND_SCREENSHOT_SIZE") {
4747 Ok(size) => size
crates/snowbound/src/screenshot/snow_leopard.rs+17-15
......@@ -30,21 +30,23 @@ pub(super) fn screenshot(
3030 size: [f32; 2],
3131) -> Result<(), Box<dyn Error>> {
3232 let title = state.window.title();
33 for (name, key) in [("snow-leopard", true), ("snow-leopard-other", false)] {
34 state.window.set_theme(Some(Appearance::Light));
35 state.set_appearance(Appearance::Light);
36 // Over the textured window's gradient, as `install_backdrop` leaves it on 10.6.
37 state.ui.theme = theme(Appearance::Light, state.light_pages, true);
38 state.titlebar = [state.ui.theme.strip; 2];
39 state.ui.window_focused = key;
40 state.settle_frame(size, 1.0)?;
41 let content = state.capture()?;
42 let bar = title_bar(state, &title, size[0], key)?;
43 let [width, height] = [size[0] as usize, size[1] as usize];
44 let window = window(&content, &bar, [width, height], key)?;
45 let (canvas, shadowed) = shadow(&window, [width, height + TITLE_LINE], key);
46 let path = PathBuf::from(format!("{}-{name}.png", prefix.display()));
47 write_png(&path, canvas.map(|side| side as u32), &shadowed)?;
33 for (name, appearance) in [("light", Appearance::Light), ("dark", Appearance::Dark)] {
34 for (suffix, key) in [("", true), ("-other", false)] {
35 state.window.set_theme(Some(appearance));
36 state.set_appearance(appearance);
37 // Over the textured window's gradient, as `install_backdrop` leaves it on 10.6.
38 state.ui.theme = theme(appearance, state.light_pages, true);
39 state.titlebar = [state.ui.theme.strip; 2];
40 state.ui.window_focused = key;
41 state.settle_frame(size, 1.0)?;
42 let content = state.capture()?;
43 let bar = title_bar(state, &title, size[0], key)?;
44 let [width, height] = [size[0] as usize, size[1] as usize];
45 let window = window(&content, &bar, [width, height], key)?;
46 let (canvas, shadowed) = shadow(&window, [width, height + TITLE_LINE], key);
47 let path = PathBuf::from(format!("{}-{name}{suffix}.png", prefix.display()));
48 write_png(&path, canvas.map(|side| side as u32), &shadowed)?;
49 }
4850 }
4951 Ok(())
5052}
crates/snowbound/src/settings.rs+43-1
......@@ -20,7 +20,7 @@ pub struct Settings {
2020 /// first saved.
2121 pub user_name: Option<String>,
2222 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.
2424 pub light_pages: bool,
2525 /// Leaves misspelled words unmarked, as OneNote's Hide Spelling Errors.
2626 pub hide_spelling: bool,
......@@ -33,6 +33,35 @@ pub struct Settings {
3333 /// Draws a tablet pen's strokes at its width, as OneNote 2010 with "Use pen pressure
3434 /// sensitivity" off.
3535 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 }
3665}
3766
3867/// What the toolbar's buttons apply from their menus' last picks.
......@@ -144,6 +173,11 @@ impl crate::State {
144173 tags: (self.tags != canvas::editor::NoteTag::defaults()).then(|| self.tags.clone()),
145174 manual_updates: !self.updates.automatic(),
146175 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(),
147181 };
148182 if let Err(error) = settings.save(path) {
149183 eprintln!("Cannot save the settings in {}: {error}", path.display());
......@@ -204,6 +238,14 @@ mod tests {
204238 }]),
205239 manual_updates: true,
206240 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()],
207249 };
208250 settings.save(&path).unwrap();
209251 assert_eq!(Settings::load(&path), settings);
crates/snowbound/src/sidebar.rs+7-1
......@@ -1074,7 +1074,7 @@ impl crate::State {
10741074 }
10751075
10761076 /// 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.
10781078 pub(crate) fn welcome(&mut self, theme: &Theme) {
10791079 let id = self.ui.id("welcome");
10801080 #[cfg(target_os = "linux")]
......@@ -1121,6 +1121,12 @@ impl crate::State {
11211121 crate::Command::OpenFromServer(None),
11221122 ),
11231123 ])
1124 .chain(crate::guide::OFFERED.then_some((
1125 "guide",
1126 art::PAGE,
1127 "Open the Snowbound Guide",
1128 crate::Command::OpenGuide,
1129 )))
11241130 .chain(install)
11251131 .collect(),
11261132 );
crates/snowbound/src/update.rs+28-2
......@@ -31,15 +31,39 @@ fn running() -> Option<Version> {
3131 Version::parse(option_env!("SNOWBOUND_BUILD")?)
3232}
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.
3536fn 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") {
3740 "macos-10.6".to_owned()
3841 } else {
3942 format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH)
4043 }
4144}
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
4367/// A published build: the day of its commit on `main`, in Los Angeles, and how many of that
4468/// day's commits on `main` lead up to and include it. `2026-09-29-r10` names it in manifests.
4569#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
......@@ -302,6 +326,8 @@ fn check(
302326 progress: &dyn Fn(Status),
303327) -> Status {
304328 let platform = platform();
329 // Under Rosetta the native build of the same version is still the update.
330 let running = running.filter(|_| !translated());
305331 let fetch = |path: &str, limit| {
306332 get(path, limit).map_err(|error| {
307333 eprintln!("Cannot fetch {BASE}{path}: {error}");
crates/snowbound/src/windows.rs+61-6
......@@ -175,6 +175,9 @@ pub fn window_attributes() -> WindowAttributes {
175175 .with_taskbar_icon(icon(32).ok())
176176 .with_decorations(caption() != Caption::Drawn)
177177 .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)
178181}
179182
180183/// 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(
236239 wm::WM_SETTINGCHANGE | wm::WM_DWMCOLORIZATIONCOLORCHANGED => {
237240 if message == wm::WM_SETTINGCHANGE && setting(lparam) == "ImmersiveColorSet" {
238241 refresh_color_policy();
239 if ACRYLIC.load(Ordering::Relaxed) {
240 accent(hwnd, Accent::Acrylic);
241 }
242242 }
243243 if let Some(proxy) = QUIT.get() {
244244 let _ = proxy.send_event(crate::UserEvent::Appearance);
......@@ -445,6 +445,26 @@ pub fn cuts_corners() -> bool {
445445/// Whether Windows 10 shows acrylic under the window, which a drag swaps for plain blur.
446446static 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
448468enum Accent {
449469 Blur,
450470 /// 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 {
482502 Accent::Acrylic => Policy {
483503 state: 4,
484504 flags: 2,
485 // 0xAABBGGRR, after the apps' colour mode.
486 tint: if registry_dword(PERSONALIZE, "AppsUseLightTheme") == Some(0) {
505 // 0xAABBGGRR, after the app's appearance.
506 tint: if DARK.load(Ordering::Relaxed) {
487507 0xcc20_2020
488508 } else {
489509 0xccf3_f3f3
......@@ -577,7 +597,7 @@ pub fn titlebar(appearance: Theme) -> Option<[[f32; 4]; 2]> {
577597/// The theme over the window's material. Over Windows 7's glass, fields and tool buttons
578598/// take white faces, as Internet Explorer's do there, so their text and icons stay legible,
579599/// and the sidebar stays opaque.
580pub fn over_backdrop(theme: ui::Theme) -> ui::Theme {
600pub fn over_backdrop(theme: ui::Theme, _: Theme) -> ui::Theme {
581601 let sidebar = theme.sidebar;
582602 let theme = theme.over_backdrop();
583603 if caption() != Caption::Glass || eleven() {
......@@ -795,6 +815,15 @@ impl Clipboard {
795815 pub fn get_text(&mut self) -> Result<String, arboard::Error> {
796816 self.0.get_text()
797817 }
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 }
798827}
799828
800829/// The DWORD `value` under `key` in the user's registry hive.
......@@ -884,6 +913,32 @@ pub fn settings_dir() -> Option<PathBuf> {
884913 Some(PathBuf::from(std::env::var_os("APPDATA")?).join("Snowbound"))
885914}
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
887942/// None: Windows reaches a share by its UNC path with the share modes and byte-range
888943/// locks OneNote takes, so a notebook there opens as a folder, as OneNote opens it.
889944/// 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 {
265265 }
266266}
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
268302fn fit_labels(nodes: &mut [Built], texts: &mut Texts, frame: u64) {
269303 for node in nodes.iter_mut() {
270304 let Some(label) = node.label.clone() else {
crates/ui/src/lib.rs+7
......@@ -827,6 +827,13 @@ impl Ui {
827827 self.hit(point, Flags::CLICKABLE | Flags::CUSTOM)
828828 }
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
830837 /// The box's rectangle from the latest layout.
831838 pub fn rect(&self, id: Id) -> Option<[f32; 4]> {
832839 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() {
216216 assert_eq!(rects[4], Some([80.0, 0.0, 100.0, 20.0]));
217217}
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
219230#[test]
220231fn a_box_sized_by_its_children_yields_what_they_yield() {
221232 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
crates/ui/src/text.rs+1
......@@ -149,6 +149,7 @@ impl Glyphs for Label {
149149 if let PositionedLayoutItem::GlyphRun(run) = item {
150150 draw::paint_parley_run(
151151 &run,
152 "",
152153 metrics.baseline,
153154 0.0,
154155 [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); }
127127static CGFloat green(id self, SEL _cmd) { return component(self, 1); }
128128static 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
135130#define RECT "{CGRect={CGPoint=dd}{CGSize=dd}}"
136131
137132static 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) {
153148 add("NSWindow", 0, "setTitlebarAppearsTransparent:", ignore, "v@:c");
154149 add("NSView", 0, "setWantsBestResolutionOpenGLSurface:", ignore, "v@:c");
155150 add("NSView", 0, "setAccessibilityLabel:", set_accessibility_label, "v@:@");
156 add("NSDatePicker", 0, "setPresentsCalendarOverlay:", ignore, "v@:c");
157151 add("NSEvent", 0, "hasPreciseScrollingDeltas", no, "c@:");
158152 add("NSEvent", 0, "scrollingDeltaX", delta_x, "d@:");
159153 add("NSEvent", 0, "scrollingDeltaY", delta_y, "d@:");
......@@ -163,8 +157,6 @@ __attribute__((constructor)) static void polyfill(void) {
163157 add("NSColorSpaceColor", 0, "greenComponent", green, "d@:");
164158 add("NSColorSpaceColor", 0, "blueComponent", blue, "d@:");
165159 add("NSColor", 1, "colorWithSRGBRed:green:blue:alpha:", srgb_color, "@@:dddd");
166 add("NSColor", 1, "textInsertionPointColor", black, "@@:");
167 add("NSColor", 1, "unemphasizedSelectedTextBackgroundColor", secondary_selection, "@@:");
168160
169161 // One appearance, Aqua, which windows have and nothing changes.
170162 appearance_class = (id)objc_getClass("NSAppearance");
tools/RELEASE.md+30-7
......@@ -18,16 +18,17 @@ development builds, which never update themselves.
1818## The published folder
1919
2020```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": ..., ...}
22222026-09-29.r10/
2323 build.json version, commit, and per platform: file, size, sha256, signature
2424 build.json.sig ed25519 signature of build.json, hex
2525 Snowbound-2026-09-29-r10-macos-aarch64.zip
26 Snowbound-2026-09-29-r10-macos-x86_64.zip
2627 Snowbound-2026-09-29-r10-macos-10.6.zip
2728 snowbound-2026-09-29-r10-linux-x86_64 the executable itself
2829 snowbound-2026-09-29-r10-linux-aarch64
29 snowbound-2026-09-29-r10-windows-x86_64.exe Windows 7 SP1 to 11
30 snowbound-2026-09-29-r10-windows-aarch64.exe Windows 11 on Arm
30 snowbound-2026-09-29-r10-windows-x86_64.exe
31 snowbound-2026-09-29-r10-windows-aarch64.exe
3132```
3233
3334A build folder is written once, under a hidden `.2026-09-29.r10.partial` name renamed into
......@@ -82,7 +83,7 @@ quarantine and open directly.
8283## Publishing
8384
8485```sh
85python3 tools/release.py # all six platforms
86python3 tools/release.py # all seven platforms
8687python3 tools/release.py --ad-hoc # the macOS app without Developer ID
8788python3 tools/release.py --dry-run # the same into a new temporary folder
8889python3 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
99100`latest.json` up to date; a different commit that derives the same version is
100101refused. The 10.6 build needs the SDK and nightly toolchain
101102`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, which
103cross linker against glibc 2.17; the Windows builds need llvm-mingw, which
103104`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
106128## In the app
107129
......@@ -111,7 +133,8 @@ Work Offline is on; Check for Updates… (the app menu on macOS, the command
111133palette elsewhere) checks at once and reports what it found. A check reads
112134`latest.json`, then the named build's `build.json` and signature, and
113135downloads 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:
115138the app unpacked into `.Snowbound.app.update` next to the bundle on macOS, the
116139executable into `.snowbound.update` next to it on Linux (`.snowbound.exe.update` on
117140Windows). 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,
5454
5555The `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
5959```sh
6060cp -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__)
1616parser.add_argument('--release', action='store_true')
1717parser.add_argument('--output', type=Path, help='Create a separate bundle at a new .app path')
1818parser.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")
1921parser.add_argument('--snow-leopard', action='store_true',
2022 help='Build with platform/snow-leopard/cargo.sh into target/snow-leopard')
2123parser.add_argument('--sign-identity', metavar='SHA1',
......@@ -29,6 +31,9 @@ if args.bundle_id and not args.output:
2931 parser.error('Use --bundle-id with --output.')
3032if args.sign and args.snow_leopard:
3133 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]
3237if args.output and (args.output.suffix != '.app' or args.output.exists()):
3338 parser.error('Choose a new output path ending in .app.')
3439root = Path(__file__).resolve().parents[2]
......@@ -83,9 +88,13 @@ if args.snow_leopard:
8388 target = root / 'target/snow-leopard'
8489 built = target / 'x86_64-apple-macosx10.6'
8590else:
86 subprocess.run(['cargo', 'build', '-p', 'snowbound'] + profile, cwd=root, check=True)
8791 metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--format-version=1', '--no-deps'], cwd=root))
8892 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)
8998bundle = args.output.resolve() if args.output else target / 'Snowbound.app'
9099if args.output:
91100 bundle.mkdir(parents=True, exist_ok=False)
......@@ -107,8 +116,10 @@ if args.snow_leopard:
107116 '--out', iconset / f'icon_{side}x{side}.png'], check=True, capture_output=True)
108117 subprocess.run(['iconutil', '-c', 'icns', iconset, '-o', resources / 'Snowbound.icns'], check=True)
109118else:
110 icon = 'Snowbound-Tahoe' if int(platform.mac_ver()[0].split('.')[0] or 0) >= 26 else 'Snowbound-Sequoia'
111 shutil.copy2(icons / f'{icon}.icns', resources / 'Snowbound.icns')
119 # macOS 26 draws the Liquid Glass icon from the catalog; it holds no flattened renditions
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)
112123# tools/release.py names the build, as 2026-09-29-r10, which About shows.
113124build = os.environ.get('SNOWBOUND_BUILD')
114125versions = {}
......@@ -126,6 +137,7 @@ if build:
126137 'NSHighResolutionCapable': True,
127138 'NSPrincipalClass': 'NSApplication',
128139 'CFBundleIconFile': 'Snowbound',
140 'CFBundleIconName': 'Snowbound-Tahoe',
129141 'NSLocalNetworkUsageDescription': 'Snowbound connects to file servers on your network to open and sync shared notebooks.',
130142 'NSMicrophoneUsageDescription':'Snowbound records audio into your notes when you choose Record Audio or Record Video.',
131143 'NSCameraUsageDescription': 'Snowbound records video into your notes when you choose Record Video.',
......@@ -134,7 +146,8 @@ if build:
134146 'NSUbiquitousContainerName': 'Snowbound',
135147 'NSUbiquitousContainerSupportedFolderLevels': 'Any',
136148 }},
137} | versions | ({'LSMinimumSystemVersion': '10.6'} if args.snow_leopard else {})))
149 'LSMinimumSystemVersion': minimum,
150} | versions))
138151# 10.6 runs the bundle unsigned.
139152if not args.snow_leopard:
140153 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,
151151 'author_timeout_seconds': author_timeout,
152152 'inspect': inspect,
153153 'collect_notebook': collect_notebook,
154 'base': json.loads(vm.BASE_MANIFEST.read_text()),
154 'base': json.loads(vm.base_manifest('win7').read_text()),
155155 'scripts': {name: hashlib.sha256((scripts / name).read_bytes()).hexdigest()
156156 for name in sorted(p.name for p in scripts.iterdir())},
157157 }, indent=2) + '\n')
tools/release.py+5-3
......@@ -17,8 +17,8 @@ PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound')
1717URL = 'https://file.paperclover.net/shr/snowbound/'
1818KEY = Path.home() / '.config/snowbound/release-key'
1919ZONE = ZoneInfo('America/Los_Angeles')
20PLATFORMS = ['macos-aarch64', 'macos-10.6', 'linux-x86_64', 'linux-aarch64', 'windows-x86_64',
21 'windows-aarch64']
20PLATFORMS = ['macos-aarch64', 'macos-x86_64', 'macos-10.6', 'linux-x86_64', 'linux-aarch64',
21 'windows-x86_64', 'windows-aarch64']
2222# Windows 7 to 11 on x86_64 (nightly's tier-3 win7 target), and Windows 11 on Arm.
2323WINDOWS = {'x86_64': 'x86_64-win7-windows-gnu', 'aarch64': 'aarch64-pc-windows-gnullvm'}
2424CHECKS = [
......@@ -114,6 +114,8 @@ def build_mac(platform, folder, developer_id, notarize):
114114 signing = ['--sign', 'developer-id', '--sign-identity', IDENTITY]
115115 else:
116116 signing = ['--sign', 'ad-hoc']
117 if platform != 'macos-10.6':
118 signing += ['--arch', platform.removeprefix('macos-')]
117119 run([sys.executable, ROOT / 'tools/canvas/build_macos.py', '--release', '--output', bundle, *signing])
118120 archive = folder / 'archive.zip'
119121 if notarize and platform != 'macos-10.6':
......@@ -150,7 +152,7 @@ def main():
150152 parser.add_argument('--ad-hoc', action='store_true',
151153 help='Sign the macOS app ad hoc instead of with Developer ID, unnotarized')
152154 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)
154156 identities = subprocess.run(['security', 'find-identity', '-v', '-p', 'codesigning'],
155157 capture_output=True, text=True).stdout
156158 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):
5050 [command[1] for command in commands[1:]])
5151
5252 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'],
5454 ['-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']))
5659 self.assertEqual(signing('macos-10.6', True, ['--key-id', 'K']), (['--snow-leopard'], ['-c']))
5760 finally:
5861 scope['run'] = run