| author | |
| committer | |
| log | 9da8af01dee9dec5df91b1f0832d6298c8f11e4d |
| tree | 4b4dddf90ef3933835ad3805f880b25e2683ccef |
| parent | 115236d842d3395805dd661eb0fe01e5e626c78d |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
- 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.5110 files changed, 5106 insertions(+), 310 deletions(-)
Cargo.lock+44-3| ... | @@ -228,9 +228,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | ... | @@ -228,9 +228,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" |
| 228 | checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" | 228 | checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" |
| 229 | dependencies = [ | 229 | dependencies = [ |
| 230 | "clipboard-win", | 230 | "clipboard-win", |
| 231 | "image", | ||
| 231 | "log", | 232 | "log", |
| 232 | "objc2 0.6.4", | 233 | "objc2 0.6.4", |
| 233 | "objc2-app-kit 0.3.2", | 234 | "objc2-app-kit 0.3.2", |
| 235 | "objc2-core-foundation", | ||
| 236 | "objc2-core-graphics", | ||
| 234 | "objc2-foundation 0.3.2", | 237 | "objc2-foundation 0.3.2", |
| 235 | "parking_lot", | 238 | "parking_lot", |
| 236 | "percent-encoding", | 239 | "percent-encoding", |
| ... | @@ -633,6 +636,7 @@ dependencies = [ | ... | @@ -633,6 +636,7 @@ dependencies = [ |
| 633 | "draw", | 636 | "draw", |
| 634 | "icu_normalizer", | 637 | "icu_normalizer", |
| 635 | "image", | 638 | "image", |
| 639 | "miniz_oxide 0.8.9", | ||
| 636 | "onestore", | 640 | "onestore", |
| 637 | "parley", | 641 | "parley", |
| 638 | "png", | 642 | "png", |
| ... | @@ -974,10 +978,13 @@ dependencies = [ | ... | @@ -974,10 +978,13 @@ dependencies = [ |
| 974 | "bytemuck", | 978 | "bytemuck", |
| 975 | "image", | 979 | "image", |
| 976 | "linebender_resource_handle", | 980 | "linebender_resource_handle", |
| 981 | "miniz_oxide 0.8.9", | ||
| 977 | "parley", | 982 | "parley", |
| 983 | "pdf-writer", | ||
| 978 | "png", | 984 | "png", |
| 979 | "pollster", | 985 | "pollster", |
| 980 | "roxmltree", | 986 | "roxmltree", |
| 987 | "subsetter", | ||
| 981 | "swash", | 988 | "swash", |
| 982 | "wgpu", | 989 | "wgpu", |
| 983 | ] | 990 | ] |
| ... | @@ -1971,7 +1978,7 @@ dependencies = [ | ... | @@ -1971,7 +1978,7 @@ dependencies = [ |
| 1971 | "naga-types", | 1978 | "naga-types", |
| 1972 | "num-traits", | 1979 | "num-traits", |
| 1973 | "once_cell", | 1980 | "once_cell", |
| 1974 | "rustc-hash", | 1981 | "rustc-hash 1.1.0", |
| 1975 | "spirv", | 1982 | "spirv", |
| 1976 | "thiserror 2.0.20", | 1983 | "thiserror 2.0.20", |
| 1977 | "unicode-ident", | 1984 | "unicode-ident", |
| ... | @@ -1985,7 +1992,7 @@ checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" | ... | @@ -1985,7 +1992,7 @@ checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" |
| 1985 | dependencies = [ | 1992 | dependencies = [ |
| 1986 | "hashbrown 0.17.1", | 1993 | "hashbrown 0.17.1", |
| 1987 | "indexmap", | 1994 | "indexmap", |
| 1988 | "rustc-hash", | 1995 | "rustc-hash 1.1.0", |
| 1989 | "thiserror 2.0.20", | 1996 | "thiserror 2.0.20", |
| 1990 | ] | 1997 | ] |
| 1991 | 1998 | ||
| ... | @@ -2046,6 +2053,7 @@ dependencies = [ | ... | @@ -2046,6 +2053,7 @@ dependencies = [ |
| 2046 | "tempfile", | 2053 | "tempfile", |
| 2047 | "thiserror 2.0.20", | 2054 | "thiserror 2.0.20", |
| 2048 | "tokio", | 2055 | "tokio", |
| 2056 | "windows-sys 0.61.2", | ||
| 2049 | "zeroize", | 2057 | "zeroize", |
| 2050 | ] | 2058 | ] |
| 2051 | 2059 | ||
| ... | @@ -2544,6 +2552,18 @@ dependencies = [ | ... | @@ -2544,6 +2552,18 @@ dependencies = [ |
| 2544 | "hmac", | 2552 | "hmac", |
| 2545 | ] | 2553 | ] |
| 2546 | 2554 | ||
| 2555 | [[package]] | ||
| 2556 | name = "pdf-writer" | ||
| 2557 | version = "0.15.0" | ||
| 2558 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2559 | checksum = "f5e456864a7a304047bff84977dc6fb162bd956475d40ba50b2dcecaada7f753" | ||
| 2560 | dependencies = [ | ||
| 2561 | "bitflags 2.13.1", | ||
| 2562 | "itoa", | ||
| 2563 | "memchr", | ||
| 2564 | "ryu", | ||
| 2565 | ] | ||
| 2566 | |||
| 2547 | [[package]] | 2567 | [[package]] |
| 2548 | name = "percent-encoding" | 2568 | name = "percent-encoding" |
| 2549 | version = "2.3.2" | 2569 | version = "2.3.2" |
| ... | @@ -2951,6 +2971,12 @@ version = "1.1.0" | ... | @@ -2951,6 +2971,12 @@ version = "1.1.0" |
| 2951 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2971 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2952 | checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" | 2972 | checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" |
| 2953 | 2973 | ||
| 2974 | [[package]] | ||
| 2975 | name = "rustc-hash" | ||
| 2976 | version = "2.1.3" | ||
| 2977 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2978 | checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" | ||
| 2979 | |||
| 2954 | [[package]] | 2980 | [[package]] |
| 2955 | name = "rustc_version" | 2981 | name = "rustc_version" |
| 2956 | version = "0.4.1" | 2982 | version = "0.4.1" |
| ... | @@ -3027,6 +3053,12 @@ version = "1.0.23" | ... | @@ -3027,6 +3053,12 @@ version = "1.0.23" |
| 3027 | source = "registry+https://github.com/rust-lang/crates.io-index" | 3053 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 3028 | checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" | 3054 | checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" |
| 3029 | 3055 | ||
| 3056 | [[package]] | ||
| 3057 | name = "ryu" | ||
| 3058 | version = "1.0.23" | ||
| 3059 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3060 | checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" | ||
| 3061 | |||
| 3030 | [[package]] | 3062 | [[package]] |
| 3031 | name = "same-file" | 3063 | name = "same-file" |
| 3032 | version = "1.0.6" | 3064 | version = "1.0.6" |
| ... | @@ -3414,6 +3446,15 @@ dependencies = [ | ... | @@ -3414,6 +3446,15 @@ dependencies = [ |
| 3414 | "float-cmp", | 3446 | "float-cmp", |
| 3415 | ] | 3447 | ] |
| 3416 | 3448 | ||
| 3449 | [[package]] | ||
| 3450 | name = "subsetter" | ||
| 3451 | version = "0.2.6" | ||
| 3452 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 3453 | checksum = "38803281d1c23166c5ebcb455439a5d2afe711cc909cf88af72448c297756ad6" | ||
| 3454 | dependencies = [ | ||
| 3455 | "rustc-hash 2.1.3", | ||
| 3456 | ] | ||
| 3457 | |||
| 3417 | [[package]] | 3458 | [[package]] |
| 3418 | name = "subtle" | 3459 | name = "subtle" |
| 3419 | version = "2.6.1" | 3460 | version = "2.6.1" |
| ... | @@ -4157,7 +4198,7 @@ dependencies = [ | ... | @@ -4157,7 +4198,7 @@ dependencies = [ |
| 4157 | "portable-atomic", | 4198 | "portable-atomic", |
| 4158 | "profiling", | 4199 | "profiling", |
| 4159 | "raw-window-handle", | 4200 | "raw-window-handle", |
| 4160 | "rustc-hash", | 4201 | "rustc-hash 1.1.0", |
| 4161 | "smallvec", | 4202 | "smallvec", |
| 4162 | "thiserror 2.0.20", | 4203 | "thiserror 2.0.20", |
| 4163 | "wgpu-core-deps-apple", | 4204 | "wgpu-core-deps-apple", |
apps/ios/Snowbound.xcodeproj/project.pbxproj+10-2| ... | @@ -8,14 +8,18 @@ | ... | @@ -8,14 +8,18 @@ |
| 8 | 8 | ||
| 9 | /* Begin PBXBuildFile section */ | 9 | /* Begin PBXBuildFile section */ |
| 10 | 		5B0000000000000000000101 /* Sample in Resources */ = {isa = PBXBuildFile; fileRef = 5B0000000000000000000201 /* Sample */; }; | 10 | 		5B0000000000000000000101 /* Sample in Resources */ = {isa = PBXBuildFile; fileRef = 5B0000000000000000000201 /* Sample */; }; |
| 11 | 		5B0000000000000000000126 /* Snowbound Guide in Resources */ = {isa = PBXBuildFile; fileRef = 5B0000000000000000000226 /* Snowbound Guide */; }; | ||
| 12 | 		5B0000000000000000000102 /* Snowbound-Tahoe.icon in Resources */ = {isa = PBXBuildFile; fileRef = 5B0000000000000000000205 /* Snowbound-Tahoe.icon */; }; | ||
| 11 | /* End PBXBuildFile section */ | 13 | /* End PBXBuildFile section */ |
| 12 | 14 | ||
| 13 | /* Begin PBXFileReference section */ | 15 | /* Begin PBXFileReference section */ |
| 14 | 		5B0000000000000000000200 /* Snowbound.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Snowbound.app; sourceTree = BUILT_PRODUCTS_DIR; }; | 16 | 		5B0000000000000000000200 /* Snowbound.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Snowbound.app; sourceTree = BUILT_PRODUCTS_DIR; }; |
| 15 | 		5B0000000000000000000201 /* Sample */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Sample; path = "../../corpus/m6/native-features-01/notebook"; sourceTree = SOURCE_ROOT; }; | 17 | 		5B0000000000000000000201 /* Sample */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Sample; path = "../../corpus/m6/native-features-01/notebook"; sourceTree = SOURCE_ROOT; }; |
| 18 | 		5B0000000000000000000226 /* Snowbound Guide */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "Snowbound Guide"; path = "../../docs/guide-notebook/Snowbound Guide"; sourceTree = SOURCE_ROOT; }; | ||
| 16 | 		5B0000000000000000000202 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; | 19 | 		5B0000000000000000000202 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; |
| 17 | 		5B0000000000000000000203 /* Snowbound-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Snowbound-Bridging-Header.h"; sourceTree = "<group>"; }; | 20 | 		5B0000000000000000000203 /* Snowbound-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Snowbound-Bridging-Header.h"; sourceTree = "<group>"; }; |
| 18 | 		5B0000000000000000000204 /* build-rust.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "build-rust.sh"; sourceTree = "<group>"; }; | 21 | 		5B0000000000000000000204 /* build-rust.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "build-rust.sh"; sourceTree = "<group>"; }; |
| 22 | 		5B0000000000000000000205 /* Snowbound-Tahoe.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; name = "Snowbound-Tahoe.icon"; path = "../../crates/snowbound/assets/icon/sources/Snowbound-Tahoe.icon"; sourceTree = SOURCE_ROOT; }; | ||
| 19 | /* End PBXFileReference section */ | 23 | /* End PBXFileReference section */ |
| 20 | 24 | ||
| 21 | /* Begin PBXFileSystemSynchronizedRootGroup section */ | 25 | /* Begin PBXFileSystemSynchronizedRootGroup section */ |
| ... | @@ -42,6 +46,8 @@ | ... | @@ -42,6 +46,8 @@ |
| 42 | 			children = ( | 46 | 			children = ( |
| 43 | 				5B0000000000000000000300 /* Snowbound */, | 47 | 				5B0000000000000000000300 /* Snowbound */, |
| 44 | 				5B0000000000000000000201 /* Sample */, | 48 | 				5B0000000000000000000201 /* Sample */, |
| 49 | 				5B0000000000000000000226 /* Snowbound Guide */, | ||
| 50 | 				5B0000000000000000000205 /* Snowbound-Tahoe.icon */, | ||
| 45 | 				5B0000000000000000000202 /* Info.plist */, | 51 | 				5B0000000000000000000202 /* Info.plist */, |
| 46 | 				5B0000000000000000000203 /* Snowbound-Bridging-Header.h */, | 52 | 				5B0000000000000000000203 /* Snowbound-Bridging-Header.h */, |
| 47 | 				5B0000000000000000000204 /* build-rust.sh */, | 53 | 				5B0000000000000000000204 /* build-rust.sh */, |
| ... | @@ -121,6 +127,8 @@ | ... | @@ -121,6 +127,8 @@ |
| 121 | 			buildActionMask = 2147483647; | 127 | 			buildActionMask = 2147483647; |
| 122 | 			files = ( | 128 | 			files = ( |
| 123 | 				5B0000000000000000000101 /* Sample in Resources */, | 129 | 				5B0000000000000000000101 /* Sample in Resources */, |
| 130 | 				5B0000000000000000000126 /* Snowbound Guide in Resources */, | ||
| 131 | 				5B0000000000000000000102 /* Snowbound-Tahoe.icon in Resources */, | ||
| 124 | 			); | 132 | 			); |
| 125 | 			runOnlyForDeploymentPostprocessing = 0; | 133 | 			runOnlyForDeploymentPostprocessing = 0; |
| 126 | 		}; | 134 | 		}; |
| ... | @@ -198,7 +206,7 @@ | ... | @@ -198,7 +206,7 @@ |
| 198 | 			isa = XCBuildConfiguration; | 206 | 			isa = XCBuildConfiguration; |
| 199 | 			buildSettings = { | 207 | 			buildSettings = { |
| 200 | 				ARCHS = arm64; | 208 | 				ARCHS = arm64; |
| 201 | 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | 209 | 				ASSETCATALOG_COMPILER_APPICON_NAME = "Snowbound-Tahoe"; |
| 202 | 				CODE_SIGN_ENTITLEMENTS = Snowbound.entitlements; | 210 | 				CODE_SIGN_ENTITLEMENTS = Snowbound.entitlements; |
| 203 | 				CURRENT_PROJECT_VERSION = 1; | 211 | 				CURRENT_PROJECT_VERSION = 1; |
| 204 | 				GENERATE_INFOPLIST_FILE = YES; | 212 | 				GENERATE_INFOPLIST_FILE = YES; |
| ... | @@ -235,7 +243,7 @@ | ... | @@ -235,7 +243,7 @@ |
| 235 | 			isa = XCBuildConfiguration; | 243 | 			isa = XCBuildConfiguration; |
| 236 | 			buildSettings = { | 244 | 			buildSettings = { |
| 237 | 				ARCHS = arm64; | 245 | 				ARCHS = arm64; |
| 238 | 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | 246 | 				ASSETCATALOG_COMPILER_APPICON_NAME = "Snowbound-Tahoe"; |
| 239 | 				CODE_SIGN_ENTITLEMENTS = Snowbound.entitlements; | 247 | 				CODE_SIGN_ENTITLEMENTS = Snowbound.entitlements; |
| 240 | 				CURRENT_PROJECT_VERSION = 1; | 248 | 				CURRENT_PROJECT_VERSION = 1; |
| 241 | 				GENERATE_INFOPLIST_FILE = YES; | 249 | 				GENERATE_INFOPLIST_FILE = YES; |
apps/ios/Snowbound/App.swift+27| ... | @@ -44,6 +44,32 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { | ... | @@ -44,6 +44,32 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { |
| 44 | } | 44 | } |
| 45 | } | 45 | } |
| 46 | 46 | ||
| 47 | /// The desktop's Appearance option: the system's, or light or dark regardless of it. | ||
| 48 | enum Appearance { | ||
| 49 | static let key = "appearance" | ||
| 50 | |||
| 51 | static var style: UIUserInterfaceStyle { | ||
| 52 | get { UIUserInterfaceStyle(rawValue: UserDefaults.standard.integer(forKey: key)) ?? .unspecified } | ||
| 53 | set { | ||
| 54 | UserDefaults.standard.set(newValue.rawValue, forKey: key) | ||
| 55 | for case let scene as UIWindowScene in UIApplication.shared.connectedScenes { | ||
| 56 | for window in scene.windows { window.overrideUserInterfaceStyle = newValue } | ||
| 57 | } | ||
| 58 | } | ||
| 59 | } | ||
| 60 | |||
| 61 | static func menu() -> UIMenu { | ||
| 62 | let choices: [(String, UIUserInterfaceStyle)] = [("System", .unspecified), ("Light", .light), ("Dark", .dark)] | ||
| 63 | return UIMenu( | ||
| 64 | title: "Appearance", image: UIImage(systemName: "circle.lefthalf.filled"), | ||
| 65 | children: choices.map { title, style in | ||
| 66 | let action = UIAction(title: title) { _ in Appearance.style = style } | ||
| 67 | action.state = Appearance.style == style ? .on : .off | ||
| 68 | return action | ||
| 69 | }) | ||
| 70 | } | ||
| 71 | } | ||
| 72 | |||
| 47 | /// Where the reader was, reopened on the next launch as Notes reopens its last note. | 73 | /// Where the reader was, reopened on the next launch as Notes reopens its last note. |
| 48 | private struct Place: Codable { | 74 | private struct Place: Codable { |
| 49 | let notebook: String | 75 | let notebook: String |
| ... | @@ -71,6 +97,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro | ... | @@ -71,6 +97,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro |
| 71 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) { | 97 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) { |
| 72 | guard let scene = scene as? UIWindowScene else { return } | 98 | guard let scene = scene as? UIWindowScene else { return } |
| 73 | let window = UIWindow(windowScene: scene) | 99 | let window = UIWindow(windowScene: scene) |
| 100 | window.overrideUserInterfaceStyle = Appearance.style | ||
| 74 | split.delegate = self | 101 | split.delegate = self |
| 75 | split.scene = self | 102 | split.scene = self |
| 76 | split.preferredDisplayMode = .oneBesideSecondary | 103 | split.preferredDisplayMode = .oneBesideSecondary |
apps/ios/Snowbound/CanvasView.swift+30-5| ... | @@ -771,7 +771,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -771,7 +771,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 771 | case #selector(copy(_:)), #selector(cut(_:)), #selector(delete(_:)), | 771 | case #selector(copy(_:)), #selector(cut(_:)), #selector(delete(_:)), |
| 772 | #selector(toggleBoldface(_:)), #selector(toggleItalics(_:)), #selector(toggleUnderline(_:)): | 772 | #selector(toggleBoldface(_:)), #selector(toggleItalics(_:)), #selector(toggleUnderline(_:)): |
| 773 | return lo < hi | 773 | return lo < hi |
| 774 | case #selector(paste(_:)): return UIPasteboard.general.hasStrings | 774 | case #selector(paste(_:)): return UIPasteboard.general.hasStrings || UIPasteboard.general.hasImages |
| 775 | case #selector(select(_:)): return lo == hi && hasText | 775 | case #selector(select(_:)): return lo == hi && hasText |
| 776 | case #selector(selectAll(_:)): return hasText && (lo > 0 || hi < endOfDocument.offset) | 776 | case #selector(selectAll(_:)): return hasText && (lo > 0 || hi < endOfDocument.offset) |
| 777 | default: return super.canPerformAction(action, withSender: sender) | 777 | default: return super.canPerformAction(action, withSender: sender) |
| ... | @@ -799,10 +799,15 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -799,10 +799,15 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 799 | edit(external: true) { replacing(range, with: "") } | 799 | edit(external: true) { replacing(range, with: "") } |
| 800 | } | 800 | } |
| 801 | 801 | ||
| 802 | /// Pasted text takes the keyboard's language, as Windows gives the clipboard. | 802 | /// Pasted text takes the keyboard's language, as Windows gives the clipboard. Text wins |
| 803 | /// over a picture, which apps put beside copied text. | ||
| 803 | override func paste(_ sender: Any?) { | 804 | override func paste(_ sender: Any?) { |
| 804 | guard let handle, let text = UIPasteboard.general.string else { return } | 805 | guard let handle else { return } |
| 805 | edit(external: true) { sb_paste(handle, text, textInputMode?.primaryLanguage ?? "") } | 806 | if let text = UIPasteboard.general.string { |
| 807 | edit(external: true) { sb_paste(handle, text, textInputMode?.primaryLanguage ?? "") } | ||
| 808 | } else if let image = UIPasteboard.general.image { | ||
| 809 | insertPicture(image) | ||
| 810 | } | ||
| 806 | } | 811 | } |
| 807 | 812 | ||
| 808 | override func select(_ sender: Any?) { | 813 | override func select(_ sender: Any?) { |
| ... | @@ -958,7 +963,27 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -958,7 +963,27 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 958 | return focused | 963 | return focused |
| 959 | } | 964 | } |
| 960 | 965 | ||
| 961 | /// Puts a JPEG or PNG after the caret's paragraph at `size` points. | 966 | /// A photo as OneNote 2010 can read it: JPEG, at most 2048 pixels across, shown at most |
| 967 | /// 220 points wide. | ||
| 968 | func insertPicture(_ image: UIImage) { | ||
| 969 | let longest: CGFloat = 2048 | ||
| 970 | let scale = min(1, longest / max(image.size.width * image.scale, image.size.height * image.scale)) | ||
| 971 | let pixels = CGSize( | ||
| 972 | width: (image.size.width * image.scale * scale).rounded(), | ||
| 973 | height: (image.size.height * image.scale * scale).rounded()) | ||
| 974 | let format = UIGraphicsImageRendererFormat() | ||
| 975 | format.scale = 1 | ||
| 976 | let drawn = UIGraphicsImageRenderer(size: pixels, format: format).image { _ in | ||
| 977 | image.draw(in: CGRect(origin: .zero, size: pixels)) | ||
| 978 | } | ||
| 979 | guard let data = drawn.jpegData(compressionQuality: 0.85) else { return } | ||
| 980 | // OneNote shows a picture's pixels at 96 per inch. | ||
| 981 | var size = CGSize(width: pixels.width * 0.75, height: pixels.height * 0.75) | ||
| 982 | if size.width > 220 { size = CGSize(width: 220, height: size.height * 220 / size.width) } | ||
| 983 | insertPicture(data, size: size) | ||
| 984 | } | ||
| 985 | |||
| 986 | /// Puts a JPEG or PNG at the caret at `size` points. | ||
| 962 | func insertPicture(_ data: Data, size: CGSize) { | 987 | func insertPicture(_ data: Data, size: CGSize) { |
| 963 | guard let handle else { return } | 988 | guard let handle else { return } |
| 964 | edit(external: true) { | 989 | edit(external: true) { |
apps/ios/Snowbound/Lists.swift+35-1| ... | @@ -22,6 +22,8 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke | ... | @@ -22,6 +22,8 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 22 | /// Opening, or why the notebook cannot be. | 22 | /// Opening, or why the notebook cannot be. |
| 23 | case status(notebook: String) | 23 | case status(notebook: String) |
| 24 | case newNotebook, openFolder, connect | 24 | case newNotebook, openFolder, connect |
| 25 | /// Copies the Snowbound Guide to On My iPhone and opens it, until it is there. | ||
| 26 | case guide | ||
| 25 | /// New Notebook in iCloud Drive, and the way to it while iCloud Drive is off. | 27 | /// New Notebook in iCloud Drive, and the way to it while iCloud Drive is off. |
| 26 | case newInCloud, turnOnICloud | 28 | case newInCloud, turnOnICloud |
| 27 | } | 29 | } |
| ... | @@ -101,6 +103,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke | ... | @@ -101,6 +103,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 101 | guard let self else { return } | 103 | guard let self else { return } |
| 102 | Author.ask(from: self) {} | 104 | Author.ask(from: self) {} |
| 103 | }) | 105 | }) |
| 106 | more.append(Appearance.menu()) | ||
| 104 | provide(actions + [UIMenu(options: .displayInline, children: more)]) | 107 | provide(actions + [UIMenu(options: .displayInline, children: more)]) |
| 105 | } | 108 | } |
| 106 | ]) | 109 | ]) |
| ... | @@ -128,13 +131,15 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke | ... | @@ -128,13 +131,15 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 128 | 131 | ||
| 129 | func reload() { | 132 | func reload() { |
| 130 | guard isViewLoaded else { return } | 133 | guard isViewLoaded else { return } |
| 134 | let copied = Notebooks.onDevice.contains { $0.source == .documents(path: Notebooks.guide) } | ||
| 135 | let guide: [Item] = Notebooks.guideOffered && !copied ? [.guide] : [] | ||
| 131 | let cloud: [(Location, [Notebook], [Item])] = | 136 | let cloud: [(Location, [Notebook], [Item])] = |
| 132 | ICloud.documents != nil | 137 | ICloud.documents != nil |
| 133 | ? [(.icloud, Notebooks.inCloud, [.newInCloud])] | 138 | ? [(.icloud, Notebooks.inCloud, [.newInCloud])] |
| 134 | : ICloud.signedIn ? [] : [(.icloud, [], [.turnOnICloud])] | 139 | : ICloud.signedIn ? [] : [(.icloud, [], [.turnOnICloud])] |
| 135 | let locations: [(Location, [Notebook], [Item])] = | 140 | let locations: [(Location, [Notebook], [Item])] = |
| 136 | cloud + (Notebooks.showsOnDevice ? [(.onDevice, Notebooks.onDevice, [.newNotebook])] : []) | 141 | cloud + (Notebooks.showsOnDevice ? [(.onDevice, Notebooks.onDevice, [.newNotebook])] : []) |
| 137 | + [(.elsewhere, Notebooks.elsewhere, [.openFolder, .connect])] | 142 | + [(.elsewhere, Notebooks.elsewhere, [.openFolder, .connect] + guide)] |
| 138 | var sections = NSDiffableDataSourceSnapshot<Location, Item>() | 143 | var sections = NSDiffableDataSourceSnapshot<Location, Item>() |
| 139 | sections.appendSections(locations.map(\.0)) | 144 | sections.appendSections(locations.map(\.0)) |
| 140 | dataSource.apply(sections, animatingDifferences: false) | 145 | dataSource.apply(sections, animatingDifferences: false) |
| ... | @@ -260,6 +265,10 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke | ... | @@ -260,6 +265,10 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 260 | content.text = "New Notebook…" | 265 | content.text = "New Notebook…" |
| 261 | content.image = UIImage(systemName: "plus") | 266 | content.image = UIImage(systemName: "plus") |
| 262 | content.textProperties.color = .tintColor | 267 | content.textProperties.color = .tintColor |
| 268 | case .guide: | ||
| 269 | content.text = "Open the Snowbound Guide" | ||
| 270 | content.image = UIImage(systemName: "book") | ||
| 271 | content.textProperties.color = .tintColor | ||
| 263 | case .newNotebook, .openFolder, .connect: | 272 | case .newNotebook, .openFolder, .connect: |
| 264 | content.text = | 273 | content.text = |
| 265 | item == .newNotebook ? "New Notebook…" : item == .openFolder ? "Open Folder…" : "Connect to Server…" | 274 | item == .newNotebook ? "New Notebook…" : item == .openFolder ? "Open Folder…" : "Connect to Server…" |
| ... | @@ -511,6 +520,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke | ... | @@ -511,6 +520,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 511 | if let settings = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(settings) } | 520 | if let settings = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(settings) } |
| 512 | case .openFolder: openFolder() | 521 | case .openFolder: openFolder() |
| 513 | case .connect: connect() | 522 | case .connect: connect() |
| 523 | case .guide: openGuide() | ||
| 514 | case .location: break | 524 | case .location: break |
| 515 | } | 525 | } |
| 516 | return false | 526 | return false |
| ... | @@ -586,6 +596,30 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke | ... | @@ -586,6 +596,30 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke |
| 586 | } | 596 | } |
| 587 | } | 597 | } |
| 588 | 598 | ||
| 599 | /// Copies the guide the app carries to On My iPhone and opens the copy, so the carried one | ||
| 600 | /// stays as shipped. | ||
| 601 | private func openGuide() { | ||
| 602 | let copy = documentsDirectory.appendingPathComponent(Notebooks.guide) | ||
| 603 | background({ () -> String? in | ||
| 604 | guard !FileManager.default.fileExists(atPath: copy.path) else { return nil } | ||
| 605 | guard let carried = Bundle.main.url(forResource: Notebooks.guide, withExtension: nil) else { | ||
| 606 | return "The guide is missing from this copy of Snowbound." | ||
| 607 | } | ||
| 608 | do { try FileManager.default.copyItem(at: carried, to: copy) } catch { return error.localizedDescription } | ||
| 609 | return nil | ||
| 610 | }) { [weak self] problem in | ||
| 611 | guard let self else { return } | ||
| 612 | if let problem { return refuse("Can’t Open the Guide", problem) } | ||
| 613 | if !Notebooks.showsOnDevice { Notebooks.showsOnDevice = true } | ||
| 614 | rescan { [weak self] notebook in | ||
| 615 | guard notebook.source == .documents(path: Notebooks.guide), | ||
| 616 | let tab = notebook.tabs.first(where: \.readable) | ||
| 617 | else { return } | ||
| 618 | self?.onOpen?(tab, notebook) | ||
| 619 | } | ||
| 620 | } | ||
| 621 | } | ||
| 622 | |||
| 589 | private func refuse(_ title: String, _ message: String) { | 623 | private func refuse(_ title: String, _ message: String) { |
| 590 | let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) | 624 | let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) |
| 591 | alert.addAction(UIAlertAction(title: "OK", style: .default)) | 625 | alert.addAction(UIAlertAction(title: "OK", style: .default)) |
apps/ios/Snowbound/Notebooks.swift+4| ... | @@ -391,6 +391,10 @@ enum Notebooks { | ... | @@ -391,6 +391,10 @@ enum Notebooks { |
| 391 | 391 | ||
| 392 | private static let key = "notebooks" | 392 | private static let key = "notebooks" |
| 393 | private static let hidden = "hidesOnDevice" | 393 | private static let hidden = "hidesOnDevice" |
| 394 | /// The Snowbound Guide's folder, in the app and in Documents once copied there. | ||
| 395 | static let guide = "Snowbound Guide" | ||
| 396 | /// Whether the list offers the guide; off until Clover has read it through. | ||
| 397 | static let guideOffered = false | ||
| 394 | private static let scripted = ProcessInfo.processInfo.environment["SNOWBOUND_NOTEBOOK"] | 398 | private static let scripted = ProcessInfo.processInfo.environment["SNOWBOUND_NOTEBOOK"] |
| 395 | /// The folders and sections in Documents, as On My iPhone lists them. | 399 | /// The folders and sections in Documents, as On My iPhone lists them. |
| 396 | private(set) static var onDevice: [Notebook] = [] | 400 | private(set) static var onDevice: [Notebook] = [] |
apps/ios/Snowbound/PageViewController.swift+2-22| ... | @@ -330,7 +330,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate | ... | @@ -330,7 +330,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate |
| 330 | guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return } | 330 | guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return } |
| 331 | provider.loadObject(ofClass: UIImage.self) { [weak self] image, _ in | 331 | provider.loadObject(ofClass: UIImage.self) { [weak self] image, _ in |
| 332 | guard let image = image as? UIImage else { return } | 332 | guard let image = image as? UIImage else { return } |
| 333 | DispatchQueue.main.async { self?.insert(image) } | 333 | DispatchQueue.main.async { self?.canvas.insertPicture(image) } |
| 334 | } | 334 | } |
| 335 | } | 335 | } |
| 336 | 336 | ||
| ... | @@ -338,27 +338,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate | ... | @@ -338,27 +338,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate |
| 338 | _ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] | 338 | _ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] |
| 339 | ) { | 339 | ) { |
| 340 | picker.dismiss(animated: true) | 340 | picker.dismiss(animated: true) |
| 341 | if let image = info[.originalImage] as? UIImage { insert(image) } | 341 | if let image = info[.originalImage] as? UIImage { canvas.insertPicture(image) } |
| 342 | } | ||
| 343 | |||
| 344 | /// A photo as OneNote 2010 can read it: JPEG, at most 2048 pixels across, shown at most | ||
| 345 | /// 220 points wide. | ||
| 346 | private func insert(_ image: UIImage) { | ||
| 347 | let longest: CGFloat = 2048 | ||
| 348 | let scale = min(1, longest / max(image.size.width * image.scale, image.size.height * image.scale)) | ||
| 349 | let pixels = CGSize( | ||
| 350 | width: (image.size.width * image.scale * scale).rounded(), | ||
| 351 | height: (image.size.height * image.scale * scale).rounded()) | ||
| 352 | let format = UIGraphicsImageRendererFormat() | ||
| 353 | format.scale = 1 | ||
| 354 | let drawn = UIGraphicsImageRenderer(size: pixels, format: format).image { _ in | ||
| 355 | image.draw(in: CGRect(origin: .zero, size: pixels)) | ||
| 356 | } | ||
| 357 | guard let data = drawn.jpegData(compressionQuality: 0.85) else { return } | ||
| 358 | // OneNote shows a picture's pixels at 96 per inch. | ||
| 359 | var size = CGSize(width: pixels.width * 0.75, height: pixels.height * 0.75) | ||
| 360 | if size.width > 220 { size = CGSize(width: 220, height: size.height * 220 / size.width) } | ||
| 361 | canvas.insertPicture(data, size: size) | ||
| 362 | } | 342 | } |
| 363 | } | 343 | } |
| 364 | 344 |
arc/canvas.md+21| ... | @@ -117,6 +117,14 @@ package. The page draws the PNG OneNote rendered of each | ... | @@ -117,6 +117,14 @@ package. The page draws the PNG OneNote rendered of each |
| 117 | pictures. Snowbound does not write XPS, so a printout page deleted and brought | 117 | pictures. Snowbound does not write XPS, so a printout page deleted and brought |
| 118 | back by undo returns as that picture (`corpus/printout`). | 118 | back by undo returns as that picture (`corpus/printout`). |
| 119 | 119 | ||
| 120 | Pictures go in as OneNote 2010 pastes and inserts them. At a caret in text the | ||
| 121 | paragraph splits around the picture, as around a file. On blank page the picture | ||
| 122 | lies on the page at the caret, and the caret moves to the grid row below it. A | ||
| 123 | picture takes the size its resolution gives it, or 96 dpi without one. Paste | ||
| 124 | takes files first (a picture file as its picture), then text, then a picture: | ||
| 125 | Finder offers a copied file's name and icon beside it, and other apps a picture | ||
| 126 | of copied text. | ||
| 127 | |||
| 120 | ## Recordings | 128 | ## Recordings |
| 121 | 129 | ||
| 122 | Record Audio and Record Video work as OneNote 2010's do. The caret's paragraph | 130 | Record Audio and Record Video work as OneNote 2010's do. The caret's paragraph |
| ... | @@ -222,6 +230,19 @@ iOS) offers the dictionary's corrections, Ignore and Add to Dictionary, and the | ... | @@ -222,6 +230,19 @@ iOS) offers the dictionary's corrections, Ignore and Add to Dictionary, and the |
| 222 | Spelling pane (F7) walks the page's marked words. A correction is an ordinary | 230 | Spelling pane (F7) walks the page's marked words. A correction is an ordinary |
| 223 | edit, one revision. Nothing about spelling is stored in the page. | 231 | edit, one revision. Nothing about spelling is stored in the page. |
| 224 | 232 | ||
| 233 | ## Printing and PDF | ||
| 234 | |||
| 235 | `canvas::print` lays pages on paper as OneNote 2010 prints them (`corpus/print`): between | ||
| 236 | half-inch top and bottom margins with the margin origin an inch in, the whole page shrunk | ||
| 237 | when its content reaches past the paper's right edge, and each sheet after the first | ||
| 238 | starting at the line of text or picture the one before would have cut. Rule lines and | ||
| 239 | template art print across the paper; the page colour does not. The footer names the | ||
| 240 | section and numbers the sheets. `draw::pdf` writes the same primitives the screen paints | ||
| 241 | as PDF: text in subset fonts whose ToUnicode maps come from the laid-out text, so it | ||
| 242 | selects and searches, ligatures included; ink and shapes as paths; pictures and tag | ||
| 243 | icons as images. The desktop's Print hands that PDF to AppKit's print panel, the XDG print | ||
| 244 | portal or the shell's print verb for PDFs; Export as PDF saves it. | ||
| 245 | |||
| 225 | ## Search, dates, conflicts | 246 | ## Search, dates, conflicts |
| 226 | 247 | ||
| 227 | Smaller modules follow the same pattern of reproducing OneNote's behaviour | 248 | Smaller 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 | ||
| 22 | page, one revision each. `cold/` is a fresh OneNote 2010 read of it, whose MathML matches | ||
| 23 | the lab's equation for equation. `tools/test_equation_select.py` checks this without a VM. | ||
| 24 | Regenerate with `CANVAS_EQUATION_SELECT_EXPORT` set to a new absolute directory while | ||
| 25 | running 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 | ||
| 3 | href="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 | ||
| 4 | style='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 | ||
| 5 | style='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 @@ | ||
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | ||
| 2 | Set-StrictMode -Version Latest | ||
| 3 | $ErrorActionPreference = 'Stop' | ||
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | ||
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | ||
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | ||
| 7 | } | ||
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | ||
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | ||
| 10 | if ($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)) | ||
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | ||
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | ||
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | ||
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | ||
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | ||
| 27 | New-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 @@ | ||
| 1 | param( | ||
| 2 | [Parameter(Mandatory=$true)][string]$Root, | ||
| 3 | [int]$ExpectedPages = -1, | ||
| 4 | [switch]$UseCurrentCache, | ||
| 5 | [switch]$Pdf, | ||
| 6 | [switch]$KeepOpen, | ||
| 7 | [string]$CloneHost = '' | ||
| 8 | ) | ||
| 9 | Set-StrictMode -Version Latest | ||
| 10 | $ErrorActionPreference = 'Stop' | ||
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | ||
| 12 | if ([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' | ||
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | ||
| 18 | if ($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 | } | ||
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | ||
| 26 | $app = New-Object -ComObject OneNote.Application | ||
| 27 | $notebookId = '' | ||
| 28 | $failure = $null | ||
| 29 | try { | ||
| 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 | ||
| 3 | href="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 | ||
| 4 | style='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 | ||
| 5 | style='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 | ||
| 3 | href="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 | ||
| 3 | href="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 | ||
| 4 | style='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 | ||
| 5 | style='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 | ||
| 3 | href="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 | ||
| 4 | style='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 | ||
| 5 | style='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 | ||
| 3 | href="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 | |||
| 2 | |||
| 3 | `native/Print.one` is a section the COM API made in OneNote 2010: "Printing test", a | ||
| 4 | ruled page (horizontal lines every 0.2 points, as OneNote reads the XML's spacing, and a | ||
| 5 | margin line) with a 70-paragraph outline running past the first sheet, a picture, an | ||
| 6 | outline near the first sheet's foot and one reaching 867 points across; "Second page"; | ||
| 7 | and "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, | ||
| 9 | Print prints. `native/rules-section.pdf` is its PDF of `page-background/candidate`. | ||
| 10 | |||
| 11 | What 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 | |||
| 28 | Print Preview and Settings offers Print range (Current Page, Page Group, Current Section), | ||
| 29 | Paper size, Scale content to paper width (on), Orientation, Footer (section and page | ||
| 30 | number, page number, section, none) and Start page numbering at 1. Save As, PDF offers | ||
| 31 | Selected Pages, Current Section and Current Notebook. | ||
| 32 | |||
| 33 | `crates/canvas/src/print.rs` checks Snowbound's sheets against these without a VM. | ||
corpus/print/native/Print.one created| Binary files /dev/null and b/corpus/print/native/Print.one differ | |||
corpus/print/native/rules-section.pdf created| Binary files /dev/null and b/corpus/print/native/rules-section.pdf differ | |||
corpus/print/native/section.pdf created| Binary files /dev/null and b/corpus/print/native/section.pdf differ | |||
crates/canvas/Cargo.toml+7| ... | @@ -8,6 +8,8 @@ publish = false | ... | @@ -8,6 +8,8 @@ publish = false |
| 8 | gpu = ["draw/render", "dep:color_quant", "dep:image", "dep:png", "dep:resvg", "dep:sha2"] | 8 | gpu = ["draw/render", "dep:color_quant", "dep:image", "dep:png", "dep:resvg", "dep:sha2"] |
| 9 | # How a page responds to input and presents itself to assistive technology. | 9 | # How a page responds to input and presents itself to assistive technology. |
| 10 | interaction = ["gpu", "dep:accesskit"] | 10 | interaction = ["gpu", "dep:accesskit"] |
| 11 | # Pages printed as PDF, as OneNote prints them. | ||
| 12 | pdf = ["interaction", "draw/pdf"] | ||
| 11 | 13 | ||
| 12 | [dependencies] | 14 | [dependencies] |
| 13 | draw = { path = "../draw", default-features = false } | 15 | draw = { path = "../draw", default-features = false } |
| ... | @@ -35,6 +37,7 @@ path = "src/bin/page_probe.rs" | ... | @@ -35,6 +37,7 @@ path = "src/bin/page_probe.rs" |
| 35 | 37 | ||
| 36 | [dev-dependencies] | 38 | [dev-dependencies] |
| 37 | draw = { path = "../draw", features = ["wgpu"] } | 39 | draw = { path = "../draw", features = ["wgpu"] } |
| 40 | miniz_oxide = "0.8" | ||
| 38 | accesskit_consumer = "0.39.0" | 41 | accesskit_consumer = "0.39.0" |
| 39 | pollster = "0.4" | 42 | pollster = "0.4" |
| 40 | wgpu.workspace = true | 43 | wgpu.workspace = true |
| ... | @@ -42,3 +45,7 @@ wgpu.workspace = true | ... | @@ -42,3 +45,7 @@ wgpu.workspace = true |
| 42 | [[example]] | 45 | [[example]] |
| 43 | name = "render_page" | 46 | name = "render_page" |
| 44 | required-features = ["gpu"] | 47 | required-features = ["gpu"] |
| 48 | |||
| 49 | [[example]] | ||
| 50 | name = "print_pdf" | ||
| 51 | required-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 | |||
| 4 | use canvas::{layout::TextEngine, print}; | ||
| 5 | use onestore::{RevisionIndex, Store, document::Document, page::Page}; | ||
| 6 | use std::{env, fs, path::Path}; | ||
| 7 | |||
| 8 | fn 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 | |||
| 8 | use canvas::{ | ||
| 9 | document::TextPosition, | ||
| 10 | editor::{CanvasEditor, EditorError, TextOutline}, | ||
| 11 | layout::{LayoutError, TextEngine}, | ||
| 12 | outline::Arrange, | ||
| 13 | }; | ||
| 14 | use onestore::{ | ||
| 15 | Arena, ExGuid, Section, | ||
| 16 | page::{ | ||
| 17 | Image, Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, | ||
| 18 | text::{Affinity, EditError}, | ||
| 19 | }, | ||
| 20 | }; | ||
| 21 | use std::{ | ||
| 22 | collections::{BTreeMap, BTreeSet, hash_map::DefaultHasher}, | ||
| 23 | hash::{Hash, Hasher}, | ||
| 24 | path::{Path, PathBuf}, | ||
| 25 | }; | ||
| 26 | |||
| 27 | #[derive(Default)] | ||
| 28 | struct 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)] | ||
| 36 | enum 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 | |||
| 47 | impl 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. | ||
| 57 | fn 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 | |||
| 82 | fn 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. | ||
| 91 | fn 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. | ||
| 115 | fn 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. | ||
| 185 | fn 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. | ||
| 230 | fn 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. | ||
| 262 | fn 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. | ||
| 291 | fn 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)] | ||
| 428 | enum Probe { | ||
| 429 | Type, | ||
| 430 | Enter, | ||
| 431 | Delete, | ||
| 432 | Backspace, | ||
| 433 | } | ||
| 434 | |||
| 435 | /// Each paragraph's table cell, `None` at the outline's top. | ||
| 436 | fn 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 | |||
| 452 | fn 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 | |||
| 536 | fn 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 | |||
| 583 | fn 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 | |||
| 598 | fn 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 @@ | ||
| 1 | use onestore::{RevisionIndex, Store, document::{Document, Kind}}; | ||
| 2 | fn 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 @@ | ||
| 1 | use onestore::{Arena, Section, page::{PageObject}}; | ||
| 2 | fn main() { | ||
| 3 | let path = std::env::args().nth(1).unwrap(); | ||
| 4 | let want = std::env::args().nth(2).unwrap_or_default(); | ||
| 5 | let arena = Arena::default(); | ||
| 6 | let mut section = Section::open(&arena, std::fs::read(path).unwrap()).unwrap(); | ||
| 7 | for (space, _, _) in section.pages().unwrap() { | ||
| 8 | let page = section.page(space).unwrap(); | ||
| 9 | for object in &page.objects { | ||
| 10 | let PageObject::Outline(outline) = object else { continue }; | ||
| 11 | for p in &outline.paragraphs { | ||
| 12 | let Some(text) = p.text() else { continue }; | ||
| 13 | if !text.text.text().contains(want.as_str()) { continue } | ||
| 14 | let mut start = 0; | ||
| 15 | println!("-- {:?}", p.id); | ||
| 16 | for span in text.text.spans() { | ||
| 17 | println!(" {:?} math={:?} obj={:?} emb={:?}", &text.text.text()[start..span.end], span.format.math, span.format.math_object, span.format.embedded_object); | ||
| 18 | start = span.end; | ||
| 19 | } | ||
| 20 | } | ||
| 21 | } | ||
| 22 | } | ||
| 23 | } | ||
crates/canvas/src/editor.rs+35-32| ... | @@ -2178,39 +2178,29 @@ impl CanvasEditor { | ... | @@ -2178,39 +2178,29 @@ impl CanvasEditor { |
| 2178 | Ok(true) | 2178 | Ok(true) |
| 2179 | } | 2179 | } |
| 2180 | 2180 | ||
| 2181 | /// Puts `image` in the active outline's flow after the caret's paragraph, as OneNote | 2181 | /// Puts `image` at the caret as OneNote 2010 pastes and inserts a picture: in an outline |
| 2182 | /// inserts a picture into an outline; the caret stays. | 2182 | /// as [`Self::insert_attachment`] puts a file, and on blank page at the caret with the |
| 2183 | /// caret moving to the grid row below it, where typing starts a new outline. | ||
| 2183 | pub fn insert_picture( | 2184 | pub fn insert_picture( |
| 2184 | &mut self, | 2185 | &mut self, |
| 2185 | engine: &mut TextEngine, | 2186 | engine: &mut TextEngine, |
| 2186 | image: onestore::page::Image, | 2187 | mut image: onestore::page::Image, |
| 2187 | ) -> Result<(), EditorError> { | 2188 | ) -> Result<(), EditorError> { |
| 2188 | let selection = self.selection(); | 2189 | let Focus::Caret { outline, .. } = &self.active else { |
| 2189 | let caret = selection.positions[0].max(selection.positions[1]); | 2190 | return self.insert_in_flow(engine, ParagraphContent::Image(image)); |
| 2190 | let (container, index, node) = self | ||
| 2191 | .active_outline() | ||
| 2192 | .document | ||
| 2193 | .leaf(caret.paragraph) | ||
| 2194 | .ok_or(EditError::InvalidRange)?; | ||
| 2195 | let picture = PageParagraph { | ||
| 2196 | id: onestore::page::text::new_id()?, | ||
| 2197 | content: ParagraphContent::Image(image), | ||
| 2198 | style: None, | ||
| 2199 | lists: Vec::new(), | ||
| 2200 | tags: Vec::new(), | ||
| 2201 | collapsed: false, | ||
| 2202 | ..node.clone() | ||
| 2203 | }; | 2191 | }; |
| 2204 | self.commit( | 2192 | let [x, y] = outline.origin(); |
| 2205 | engine, | 2193 | [image.layout.x, image.layout.y] = [Some(x), Some(y)]; |
| 2206 | DocumentEdit { | 2194 | image.layout.width_set_by_user = Some(false); |
| 2207 | columns: BTreeMap::new(), | 2195 | let height = image.layout.max_height.unwrap_or_default(); |
| 2208 | container, | 2196 | self.finish_composition(); |
| 2209 | range: index + 1..index + 1, | 2197 | let index = self.objects.len(); |
| 2210 | replacement: vec![picture], | 2198 | self.add_picture(index, image); |
| 2211 | }, | 2199 | self.undo.push(History::Picture { index, image: None }); |
| 2212 | selection, | 2200 | self.redo.clear(); |
| 2213 | ) | 2201 | let margin = self.margin_origin()[1]; |
| 2202 | let row = ((y + height - margin) / 18.0).floor() + 1.0; | ||
| 2203 | self.place_caret(engine, [x, margin + row * 18.0], DEFAULT_OUTLINE_WIDTH) | ||
| 2214 | } | 2204 | } |
| 2215 | 2205 | ||
| 2216 | /// Attaches `file` at the caret as OneNote 2010 does (`corpus/attachment-insert`): the | 2206 | /// Attaches `file` at the caret as OneNote 2010 does (`corpus/attachment-insert`): the |
| ... | @@ -2241,11 +2231,21 @@ impl CanvasEditor { | ... | @@ -2241,11 +2231,21 @@ impl CanvasEditor { |
| 2241 | self.redo.clear(); | 2231 | self.redo.clear(); |
| 2242 | return Ok(()); | 2232 | return Ok(()); |
| 2243 | } | 2233 | } |
| 2244 | let file = match self.across(engine, |editor, engine| { | 2234 | self.insert_in_flow(engine, ParagraphContent::Attachment(file)) |
| 2245 | editor.insert_attachment(engine, file.clone()) | 2235 | } |
| 2236 | |||
| 2237 | /// Splits the caret's paragraph around `content`, which takes the place of an empty first | ||
| 2238 | /// half; the caret starts the second. | ||
| 2239 | fn insert_in_flow( | ||
| 2240 | &mut self, | ||
| 2241 | engine: &mut TextEngine, | ||
| 2242 | content: ParagraphContent, | ||
| 2243 | ) -> Result<(), EditorError> { | ||
| 2244 | let content = match self.across(engine, |editor, engine| { | ||
| 2245 | editor.insert_in_flow(engine, content.clone()) | ||
| 2246 | })? { | 2246 | })? { |
| 2247 | Some(()) => return Ok(()), | 2247 | Some(()) => return Ok(()), |
| 2248 | None => file, | 2248 | None => content, |
| 2249 | }; | 2249 | }; |
| 2250 | let [anchor, focus] = self.selection().positions; | 2250 | let [anchor, focus] = self.selection().positions; |
| 2251 | let (start, end) = (anchor.min(focus), anchor.max(focus)); | 2251 | let (start, end) = (anchor.min(focus), anchor.max(focus)); |
| ... | @@ -2260,7 +2260,7 @@ impl CanvasEditor { | ... | @@ -2260,7 +2260,7 @@ impl CanvasEditor { |
| 2260 | }; | 2260 | }; |
| 2261 | let node = PageParagraph { | 2261 | let node = PageParagraph { |
| 2262 | id: onestore::page::text::new_id()?, | 2262 | id: onestore::page::text::new_id()?, |
| 2263 | content: ParagraphContent::Attachment(file), | 2263 | content, |
| 2264 | style: None, | 2264 | style: None, |
| 2265 | lists: Vec::new(), | 2265 | lists: Vec::new(), |
| 2266 | tags: Vec::new(), | 2266 | tags: Vec::new(), |
| ... | @@ -3209,6 +3209,7 @@ impl CanvasEditor { | ... | @@ -3209,6 +3209,7 @@ impl CanvasEditor { |
| 3209 | engine: &mut TextEngine, | 3209 | engine: &mut TextEngine, |
| 3210 | replacement: Vec<Paragraph>, | 3210 | replacement: Vec<Paragraph>, |
| 3211 | ) -> Result<(), EditorError> { | 3211 | ) -> Result<(), EditorError> { |
| 3212 | self.take_objects()?; | ||
| 3212 | let start = self.active_outline().selection.positions[0] | 3213 | let start = self.active_outline().selection.positions[0] |
| 3213 | .min(self.active_outline().selection.positions[1]); | 3214 | .min(self.active_outline().selection.positions[1]); |
| 3214 | let end = self.active_outline().selection.positions[0] | 3215 | let end = self.active_outline().selection.positions[0] |
| ... | @@ -3308,6 +3309,7 @@ impl CanvasEditor { | ... | @@ -3308,6 +3309,7 @@ impl CanvasEditor { |
| 3308 | editor.paste(engine, text, language) | 3309 | editor.paste(engine, text, language) |
| 3309 | }); | 3310 | }); |
| 3310 | } | 3311 | } |
| 3312 | self.take_objects()?; | ||
| 3311 | let lines = text | 3313 | let lines = text |
| 3312 | .split('\n') | 3314 | .split('\n') |
| 3313 | .map(|line| line.strip_suffix('\r').unwrap_or(line)) | 3315 | .map(|line| line.strip_suffix('\r').unwrap_or(line)) |
| ... | @@ -3569,6 +3571,7 @@ impl CanvasEditor { | ... | @@ -3569,6 +3571,7 @@ impl CanvasEditor { |
| 3569 | self.grouped(|editor| editor.remove_page(engine, false))?; | 3571 | self.grouped(|editor| editor.remove_page(engine, false))?; |
| 3570 | return Ok(true); | 3572 | return Ok(true); |
| 3571 | } | 3573 | } |
| 3574 | self.take_objects()?; | ||
| 3572 | let selection = self.active_outline().selection; | 3575 | let selection = self.active_outline().selection; |
| 3573 | let [anchor, focus] = selection.positions; | 3576 | let [anchor, focus] = selection.positions; |
| 3574 | let mut range = anchor.min(focus)..anchor.max(focus); | 3577 | let mut range = anchor.min(focus)..anchor.max(focus); |
crates/canvas/src/editor/equation.rs+117-5| ... | @@ -36,6 +36,107 @@ pub(crate) fn zone(text: &Paragraph, offset: u32) -> Option<Range<u32>> { | ... | @@ -36,6 +36,107 @@ pub(crate) fn zone(text: &Paragraph, offset: u32) -> Option<Range<u32>> { |
| 36 | current.filter(|zone| zone.start <= offset && offset <= zone.end) | 36 | current.filter(|zone| zone.start <= offset && offset <= zone.end) |
| 37 | } | 37 | } |
| 38 | 38 | ||
| 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)] | ||
| 42 | struct 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. | ||
| 49 | fn 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. | ||
| 100 | pub(crate) fn widen( | ||
| 101 | document: &TextDocument, | ||
| 102 | range: Range<TextPosition>, | ||
| 103 | ) -> Result<Range<TextPosition>, EditError> { | ||
| 104 | let text = |paragraph| document.paragraph(paragraph).ok_or(EditError::InvalidRange); | ||
| 105 | let last = text(range.end.paragraph)?; | ||
| 106 | let starts = holders(text(range.start.paragraph)?, range.start.offset); | ||
| 107 | let ends = holders(last, range.end.offset); | ||
| 108 | let common = if range.start.paragraph == range.end.paragraph { | ||
| 109 | starts.iter().zip(&ends).take_while(|(a, b)| a == b).count() | ||
| 110 | } else { | ||
| 111 | 0 | ||
| 112 | }; | ||
| 113 | let level = |holders: &[Holder]| { | ||
| 114 | holders | ||
| 115 | .iter() | ||
| 116 | .position(|holder| holder.placeholder) | ||
| 117 | .map_or(common, |placeholder| placeholder.min(common)) | ||
| 118 | }; | ||
| 119 | let mut widened = range.clone(); | ||
| 120 | if let Some(holder) = starts.get(level(&starts)) { | ||
| 121 | widened.start.offset = holder.range.start; | ||
| 122 | } | ||
| 123 | if let Some(holder) = ends.get(level(&ends)) { | ||
| 124 | widened.end.offset = holder.range.end; | ||
| 125 | let next = range.end.paragraph + 1; | ||
| 126 | let container = |paragraph| document.leaf(paragraph).map(|(container, ..)| container); | ||
| 127 | if range.start.paragraph != range.end.paragraph | ||
| 128 | && widened.end.offset == last.utf16_offset(last.text().len())? | ||
| 129 | && container(next).is_some_and(|next| Some(next) == container(range.end.paragraph)) | ||
| 130 | { | ||
| 131 | widened.end = TextPosition { | ||
| 132 | paragraph: next, | ||
| 133 | offset: 0, | ||
| 134 | }; | ||
| 135 | } | ||
| 136 | } | ||
| 137 | Ok(widened) | ||
| 138 | } | ||
| 139 | |||
| 39 | /// The format text typed after an equation in `text` takes: the paragraph's last text's, or | 140 | /// The format text typed after an equation in `text` takes: the paragraph's last text's, or |
| 40 | /// its first run's without the equation editor's. | 141 | /// its first run's without the equation editor's. |
| 41 | pub(super) fn text_after(text: &Paragraph) -> Format { | 142 | pub(super) fn text_after(text: &Paragraph) -> Format { |
| ... | @@ -70,6 +171,20 @@ impl CanvasEditor { | ... | @@ -70,6 +171,20 @@ impl CanvasEditor { |
| 70 | Some((caret.paragraph, zone(text, caret.offset)?)) | 171 | Some((caret.paragraph, zone(text, caret.offset)?)) |
| 71 | } | 172 | } |
| 72 | 173 | ||
| 174 | /// Widens the selection an edit is about to replace as the equation editor selects. | ||
| 175 | pub(super) fn take_objects(&mut self) -> Result<(), EditError> { | ||
| 176 | let [anchor, focus] = self.active_outline().selection.positions; | ||
| 177 | let range = anchor.min(focus)..anchor.max(focus); | ||
| 178 | let widened = widen(&self.active_outline().document, range.clone())?; | ||
| 179 | if widened != range { | ||
| 180 | self.active_outline_mut().selection = Selection { | ||
| 181 | positions: [widened.start, widened.end], | ||
| 182 | affinities: [Affinity::Downstream, Affinity::Upstream], | ||
| 183 | }; | ||
| 184 | } | ||
| 185 | Ok(()) | ||
| 186 | } | ||
| 187 | |||
| 73 | /// Whether the caret is in an equation, which Linear and Professional act on. | 188 | /// Whether the caret is in an equation, which Linear and Professional act on. |
| 74 | pub fn in_equation(&self) -> bool { | 189 | pub fn in_equation(&self) -> bool { |
| 75 | self.caret_zone().is_some() | 190 | self.caret_zone().is_some() |
| ... | @@ -373,13 +488,10 @@ impl CanvasEditor { | ... | @@ -373,13 +488,10 @@ impl CanvasEditor { |
| 373 | for part in parts { | 488 | for part in parts { |
| 374 | broken.append(part)?; | 489 | broken.append(part)?; |
| 375 | } | 490 | } |
| 376 | let Some(linear) = Math::parse(&broken) | 491 | let Ok(nodes) = Math::parse(&broken) else { |
| 377 | .ok() | ||
| 378 | .and_then(|nodes| Math::linear(&nodes)) | ||
| 379 | else { | ||
| 380 | return Ok(false); | 492 | return Ok(false); |
| 381 | }; | 493 | }; |
| 382 | let built = Math::paragraph(&Math::from_linear(&linear), &math.spans()[0].format); | 494 | let built = Math::paragraph(&nodes, &math.spans()[0].format); |
| 383 | // The caret starts the new row: after as many characters other than spaces as came | 495 | // The caret starts the new row: after as many characters other than spaces as came |
| 384 | // before it. | 496 | // before it. |
| 385 | let before = broken | 497 | let before = broken |
crates/canvas/src/editor/table.rs+1| ... | @@ -398,6 +398,7 @@ impl CanvasEditor { | ... | @@ -398,6 +398,7 @@ impl CanvasEditor { |
| 398 | if soft { | 398 | if soft { |
| 399 | return self.insert(engine, "\u{000b}"); | 399 | return self.insert(engine, "\u{000b}"); |
| 400 | } | 400 | } |
| 401 | self.take_objects()?; | ||
| 401 | if self.active_outline().title { | 402 | if self.active_outline().title { |
| 402 | return self.leave_title(engine); | 403 | return self.leave_title(engine); |
| 403 | } | 404 | } |
crates/canvas/src/gpu/mod.rs+1| ... | @@ -110,6 +110,7 @@ impl Glyphs for TextLayout { | ... | @@ -110,6 +110,7 @@ impl Glyphs for TextLayout { |
| 110 | }; | 110 | }; |
| 111 | draw::paint_parley_run( | 111 | draw::paint_parley_run( |
| 112 | &run, | 112 | &run, |
| 113 | &self.text, | ||
| 113 | bounds.baseline, | 114 | bounds.baseline, |
| 114 | run.style().brush.rise, | 115 | run.style().brush.rise, |
| 115 | [bounds.top, bounds.height], | 116 | [bounds.top, bounds.height], |
crates/canvas/src/gpu/page.rs+85| ... | @@ -766,6 +766,91 @@ impl PageScene { | ... | @@ -766,6 +766,91 @@ impl PageScene { |
| 766 | None | 766 | None |
| 767 | } | 767 | } |
| 768 | 768 | ||
| 769 | /// Where the page read without an editor lies, in page coordinates: the rectangle all | ||
| 770 | /// its content covers, and the top and bottom of each line of text and each picture, which | ||
| 771 | /// a sheet of paper does not cut through where it can help it. | ||
| 772 | pub(crate) fn printed_extent(&self) -> Result<([f32; 4], Vec<[f32; 2]>), SceneError> { | ||
| 773 | let mut bounds = [ | ||
| 774 | f32::INFINITY, | ||
| 775 | f32::INFINITY, | ||
| 776 | f32::NEG_INFINITY, | ||
| 777 | f32::NEG_INFINITY, | ||
| 778 | ]; | ||
| 779 | let mut rows = Vec::new(); | ||
| 780 | let mut cover = |[x0, y0, x1, y1]: [f32; 4], row: bool| { | ||
| 781 | bounds = [ | ||
| 782 | bounds[0].min(x0), | ||
| 783 | bounds[1].min(y0), | ||
| 784 | bounds[2].max(x1), | ||
| 785 | bounds[3].max(y1), | ||
| 786 | ]; | ||
| 787 | if row { | ||
| 788 | rows.push([y0, y1]); | ||
| 789 | } | ||
| 790 | }; | ||
| 791 | for content in self.objects(None)? { | ||
| 792 | match content { | ||
| 793 | Content::Outline { | ||
| 794 | source, | ||
| 795 | layout, | ||
| 796 | below_title, | ||
| 797 | } => { | ||
| 798 | let [x, y] = outline_origin( | ||
| 799 | [ | ||
| 800 | source.layout.x.unwrap_or(0.0), | ||
| 801 | source.layout.y.unwrap_or(0.0), | ||
| 802 | ], | ||
| 803 | *below_title, | ||
| 804 | None, | ||
| 805 | )?; | ||
| 806 | cover([x, y, x + layout.size[0], y + layout.size[1]], false); | ||
| 807 | for paragraph in &layout.paragraphs { | ||
| 808 | let [px, py] = [x + paragraph.origin[0], y + paragraph.origin[1]]; | ||
| 809 | for (line, bounds) in paragraph.text.lines() { | ||
| 810 | let top = py + bounds.top; | ||
| 811 | let right = px + line.metrics().advance; | ||
| 812 | cover([px, top, right, top + bounds.height], true); | ||
| 813 | } | ||
| 814 | } | ||
| 815 | for object in &layout.objects { | ||
| 816 | let [x0, y0, x1, y1] = object.rect; | ||
| 817 | cover([x + x0, y + y0, x + x1, y + object.bottom.max(y1)], true); | ||
| 818 | } | ||
| 819 | } | ||
| 820 | Content::Image(source) => { | ||
| 821 | let [x, y] = [ | ||
| 822 | source.layout.x.unwrap_or(0.0), | ||
| 823 | source.layout.y.unwrap_or(0.0), | ||
| 824 | ]; | ||
| 825 | let size = match self.backgrounds.get(&source.id) { | ||
| 826 | Some(art) => Some(art.size), | ||
| 827 | None => source | ||
| 828 | .layout | ||
| 829 | .max_width | ||
| 830 | .zip(source.layout.max_height) | ||
| 831 | .map(|(width, height)| [width, height]), | ||
| 832 | }; | ||
| 833 | if let Some([width, height]) = size { | ||
| 834 | cover([x, y, x + width, y + height], !source.background); | ||
| 835 | } | ||
| 836 | } | ||
| 837 | Content::File { .. } => { | ||
| 838 | if let Some((_, rect)) = content.file() { | ||
| 839 | cover(rect, true); | ||
| 840 | } | ||
| 841 | } | ||
| 842 | Content::Ink(ink) => { | ||
| 843 | if let Some(rect) = crate::editor::page::ink_bounds(ink) { | ||
| 844 | cover(rect, false); | ||
| 845 | } | ||
| 846 | } | ||
| 847 | Content::ReadOnly(object) => cover(object.rect(), true), | ||
| 848 | Content::Date { .. } | Content::Editable(_) => {} | ||
| 849 | } | ||
| 850 | } | ||
| 851 | Ok((bounds, rows)) | ||
| 852 | } | ||
| 853 | |||
| 769 | /// The offset is in document points. | 854 | /// The offset is in document points. |
| 770 | pub fn append_primitives<'a>( | 855 | pub fn append_primitives<'a>( |
| 771 | &'a self, | 856 | &'a self, |
crates/canvas/src/interaction/ink.rs+1-9| ... | @@ -2,7 +2,7 @@ | ... | @@ -2,7 +2,7 @@ |
| 2 | //! dragged out on the grid, the stroke eraser, and the lasso whose drawings move and delete, | 2 | //! dragged out on the grid, the stroke eraser, and the lasso whose drawings move and delete, |
| 3 | //! as OneNote 2010 has them (`corpus/ink-tools`). | 3 | //! as OneNote 2010 has them (`corpus/ink-tools`). |
| 4 | 4 | ||
| 5 | use super::{PageView, Response, Result, snap_to_grid}; | 5 | use super::{PageView, Response, Result}; |
| 6 | use crate::editor::{FAVORITES, Pen}; | 6 | use crate::editor::{FAVORITES, Pen}; |
| 7 | use crate::gpu::colorref; | 7 | use crate::gpu::colorref; |
| 8 | use draw::{ | 8 | use draw::{ |
| ... | @@ -184,14 +184,6 @@ impl PageView { | ... | @@ -184,14 +184,6 @@ impl PageView { |
| 184 | Ok(Some(self.changed()?)) | 184 | Ok(Some(self.changed()?)) |
| 185 | } | 185 | } |
| 186 | 186 | ||
| 187 | fn grid(&self, point: [f32; 2]) -> [f32; 2] { | ||
| 188 | if self.modifiers.option { | ||
| 189 | point | ||
| 190 | } else { | ||
| 191 | snap_to_grid(point, self.editor.margin_origin()) | ||
| 192 | } | ||
| 193 | } | ||
| 194 | |||
| 195 | /// The pointer moved to page point `point` during a gesture. | 187 | /// The pointer moved to page point `point` during a gesture. |
| 196 | pub(super) fn ink_moved(&mut self, point: [f32; 2]) -> Result<Response> { | 188 | pub(super) fn ink_moved(&mut self, point: [f32; 2]) -> Result<Response> { |
| 197 | let pressure = self.ink.pressure; | 189 | let pressure = self.ink.pressure; |
crates/canvas/src/interaction/mod.rs+50-24| ... | @@ -258,6 +258,9 @@ pub struct PageView { | ... | @@ -258,6 +258,9 @@ pub struct PageView { |
| 258 | /// A platform scroll view owns the viewport: changes neither clamp it nor reveal the | 258 | /// A platform scroll view owns the viewport: changes neither clamp it nor reveal the |
| 259 | /// caret, which the host does knowing its bars and keyboard. | 259 | /// caret, which the host does knowing its bars and keyboard. |
| 260 | pub host_viewport: bool, | 260 | pub host_viewport: bool, |
| 261 | /// OneNote's Snap to Grid: clicks, drags and shapes land on the placement grid unless | ||
| 262 | /// Option (Alt) is held. Off, they land where the pointer is. | ||
| 263 | pub snap_to_grid: bool, | ||
| 261 | /// Matches of the search shown on the page, marked under their text. | 264 | /// Matches of the search shown on the page, marked under their text. |
| 262 | pub found: Vec<crate::search::PageMatch>, | 265 | pub found: Vec<crate::search::PageMatch>, |
| 263 | /// The note playing, highlighted as See Playback highlights it. | 266 | /// The note playing, highlighted as See Playback highlights it. |
| ... | @@ -271,6 +274,9 @@ pub struct PageView { | ... | @@ -271,6 +274,9 @@ pub struct PageView { |
| 271 | /// When the caret last moved, which restarts its blink. | 274 | /// When the caret last moved, which restarts its blink. |
| 272 | blink_from: Instant, | 275 | blink_from: Instant, |
| 273 | ink: ink::State, | 276 | ink: ink::State, |
| 277 | /// What the view showed before each zoom since the page opened, in document points: | ||
| 278 | /// scrolling still reaches it, so a zoom keeps the point under the pointer in place. | ||
| 279 | reach: Option<[f32; 4]>, | ||
| 274 | } | 280 | } |
| 275 | 281 | ||
| 276 | /// Room, in OneNote pixels, the view leaves beyond content it scrolls to. | 282 | /// Room, in OneNote pixels, the view leaves beyond content it scrolls to. |
| ... | @@ -326,6 +332,7 @@ impl PageView { | ... | @@ -326,6 +332,7 @@ impl PageView { |
| 326 | focused: true, | 332 | focused: true, |
| 327 | touch: false, | 333 | touch: false, |
| 328 | host_viewport: false, | 334 | host_viewport: false, |
| 335 | snap_to_grid: true, | ||
| 329 | found: Vec::new(), | 336 | found: Vec::new(), |
| 330 | played: None, | 337 | played: None, |
| 331 | spelling: None, | 338 | spelling: None, |
| ... | @@ -333,6 +340,7 @@ impl PageView { | ... | @@ -333,6 +340,7 @@ impl PageView { |
| 333 | caret: 1.0, | 340 | caret: 1.0, |
| 334 | blink_from: Instant::now(), | 341 | blink_from: Instant::now(), |
| 335 | ink: Default::default(), | 342 | ink: Default::default(), |
| 343 | reach: None, | ||
| 336 | }; | 344 | }; |
| 337 | view.place_opened(); | 345 | view.place_opened(); |
| 338 | view | 346 | view |
| ... | @@ -376,6 +384,7 @@ impl PageView { | ... | @@ -376,6 +384,7 @@ impl PageView { |
| 376 | place: Option<Place>, | 384 | place: Option<Place>, |
| 377 | ) { | 385 | ) { |
| 378 | self.replace(editor, scene); | 386 | self.replace(editor, scene); |
| 387 | self.reach = None; | ||
| 379 | let Some(place) = place else { | 388 | let Some(place) = place else { |
| 380 | return self.place_opened(); | 389 | return self.place_opened(); |
| 381 | }; | 390 | }; |
| ... | @@ -461,14 +470,7 @@ impl PageView { | ... | @@ -461,14 +470,7 @@ impl PageView { |
| 461 | } | 470 | } |
| 462 | let point = self.viewport.document_point(self.pointer); | 471 | let point = self.viewport.document_point(self.pointer); |
| 463 | let position = [point[0] - grab[0], point[1] - grab[1]]; | 472 | let position = [point[0] - grab[0], point[1] - grab[1]]; |
| 464 | Some(( | 473 | Some((*id, self.grid(position))) |
| 465 | *id, | ||
| 466 | if self.modifiers.option { | ||
| 467 | position | ||
| 468 | } else { | ||
| 469 | snap_to_grid(position, self.editor.margin_origin()) | ||
| 470 | }, | ||
| 471 | )) | ||
| 472 | } | 474 | } |
| 473 | 475 | ||
| 474 | /// The dragged outline and how far it would move. | 476 | /// The dragged outline and how far it would move. |
| ... | @@ -505,12 +507,16 @@ impl PageView { | ... | @@ -505,12 +507,16 @@ impl PageView { |
| 505 | return Some((*id, origin, size)); | 507 | return Some((*id, origin, size)); |
| 506 | } | 508 | } |
| 507 | let origin = [origin[0] + delta[0], origin[1] + delta[1]]; | 509 | let origin = [origin[0] + delta[0], origin[1] + delta[1]]; |
| 508 | let origin = if self.modifiers.option { | 510 | Some((*id, self.grid(origin), size)) |
| 509 | origin | 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()) | ||
| 510 | } else { | 517 | } else { |
| 511 | snap_to_grid(origin, self.editor.margin_origin()) | 518 | point |
| 512 | }; | 519 | } |
| 513 | Some((*id, origin, size)) | ||
| 514 | } | 520 | } |
| 515 | 521 | ||
| 516 | fn pixel(&self) -> f32 { | 522 | fn pixel(&self) -> f32 { |
| ... | @@ -786,14 +792,27 @@ impl PageView { | ... | @@ -786,14 +792,27 @@ impl PageView { |
| 786 | self.moved() | 792 | self.moved() |
| 787 | } | 793 | } |
| 788 | 794 | ||
| 789 | /// Scales the view by `factor` about the pointer, as a trackpad pinch does. | 795 | /// Scales the view by `factor` about `anchor`, in device pixels from the view's |
| 790 | pub fn pinch(&mut self, factor: f32) -> Result<Response> { | 796 | /// top-left, as a trackpad pinch does about the pointer. |
| 791 | self.zoom_about(factor, self.pointer); | 797 | pub fn pinch(&mut self, factor: f32, anchor: [f32; 2]) -> Result<Response> { |
| 798 | self.zoom_about(factor, anchor); | ||
| 792 | self.moved() | 799 | self.moved() |
| 793 | } | 800 | } |
| 794 | 801 | ||
| 795 | /// Scales the view by `factor`, keeping the document point under `anchor` in place. | 802 | /// Scales the view by `factor`, keeping the document point under `anchor` in place. |
| 796 | fn zoom_about(&mut self, factor: f32, anchor: [f32; 2]) { | 803 | fn zoom_about(&mut self, factor: f32, anchor: [f32; 2]) { |
| 804 | let [x0, y0] = self.viewport.document_point([0.0; 2]); | ||
| 805 | let [x1, y1] = self | ||
| 806 | .viewport | ||
| 807 | .document_point(self.viewport.size.map(|size| size as f32)); | ||
| 808 | self.reach = Some(self.reach.map_or([x0, y0, x1, y1], |reach| { | ||
| 809 | [ | ||
| 810 | reach[0].min(x0), | ||
| 811 | reach[1].min(y0), | ||
| 812 | reach[2].max(x1), | ||
| 813 | reach[3].max(y1), | ||
| 814 | ] | ||
| 815 | })); | ||
| 797 | let point = self.viewport.document_point(anchor); | 816 | let point = self.viewport.document_point(anchor); |
| 798 | let dpr = self.display_scale; | 817 | let dpr = self.display_scale; |
| 799 | self.viewport.scale = (self.viewport.scale * factor).clamp(dpr / 3.0, dpr * 16.0 / 3.0); | 818 | self.viewport.scale = (self.viewport.scale * factor).clamp(dpr / 3.0, dpr * 16.0 / 3.0); |
| ... | @@ -841,7 +860,16 @@ impl PageView { | ... | @@ -841,7 +860,16 @@ impl PageView { |
| 841 | ) | 860 | ) |
| 842 | }) | 861 | }) |
| 843 | }); | 862 | }); |
| 844 | scroll::Scroll::new(self.viewport, editable.map(|rect| (rect, pad)).chain(fixed)) | 863 | let mut scroll = |
| 864 | scroll::Scroll::new(self.viewport, editable.map(|rect| (rect, pad)).chain(fixed)); | ||
| 865 | if let Some(reach) = self.reach { | ||
| 866 | for axis in 0..2 { | ||
| 867 | scroll.min[axis] = scroll.min[axis].min(reach[axis] * self.viewport.scale); | ||
| 868 | scroll.max[axis] = scroll.max[axis] | ||
| 869 | .max(reach[axis + 2] * self.viewport.scale - self.viewport.size[axis] as f32); | ||
| 870 | } | ||
| 871 | } | ||
| 872 | scroll | ||
| 845 | } | 873 | } |
| 846 | 874 | ||
| 847 | /// Scrolls the view's corner `offset` device pixels from the page origin along `axis`, | 875 | /// Scrolls the view's corner `offset` device pixels from the page origin along `axis`, |
| ... | @@ -1263,11 +1291,7 @@ impl PageView { | ... | @@ -1263,11 +1291,7 @@ impl PageView { |
| 1263 | /// A fresh click places text 7 px above the pointer, on the grid. | 1291 | /// A fresh click places text 7 px above the pointer, on the grid. |
| 1264 | fn place_caret(&mut self, point: [f32; 2]) -> Result<()> { | 1292 | fn place_caret(&mut self, point: [f32; 2]) -> Result<()> { |
| 1265 | let position = [point[0], point[1] - 7.0 * self.pixel()]; | 1293 | let position = [point[0], point[1] - 7.0 * self.pixel()]; |
| 1266 | let position = if self.modifiers.option { | 1294 | let position = self.grid(position); |
| 1267 | position | ||
| 1268 | } else { | ||
| 1269 | snap_to_grid(position, self.editor.margin_origin()) | ||
| 1270 | }; | ||
| 1271 | self.editor | 1295 | self.editor |
| 1272 | .place_caret(&mut self.engine, position, DEFAULT_OUTLINE_WIDTH)?; | 1296 | .place_caret(&mut self.engine, position, DEFAULT_OUTLINE_WIDTH)?; |
| 1273 | Ok(()) | 1297 | Ok(()) |
| ... | @@ -1662,7 +1686,8 @@ impl PageView { | ... | @@ -1662,7 +1686,8 @@ impl PageView { |
| 1662 | self.edited() | 1686 | self.edited() |
| 1663 | } | 1687 | } |
| 1664 | 1688 | ||
| 1665 | /// Insert, Picture: a PNG, JPEG or GIF `size` points large after the caret's paragraph. | 1689 | /// Insert, Picture, or a pasted one: a PNG, JPEG or GIF `size` points large; see |
| 1690 | /// [`CanvasEditor::insert_picture`]. | ||
| 1666 | pub fn insert_picture(&mut self, bytes: Vec<u8>, size: [f32; 2]) -> Result<Response> { | 1691 | pub fn insert_picture(&mut self, bytes: Vec<u8>, size: [f32; 2]) -> Result<Response> { |
| 1667 | if !self.accepts_text() || self.editor.marked_range().is_some() { | 1692 | if !self.accepts_text() || self.editor.marked_range().is_some() { |
| 1668 | return Ok(Response::default()); | 1693 | return Ok(Response::default()); |
| ... | @@ -1682,6 +1707,7 @@ impl PageView { | ... | @@ -1682,6 +1707,7 @@ impl PageView { |
| 1682 | printout: None, | 1707 | printout: None, |
| 1683 | }; | 1708 | }; |
| 1684 | self.editor.insert_picture(&mut self.engine, image)?; | 1709 | self.editor.insert_picture(&mut self.engine, image)?; |
| 1710 | self.follow_pictures()?; | ||
| 1685 | self.edited() | 1711 | self.edited() |
| 1686 | } | 1712 | } |
| 1687 | 1713 | ||
| ... | @@ -2269,7 +2295,7 @@ fn page_hit( | ... | @@ -2269,7 +2295,7 @@ fn page_hit( |
| 2269 | /// The page's rule lines over `visible`, a document rectangle, as OneNote 2010 draws them: | 2295 | /// The page's rule lines over `visible`, a document rectangle, as OneNote 2010 draws them: |
| 2270 | /// 1/96 inch wide, or a device pixel where that is wider, horizontal lines from the margin | 2296 | /// 1/96 inch wide, or a device pixel where that is wider, horizontal lines from the margin |
| 2271 | /// origin down, and a grid's vertical lines through it or a margin line 1/96 inch left of it. | 2297 | /// origin down, and a grid's vertical lines through it or a margin line 1/96 inch left of it. |
| 2272 | fn rule_primitives( | 2298 | pub(crate) fn rule_primitives( |
| 2273 | lines: Option<onestore::page::RuleLines>, | 2299 | lines: Option<onestore::page::RuleLines>, |
| 2274 | [x, y]: [f32; 2], | 2300 | [x, y]: [f32; 2], |
| 2275 | [left, top, right, bottom]: [f32; 4], | 2301 | [left, top, right, bottom]: [f32; 4], |
crates/canvas/src/interaction/tests.rs+28-9| ... | @@ -1753,6 +1753,12 @@ fn events_route_through_the_view_as_the_host_delivers_them() { | ... | @@ -1753,6 +1753,12 @@ fn events_route_through_the_view_as_the_host_delivers_them() { |
| 1753 | click(&mut view, [400.0, 300.0], now + Duration::from_secs(3)); | 1753 | click(&mut view, [400.0, 300.0], now + Duration::from_secs(3)); |
| 1754 | let caret = view.editor.caret_outline().unwrap().origin(); | 1754 | let caret = view.editor.caret_outline().unwrap().origin(); |
| 1755 | assert_eq!(caret, snap_to_grid([400.0, 293.0], [36.0, 14.4])); | 1755 | assert_eq!(caret, snap_to_grid([400.0, 293.0], [36.0, 14.4])); |
| 1756 | |||
| 1757 | // With Snap to Grid off it lands where the pointer is, as OneNote 2010 places it. | ||
| 1758 | view.snap_to_grid = false; | ||
| 1759 | click(&mut view, [410.0, 330.0], now + Duration::from_secs(5)); | ||
| 1760 | let caret = view.editor.caret_outline().unwrap().origin(); | ||
| 1761 | assert_eq!(caret, [410.0, 323.0]); | ||
| 1756 | } | 1762 | } |
| 1757 | 1763 | ||
| 1758 | #[test] | 1764 | #[test] |
| ... | @@ -2471,6 +2477,8 @@ fn the_page_selection_does_not_return_with_its_text_selection() { | ... | @@ -2471,6 +2477,8 @@ fn the_page_selection_does_not_return_with_its_text_selection() { |
| 2471 | assert_eq!(view.editor.whole(), None); | 2477 | assert_eq!(view.editor.whole(), None); |
| 2472 | } | 2478 | } |
| 2473 | 2479 | ||
| 2480 | /// The point under the pointer stays put through a pinch, as in Safari, even where the | ||
| 2481 | /// page's content is too small to scroll that far; scrolling afterwards still reaches it. | ||
| 2474 | #[test] | 2482 | #[test] |
| 2475 | fn pinch_zooms_about_the_pointer_within_range() { | 2483 | fn pinch_zooms_about_the_pointer_within_range() { |
| 2476 | let mut engine = TextEngine::default(); | 2484 | let mut engine = TextEngine::default(); |
| ... | @@ -2488,17 +2496,28 @@ fn pinch_zooms_about_the_pointer_within_range() { | ... | @@ -2488,17 +2496,28 @@ fn pinch_zooms_about_the_pointer_within_range() { |
| 2488 | 1.0, | 2496 | 1.0, |
| 2489 | Duration::from_millis(500), | 2497 | Duration::from_millis(500), |
| 2490 | ); | 2498 | ); |
| 2491 | // The host's own scrolling leaves the anchor unclamped, as iOS's does. | 2499 | let pointer = [700.0, 500.0]; |
| 2492 | view.host_viewport = true; | ||
| 2493 | let pointer = [300.0, 200.0]; | ||
| 2494 | let _ = view.pointer_moved(pointer).unwrap(); | ||
| 2495 | let under = view.viewport.document_point(pointer); | 2500 | let under = view.viewport.document_point(pointer); |
| 2501 | let shown = view.viewport.document_point([800.0, 600.0]); | ||
| 2502 | let stays = |view: &PageView| { | ||
| 2503 | let after = view.viewport.document_point(pointer); | ||
| 2504 | assert!( | ||
| 2505 | (after[0] - under[0]).abs() < 1e-3 && (after[1] - under[1]).abs() < 1e-3, | ||
| 2506 | "{under:?} moved to {after:?}" | ||
| 2507 | ); | ||
| 2508 | }; | ||
| 2496 | let zoom = view.zoom(); | 2509 | let zoom = view.zoom(); |
| 2497 | assert!(view.pinch(1.5).unwrap().moved); | 2510 | for factor in [1.05, 1.0 / 1.05, 1.05] { |
| 2498 | assert!((view.zoom() / zoom - 1.5).abs() < 1e-5); | 2511 | for _ in 0..10 { |
| 2499 | let after = view.viewport.document_point(pointer); | 2512 | assert!(view.pinch(factor, pointer).unwrap().moved); |
| 2500 | assert!((after[0] - under[0]).abs() < 1e-3 && (after[1] - under[1]).abs() < 1e-3); | 2513 | stays(&view); |
| 2501 | let _ = view.pinch(100.0).unwrap(); | 2514 | } |
| 2515 | } | ||
| 2516 | assert!((view.zoom() / zoom - 1.05_f32.powi(10)).abs() < 1e-4); | ||
| 2517 | let _ = view.wheel([-5000.0; 2]).unwrap(); | ||
| 2518 | let corner = view.viewport.document_point([800.0, 600.0]); | ||
| 2519 | assert!((corner[0] - shown[0]).abs() < 1e-3 && (corner[1] - shown[1]).abs() < 1e-3); | ||
| 2520 | let _ = view.pinch(100.0, pointer).unwrap(); | ||
| 2502 | assert!((view.zoom() - 4.0).abs() < 1e-5); | 2521 | assert!((view.zoom() - 4.0).abs() < 1e-5); |
| 2503 | } | 2522 | } |
| 2504 | 2523 |
crates/canvas/src/layout.rs+3| ... | @@ -61,6 +61,8 @@ pub struct LineBox { | ... | @@ -61,6 +61,8 @@ pub struct LineBox { |
| 61 | pub struct TextLayout { | 61 | pub struct TextLayout { |
| 62 | id: u64, | 62 | id: u64, |
| 63 | pub(crate) shaped: Arc<Layout<TextBrush>>, | 63 | pub(crate) shaped: Arc<Layout<TextBrush>>, |
| 64 | /// The paragraph text laid out, which a PDF's text layer maps glyphs back to. | ||
| 65 | pub(crate) text: Arc<str>, | ||
| 64 | lines: Vec<LineBox>, | 66 | lines: Vec<LineBox>, |
| 65 | /// Where each inline space lies: its index in the spaces laid out, its x, and its line. | 67 | /// Where each inline space lies: its index in the spaces laid out, its x, and its line. |
| 66 | spaces: Vec<(usize, f32, usize)>, | 68 | spaces: Vec<(usize, f32, usize)>, |
| ... | @@ -518,6 +520,7 @@ impl TextEngine { | ... | @@ -518,6 +520,7 @@ impl TextEngine { |
| 518 | Ok(TextLayout { | 520 | Ok(TextLayout { |
| 519 | id, | 521 | id, |
| 520 | shaped: Arc::new(shaped), | 522 | shaped: Arc::new(shaped), |
| 523 | text: text.into(), | ||
| 521 | lines, | 524 | lines, |
| 522 | spaces: placed, | 525 | spaces: placed, |
| 523 | }) | 526 | }) |
crates/canvas/src/lib.rs+2| ... | @@ -14,6 +14,8 @@ pub mod date; | ... | @@ -14,6 +14,8 @@ pub mod date; |
| 14 | pub mod gpu; | 14 | pub mod gpu; |
| 15 | #[cfg(feature = "interaction")] | 15 | #[cfg(feature = "interaction")] |
| 16 | pub mod interaction; | 16 | pub mod interaction; |
| 17 | #[cfg(feature = "pdf")] | ||
| 18 | pub mod print; | ||
| 17 | 19 | ||
| 18 | /// Parley's caret affinity mapped onto the page model's hidden-field affinity. | 20 | /// Parley's caret affinity mapped onto the page model's hidden-field affinity. |
| 19 | pub fn affinity(affinity: parley::Affinity) -> onestore::page::text::Affinity { | 21 | pub 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 | |||
| 11 | use crate::gpu::{Paper, page::PageScene}; | ||
| 12 | use crate::layout::{LayoutError, TextEngine}; | ||
| 13 | use draw::{Layer, Primitive, RenderError, Sheet}; | ||
| 14 | use onestore::page::{Page, text::Paragraph}; | ||
| 15 | use std::fmt; | ||
| 16 | |||
| 17 | pub const LETTER: [f32; 2] = [612.0, 792.0]; | ||
| 18 | pub const A4: [f32; 2] = [595.276, 841.89]; | ||
| 19 | |||
| 20 | /// The paper's top and bottom margins, where no page content prints. | ||
| 21 | const MARGIN: f32 = 36.0; | ||
| 22 | /// How far from the paper's left edge the margin origin prints at full size. | ||
| 23 | const LEFT: f32 = 72.0; | ||
| 24 | /// The room OneNote leaves right of the content when it shrinks a page to the paper's width. | ||
| 25 | const RIGHT: f32 = 9.0; | ||
| 26 | /// Pixels per point pictures are decoded at, 300 a inch. | ||
| 27 | const DENSITY: f32 = 300.0 / 72.0; | ||
| 28 | |||
| 29 | /// How a page lies on its sheets. | ||
| 30 | struct 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 | |||
| 41 | impl 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)] | ||
| 98 | pub enum PrintError { | ||
| 99 | Scene(crate::gpu::page::SceneError), | ||
| 100 | Layout(LayoutError), | ||
| 101 | Render(RenderError), | ||
| 102 | } | ||
| 103 | |||
| 104 | impl 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 | |||
| 114 | impl 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`. | ||
| 118 | pub 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)] | ||
| 234 | mod tests { | ||
| 235 | use super::*; | ||
| 236 | use onestore::{RevisionIndex, Store, document::Document}; | ||
| 237 | use std::collections::HashMap; | ||
| 238 | |||
| 239 | fn section(path: &str) -> Vec<Page> { | ||
| 240 | let bytes = std::fs::read(format!("{}/../../{path}", env!("CARGO_MANIFEST_DIR"))).unwrap(); | ||
| 241 | let store = Store::parse(&bytes).unwrap(); | ||
| 242 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 243 | let document = Document::parse(&index).unwrap(); | ||
| 244 | let mut spaces: Vec<_> = document | ||
| 245 | .pages() | ||
| 246 | .unwrap() | ||
| 247 | .into_iter() | ||
| 248 | .map(|(space, _)| space) | ||
| 249 | .collect(); | ||
| 250 | spaces.dedup(); | ||
| 251 | spaces | ||
| 252 | .into_iter() | ||
| 253 | .map(|space| Page::from_space(&document, space).unwrap()) | ||
| 254 | .collect() | ||
| 255 | } | ||
| 256 | |||
| 257 | /// Each sheet's size and text, as a reader extracts it through the fonts' ToUnicode maps. | ||
| 258 | fn sheets(pdf: &[u8]) -> Vec<([f32; 2], String)> { | ||
| 259 | let find = |haystack: &[u8], needle: &[u8]| { | ||
| 260 | haystack | ||
| 261 | .windows(needle.len()) | ||
| 262 | .position(|window| window == needle) | ||
| 263 | }; | ||
| 264 | let mut objects: HashMap<u32, (&[u8], Vec<u8>)> = HashMap::new(); | ||
| 265 | let mut rest = pdf; | ||
| 266 | while let Some(start) = find(rest, b" 0 obj\n") { | ||
| 267 | let number = rest[..start] | ||
| 268 | .rsplit(|byte| !byte.is_ascii_digit()) | ||
| 269 | .next() | ||
| 270 | .unwrap(); | ||
| 271 | let number: u32 = std::str::from_utf8(number).unwrap().parse().unwrap(); | ||
| 272 | rest = &rest[start + 7..]; | ||
| 273 | let end = find(rest, b"endobj").unwrap(); | ||
| 274 | let object = &rest[..end]; | ||
| 275 | let (dictionary, stream) = match find(object, b"stream\n") { | ||
| 276 | Some(at) => { | ||
| 277 | let data = &object[at + 7..find(object, b"\nendstream").unwrap()]; | ||
| 278 | ( | ||
| 279 | &object[..at], | ||
| 280 | miniz_oxide::inflate::decompress_to_vec_zlib(data).unwrap_or_default(), | ||
| 281 | ) | ||
| 282 | } | ||
| 283 | None => (object, Vec::new()), | ||
| 284 | }; | ||
| 285 | objects.insert(number, (dictionary, stream)); | ||
| 286 | rest = &rest[end..]; | ||
| 287 | } | ||
| 288 | let text = |bytes: &[u8]| String::from_utf8_lossy(bytes).into_owned(); | ||
| 289 | let reference = |dictionary: &[u8], key: &str| -> Option<u32> { | ||
| 290 | let dictionary = text(dictionary); | ||
| 291 | let at = dictionary.find(&format!("{key} "))? + key.len() + 1; | ||
| 292 | dictionary[at..].split(' ').next()?.parse().ok() | ||
| 293 | }; | ||
| 294 | let tree = objects | ||
| 295 | .values() | ||
| 296 | .find(|(dictionary, _)| text(dictionary).contains("/Type /Pages")) | ||
| 297 | .unwrap(); | ||
| 298 | let kids = text(tree.0); | ||
| 299 | let kids = &kids[kids.find("/Kids [").unwrap() + 7..]; | ||
| 300 | let kids: Vec<u32> = kids[..kids.find(']').unwrap()] | ||
| 301 | .split(" 0 R") | ||
| 302 | .filter_map(|kid| kid.trim().parse().ok()) | ||
| 303 | .collect(); | ||
| 304 | kids.into_iter() | ||
| 305 | .map(|page| { | ||
| 306 | let dictionary = text(objects[&page].0); | ||
| 307 | let media: Vec<f32> = dictionary[dictionary.find("/MediaBox [").unwrap() + 11..] | ||
| 308 | .split(']') | ||
| 309 | .next() | ||
| 310 | .unwrap() | ||
| 311 | .split_whitespace() | ||
| 312 | .map(|value| value.parse().unwrap()) | ||
| 313 | .collect(); | ||
| 314 | let mut maps: HashMap<String, HashMap<u16, String>> = HashMap::new(); | ||
| 315 | for (at, _) in dictionary.match_indices("/F") { | ||
| 316 | let mut words = dictionary[at + 1..].split_whitespace(); | ||
| 317 | let (Some(name), Some(font)) = ( | ||
| 318 | words.next(), | ||
| 319 | words.next().and_then(|id| id.parse::<u32>().ok()), | ||
| 320 | ) else { | ||
| 321 | continue; | ||
| 322 | }; | ||
| 323 | let Some(unicode) = reference(objects[&font].0, "/ToUnicode") else { | ||
| 324 | continue; | ||
| 325 | }; | ||
| 326 | let cmap = text(&objects[&unicode].1); | ||
| 327 | let mut map = HashMap::new(); | ||
| 328 | for line in cmap.lines().filter(|line| line.starts_with('<')) { | ||
| 329 | let hex: Vec<&str> = line | ||
| 330 | .split(['<', '>', ' ']) | ||
| 331 | .filter(|part| !part.is_empty()) | ||
| 332 | .collect(); | ||
| 333 | let units: Vec<u16> = hex[1] | ||
| 334 | .as_bytes() | ||
| 335 | .chunks(4) | ||
| 336 | .map(|unit| { | ||
| 337 | u16::from_str_radix(std::str::from_utf8(unit).unwrap(), 16).unwrap() | ||
| 338 | }) | ||
| 339 | .collect(); | ||
| 340 | map.insert( | ||
| 341 | u16::from_str_radix(hex[0], 16).unwrap(), | ||
| 342 | String::from_utf16_lossy(&units), | ||
| 343 | ); | ||
| 344 | } | ||
| 345 | maps.insert(name.to_owned(), map); | ||
| 346 | } | ||
| 347 | let content = &objects[&reference(objects[&page].0, "/Contents").unwrap()].1; | ||
| 348 | let (mut shown, mut font, mut name, mut at) = | ||
| 349 | (String::new(), String::new(), String::new(), 0); | ||
| 350 | while at < content.len() { | ||
| 351 | let byte = content[at]; | ||
| 352 | at += 1; | ||
| 353 | let mut string = Vec::new(); | ||
| 354 | match byte { | ||
| 355 | b'(' => { | ||
| 356 | while content[at] != b')' { | ||
| 357 | if content[at] == b'\\' { | ||
| 358 | at += 1; | ||
| 359 | if content[at].is_ascii_digit() { | ||
| 360 | let octal = | ||
| 361 | std::str::from_utf8(&content[at..at + 3]).unwrap(); | ||
| 362 | string.push(u8::from_str_radix(octal, 8).unwrap()); | ||
| 363 | at += 3; | ||
| 364 | continue; | ||
| 365 | } | ||
| 366 | string.push(match content[at] { | ||
| 367 | b'n' => b'\n', | ||
| 368 | b'r' => b'\r', | ||
| 369 | b't' => b'\t', | ||
| 370 | b'b' => 8, | ||
| 371 | b'f' => 12, | ||
| 372 | other => other, | ||
| 373 | }); | ||
| 374 | } else { | ||
| 375 | string.push(content[at]); | ||
| 376 | } | ||
| 377 | at += 1; | ||
| 378 | } | ||
| 379 | at += 1; | ||
| 380 | } | ||
| 381 | b'<' if content[at] != b'<' => { | ||
| 382 | let end = | ||
| 383 | at + content[at..].iter().position(|byte| *byte == b'>').unwrap(); | ||
| 384 | string = content[at..end] | ||
| 385 | .chunks(2) | ||
| 386 | .map(|pair| { | ||
| 387 | u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16) | ||
| 388 | .unwrap() | ||
| 389 | }) | ||
| 390 | .collect(); | ||
| 391 | at = end + 1; | ||
| 392 | } | ||
| 393 | b'/' => { | ||
| 394 | let end = at | ||
| 395 | + content[at..] | ||
| 396 | .iter() | ||
| 397 | .position(|byte| byte.is_ascii_whitespace()) | ||
| 398 | .unwrap(); | ||
| 399 | name = text(&content[at..end]); | ||
| 400 | at = end; | ||
| 401 | } | ||
| 402 | b'T' if content.get(at) == Some(&b'f') => font = name.clone(), | ||
| 403 | b'T' if content.get(at) == Some(&b'm') => shown.push('\n'), | ||
| 404 | _ => {} | ||
| 405 | } | ||
| 406 | if let Some(map) = maps.get(&font) { | ||
| 407 | for cid in string.chunks(2).filter(|pair| pair.len() == 2) { | ||
| 408 | if let Some(text) = map.get(&u16::from_be_bytes([cid[0], cid[1]])) { | ||
| 409 | shown.push_str(text); | ||
| 410 | } | ||
| 411 | } | ||
| 412 | } | ||
| 413 | } | ||
| 414 | ([media[2], media[3]], shown) | ||
| 415 | }) | ||
| 416 | .collect() | ||
| 417 | } | ||
| 418 | |||
| 419 | /// A ligature's glyph reads back as the letters it joins. | ||
| 420 | #[test] | ||
| 421 | fn glyphs_read_back_as_their_text() { | ||
| 422 | let mut engine = TextEngine::default(); | ||
| 423 | let format = onestore::document::Format { | ||
| 424 | font: Some("Calibri".into()), | ||
| 425 | font_size: Some(11.0), | ||
| 426 | ..Default::default() | ||
| 427 | }; | ||
| 428 | let layout = engine | ||
| 429 | .layout(&Paragraph::new("office fish ffi".into(), format), f32::MAX) | ||
| 430 | .unwrap(); | ||
| 431 | let primitives = [Primitive::Text { | ||
| 432 | text: &layout, | ||
| 433 | origin: [72.0, 72.0], | ||
| 434 | clip: None, | ||
| 435 | ink: Paper::WHITE.ink, | ||
| 436 | }]; | ||
| 437 | let sheet = Sheet { | ||
| 438 | size: LETTER, | ||
| 439 | layers: vec![Layer { | ||
| 440 | scale: 1.0, | ||
| 441 | origin: [0.0; 2], | ||
| 442 | clip: None, | ||
| 443 | backdrop: None, | ||
| 444 | round: None, | ||
| 445 | motion: None, | ||
| 446 | primitives: &primitives, | ||
| 447 | }], | ||
| 448 | }; | ||
| 449 | let pdf = draw::pdf("", &[sheet]).unwrap(); | ||
| 450 | assert_eq!(sheets(&pdf)[0].1.trim(), "office fish ffi"); | ||
| 451 | } | ||
| 452 | |||
| 453 | #[test] | ||
| 454 | fn sheets_break_between_lines() { | ||
| 455 | // Lines 20 points tall from 90 down; a Letter sheet holds 720 points of page. | ||
| 456 | let rows: Vec<[f32; 2]> = (0..60) | ||
| 457 | .map(|line| [90.0 + line as f32 * 20.0, 110.0 + line as f32 * 20.0]) | ||
| 458 | .collect(); | ||
| 459 | let pagination = Pagination::new([36.0, 14.4, 400.0, 1290.0], &rows, [36.0, 14.4], LETTER); | ||
| 460 | assert_eq!(pagination.scale, 1.0); | ||
| 461 | // 14.4 + 720 cuts the line from 730 to 750, which starts the next sheet. | ||
| 462 | assert_eq!(pagination.tops, [14.4, 730.0]); | ||
| 463 | let sheets: Vec<_> = pagination.sheets().collect(); | ||
| 464 | assert_eq!(sheets, [[14.4, 730.0], [730.0, 1450.0]]); | ||
| 465 | } | ||
| 466 | |||
| 467 | #[test] | ||
| 468 | fn a_line_taller_than_a_sheet_is_cut() { | ||
| 469 | let pagination = Pagination::new( | ||
| 470 | [36.0, 14.4, 400.0, 1000.0], | ||
| 471 | &[[20.0, 1000.0]], | ||
| 472 | [36.0, 14.4], | ||
| 473 | LETTER, | ||
| 474 | ); | ||
| 475 | assert_eq!(pagination.tops, [14.4, 734.4]); | ||
| 476 | } | ||
| 477 | |||
| 478 | /// OneNote's scales for pages whose content ends 866.7, 581.8 and 1221.4 points across, | ||
| 479 | /// on its 612.36-point Letter sheet (`corpus/print`). | ||
| 480 | #[test] | ||
| 481 | fn wide_pages_shrink_to_the_paper_as_onenote_shrinks_them() { | ||
| 482 | for (right, onenote) in [ | ||
| 483 | (866.7, 0.67167), | ||
| 484 | (581.8, 0.978), | ||
| 485 | (1221.4, 0.48361), | ||
| 486 | (400.0, 1.0), | ||
| 487 | ] { | ||
| 488 | let scale = Pagination::new( | ||
| 489 | [36.0, 14.4, right, 100.0], | ||
| 490 | &[], | ||
| 491 | [36.0, 14.4], | ||
| 492 | [612.36, 790.92], | ||
| 493 | ) | ||
| 494 | .scale; | ||
| 495 | assert!( | ||
| 496 | (scale - onenote).abs() < 0.002, | ||
| 497 | "{right}: {scale} for {onenote}" | ||
| 498 | ); | ||
| 499 | } | ||
| 500 | } | ||
| 501 | |||
| 502 | /// `corpus/print`: the section OneNote 2010 saved as `native/section.pdf`, on the | ||
| 503 | /// same sheets with the same text. | ||
| 504 | #[test] | ||
| 505 | fn a_section_prints_on_onenotes_sheets() { | ||
| 506 | let pdf = pdf( | ||
| 507 | section("corpus/print/native/Print.one"), | ||
| 508 | &mut TextEngine::default(), | ||
| 509 | LETTER, | ||
| 510 | "Print", | ||
| 511 | ) | ||
| 512 | .unwrap(); | ||
| 513 | let sheets = sheets(&pdf); | ||
| 514 | assert_eq!(sheets.len(), 5); | ||
| 515 | for (at, (size, text)) in sheets.iter().enumerate() { | ||
| 516 | assert_eq!(*size, LETTER); | ||
| 517 | assert!(text.contains(&format!("Print Page {}", at + 1)), "{text}"); | ||
| 518 | } | ||
| 519 | let [first, second] = [&sheets[0].1, &sheets[1].1]; | ||
| 520 | assert!(first.contains("Printing test")); | ||
| 521 | assert!( | ||
| 522 | first.contains( | ||
| 523 | "Line 37 of the long outline, with enough words to show wrapping where the" | ||
| 524 | ) | ||
| 525 | ); | ||
| 526 | assert!(first.contains("outline is narrow. Searchable-37")); | ||
| 527 | assert!(first.contains("Far right outline past the paper width")); | ||
| 528 | assert!(!first.contains("Searchable-38")); | ||
| 529 | assert!(second.contains("Line 38 of the long outline")); | ||
| 530 | assert!(second.contains("Searchable-70")); | ||
| 531 | assert!(!second.contains("Searchable-37\n")); | ||
| 532 | assert!(sheets[2].1.contains("Short second page")); | ||
| 533 | } | ||
| 534 | |||
| 535 | /// `corpus/page-background`'s pages, ruled and with template art, take the twelve | ||
| 536 | /// sheets OneNote 2010 prints them on (`corpus/print/native/rules-section.pdf`), the | ||
| 537 | /// art reaching past the Very Large Grid page's first. | ||
| 538 | #[test] | ||
| 539 | fn ruled_pages_and_art_print_on_onenotes_sheets() { | ||
| 540 | let pdf = pdf( | ||
| 541 | section("corpus/page-background/candidate/Rules.one"), | ||
| 542 | &mut TextEngine::default(), | ||
| 543 | LETTER, | ||
| 544 | "Rules", | ||
| 545 | ) | ||
| 546 | .unwrap(); | ||
| 547 | let sheets = sheets(&pdf); | ||
| 548 | assert_eq!(sheets.len(), 12); | ||
| 549 | assert!(sheets[8].1.contains("VeryLargeGrid")); | ||
| 550 | assert!(sheets[9].1.contains("Rules Page 10")); | ||
| 551 | assert!(!sheets[9].1.contains("VeryLargeGrid")); | ||
| 552 | } | ||
| 553 | } | ||
crates/canvas/tests/links_equations.rs+187| ... | @@ -797,3 +797,190 @@ fn equations_draw_inline_and_url_text_shows_as_a_link() { | ... | @@ -797,3 +797,190 @@ fn equations_draw_inline_and_url_text_shows_as_a_link() { |
| 797 | assert_eq!(canvas::editor::shown_urls(&url), vec![5..22]); | 797 | assert_eq!(canvas::editor::shown_urls(&url), vec![5..22]); |
| 798 | assert!(url.spans()[0].format.hyperlink.is_none()); | 798 | assert!(url.spans()[0].format.hyperlink.is_none()); |
| 799 | } | 799 | } |
| 800 | |||
| 801 | /// Selections reaching into equation objects, and Enter in a placeholder and in a limit, act | ||
| 802 | /// as OneNote 2010's equation editor acted on the same page (`corpus/equation-select`): an | ||
| 803 | /// end inside an object the other end is outside takes the whole object, and across | ||
| 804 | /// paragraphs the end of the paragraph with it; a placeholder is taken whole; Enter inside a | ||
| 805 | /// limit makes its argument an equation array. Each action saves as one revision. | ||
| 806 | #[test] | ||
| 807 | fn 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 | |||
| 7 | use canvas::{ | ||
| 8 | document::TextPosition, | ||
| 9 | editor::{CanvasEditor, Selection}, | ||
| 10 | layout::TextEngine, | ||
| 11 | }; | ||
| 12 | use onestore::{ | ||
| 13 | Arena, Section, Store, | ||
| 14 | document::Layout, | ||
| 15 | op::{Edit, Op}, | ||
| 16 | page::{Image, PageObject, ParagraphContent}, | ||
| 17 | }; | ||
| 18 | use parley::Affinity; | ||
| 19 | |||
| 20 | fn 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 | |||
| 41 | fn 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. | ||
| 50 | fn 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 | |||
| 71 | fn 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] | ||
| 85 | fn pasted_pictures_split_the_text_or_lie_on_the_page_as_onenote_places_them() { | ||
| 86 | let source = onestore::create_section("pictures.one", "Pasted pictures", "Author").unwrap(); | ||
| 87 | let arena = Arena::default(); | ||
| 88 | let mut section = Section::open(&arena, source.clone()).unwrap(); | ||
| 89 | let (space, ..) = section.pages().unwrap()[0].clone(); | ||
| 90 | let mut engine = TextEngine::default(); | ||
| 91 | let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap(); | ||
| 92 | let mut at = 133_000_000_000_000_000; | ||
| 93 | let mut store = |editor: &mut CanvasEditor| { | ||
| 94 | let ops = editor.take_ops().unwrap(); | ||
| 95 | assert!(!ops.is_empty()); | ||
| 96 | at += 10_000_000; | ||
| 97 | let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect(); | ||
| 98 | section.apply("Author", &Edit { at, ops }).unwrap(); | ||
| 99 | }; | ||
| 100 | let text = editor | ||
| 101 | .outlines() | ||
| 102 | .iter() | ||
| 103 | .find(|outline| !outline.title) | ||
| 104 | .unwrap() | ||
| 105 | .id; | ||
| 106 | editor.focus_outline(text).unwrap(); | ||
| 107 | |||
| 108 | // Within text, the caret starting the second half. | ||
| 109 | editor.select(caret(0, 6)).unwrap(); | ||
| 110 | editor | ||
| 111 | .insert_picture(&mut engine, picture([150.0, 75.0])) | ||
| 112 | .unwrap(); | ||
| 113 | store(&mut editor); | ||
| 114 | assert_eq!(body(&editor), ["Pasted", "[150.0, 75.0]", " pictures"]); | ||
| 115 | assert_eq!( | ||
| 116 | editor.selection().positions, | ||
| 117 | [TextPosition { | ||
| 118 | paragraph: 1, | ||
| 119 | offset: 0 | ||
| 120 | }; 2] | ||
| 121 | ); | ||
| 122 | editor.undo(&mut engine).unwrap(); | ||
| 123 | store(&mut editor); | ||
| 124 | assert_eq!(body(&editor), ["Pasted pictures"]); | ||
| 125 | |||
| 126 | // At the end, an empty paragraph follows for the caret; at an empty paragraph's start | ||
| 127 | // the picture takes its place. | ||
| 128 | editor.select(caret(0, 15)).unwrap(); | ||
| 129 | editor | ||
| 130 | .insert_picture(&mut engine, picture([150.0, 75.0])) | ||
| 131 | .unwrap(); | ||
| 132 | store(&mut editor); | ||
| 133 | editor | ||
| 134 | .insert_picture(&mut engine, picture([200.0, 100.0])) | ||
| 135 | .unwrap(); | ||
| 136 | store(&mut editor); | ||
| 137 | assert_eq!( | ||
| 138 | body(&editor), | ||
| 139 | ["Pasted pictures", "[150.0, 75.0]", "[200.0, 100.0]", ""] | ||
| 140 | ); | ||
| 141 | |||
| 142 | // On blank page: at the caret, the caret moving to the grid row below (OneNote put a | ||
| 143 | // 75 pt picture at y 266.4 and the caret at 356.4, a 72 pt one at 248.4 and 338.4). | ||
| 144 | for (y, height, below) in [(266.4, 75.0, 356.4), (248.4 + 180.0, 72.0, 338.4 + 180.0)] { | ||
| 145 | editor.place_caret(&mut engine, [270.0, y], 468.0).unwrap(); | ||
| 146 | editor | ||
| 147 | .insert_picture(&mut engine, picture([150.0, height])) | ||
| 148 | .unwrap(); | ||
| 149 | store(&mut editor); | ||
| 150 | let placed = page_pictures(&editor).pop().unwrap(); | ||
| 151 | assert_eq!([placed.layout.x, placed.layout.y], [Some(270.0), Some(y)]); | ||
| 152 | assert_eq!(editor.active_outline().origin(), [270.0, below]); | ||
| 153 | } | ||
| 154 | editor.insert(&mut engine, "below").unwrap(); | ||
| 155 | store(&mut editor); | ||
| 156 | editor.undo(&mut engine).unwrap(); | ||
| 157 | editor.undo(&mut engine).unwrap(); | ||
| 158 | store(&mut editor); | ||
| 159 | assert_eq!(page_pictures(&editor).len(), 1); | ||
| 160 | |||
| 161 | let mut image = source; | ||
| 162 | section.seal().unwrap().unwrap().apply(&mut image).unwrap(); | ||
| 163 | let arena = Arena::default(); | ||
| 164 | let reopened = Section::open(&arena, image.clone()).unwrap(); | ||
| 165 | let page = reopened.page(space).unwrap(); | ||
| 166 | let stored: Vec<_> = page | ||
| 167 | .objects | ||
| 168 | .iter() | ||
| 169 | .filter_map(|object| match object { | ||
| 170 | PageObject::Image(image) => Some(image), | ||
| 171 | _ => None, | ||
| 172 | }) | ||
| 173 | .collect(); | ||
| 174 | assert_eq!(stored.len(), 1); | ||
| 175 | assert_eq!( | ||
| 176 | [stored[0].layout.x, stored[0].layout.y], | ||
| 177 | [Some(270.0), Some(266.4)] | ||
| 178 | ); | ||
| 179 | assert_eq!(*stored[0], page_pictures(&editor)[0]); | ||
| 180 | |||
| 181 | if let Some(directory) = std::env::var_os("CANVAS_PICTURE_EXPORT") { | ||
| 182 | let directory = std::path::PathBuf::from(directory); | ||
| 183 | std::fs::create_dir_all(&directory).unwrap(); | ||
| 184 | std::fs::write(directory.join("pictures.one"), &image).unwrap(); | ||
| 185 | let file_id = Store::parse(&image).unwrap().header.file_id; | ||
| 186 | std::fs::write( | ||
| 187 | directory.join("Open Notebook.onetoc2"), | ||
| 188 | onestore::create_table_of_contents( | ||
| 189 | "Open Notebook.onetoc2", | ||
| 190 | &[("pictures.one", file_id)], | ||
| 191 | ) | ||
| 192 | .unwrap(), | ||
| 193 | ) | ||
| 194 | .unwrap(); | ||
| 195 | } | ||
| 196 | } | ||
crates/draw/Cargo.toml+5| ... | @@ -11,6 +11,8 @@ default = ["wgpu"] | ... | @@ -11,6 +11,8 @@ default = ["wgpu"] |
| 11 | # Windows 7 has no Direct3D 12. | 11 | # Windows 7 has no Direct3D 12. |
| 12 | render = ["dep:base64", "dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:swash"] | 12 | render = ["dep:base64", "dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:swash"] |
| 13 | wgpu = ["render", "dep:wgpu"] | 13 | wgpu = ["render", "dep:wgpu"] |
| 14 | # Writes layers as PDF pages, text as real text in subset fonts. | ||
| 15 | pdf = ["render", "dep:miniz_oxide", "dep:pdf-writer", "dep:subsetter"] | ||
| 14 | 16 | ||
| 15 | [dependencies] | 17 | [dependencies] |
| 16 | parley.workspace = true | 18 | parley.workspace = true |
| ... | @@ -19,7 +21,10 @@ base64 = { version = "0.22.1", optional = true } | ... | @@ -19,7 +21,10 @@ base64 = { version = "0.22.1", optional = true } |
| 19 | bytemuck = { version = "1.25", features = ["derive"], optional = true } | 21 | bytemuck = { version = "1.25", features = ["derive"], optional = true } |
| 20 | image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png", "tiff"], optional = true } | 22 | image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png", "tiff"], optional = true } |
| 21 | linebender_resource_handle = { version = "0.1.1", optional = true } | 23 | linebender_resource_handle = { version = "0.1.1", optional = true } |
| 24 | miniz_oxide = { version = "0.8", optional = true } | ||
| 25 | pdf-writer = { version = "0.15", optional = true } | ||
| 22 | roxmltree = { version = "0.21", optional = true } | 26 | roxmltree = { version = "0.21", optional = true } |
| 27 | subsetter = { version = "0.2.6", default-features = false, optional = true } | ||
| 23 | swash = { version = "0.2.10", optional = true } | 28 | swash = { version = "0.2.10", optional = true } |
| 24 | wgpu = { workspace = true, optional = true } | 29 | wgpu = { workspace = true, optional = true } |
| 25 | 30 |
crates/draw/src/render.rs+5| ... | @@ -3,6 +3,8 @@ mod dual; | ... | @@ -3,6 +3,8 @@ mod dual; |
| 3 | #[cfg(any(windows, not(feature = "wgpu")))] | 3 | #[cfg(any(windows, not(feature = "wgpu")))] |
| 4 | mod gl; | 4 | mod gl; |
| 5 | mod icon; | 5 | mod icon; |
| 6 | #[cfg(feature = "pdf")] | ||
| 7 | mod pdf; | ||
| 6 | mod text; | 8 | mod text; |
| 7 | #[cfg(feature = "wgpu")] | 9 | #[cfg(feature = "wgpu")] |
| 8 | mod translucent; | 10 | mod translucent; |
| ... | @@ -20,6 +22,8 @@ pub use backend::Target; | ... | @@ -20,6 +22,8 @@ pub use backend::Target; |
| 20 | #[cfg(all(feature = "wgpu", windows))] | 22 | #[cfg(all(feature = "wgpu", windows))] |
| 21 | pub use gl::Target as GlTarget; | 23 | pub use gl::Target as GlTarget; |
| 22 | pub use icon::{Palette, picture_icon}; | 24 | pub use icon::{Palette, picture_icon}; |
| 25 | #[cfg(feature = "pdf")] | ||
| 26 | pub use pdf::{Sheet, pdf}; | ||
| 23 | pub use text::{Decoration, Glyph, GlyphRun, Glyphs, paint_parley_run}; | 27 | pub use text::{Decoration, Glyph, GlyphRun, Glyphs, paint_parley_run}; |
| 24 | #[cfg(feature = "wgpu")] | 28 | #[cfg(feature = "wgpu")] |
| 25 | pub use translucent::Translucent; | 29 | pub use translucent::Translucent; |
| ... | @@ -1736,6 +1740,7 @@ mod tests { | ... | @@ -1736,6 +1740,7 @@ mod tests { |
| 1736 | if let PositionedLayoutItem::GlyphRun(run) = item { | 1740 | if let PositionedLayoutItem::GlyphRun(run) = item { |
| 1737 | paint_parley_run( | 1741 | paint_parley_run( |
| 1738 | &run, | 1742 | &run, |
| 1743 | "", | ||
| 1739 | metrics.baseline, | 1744 | metrics.baseline, |
| 1740 | 0.0, | 1745 | 0.0, |
| 1741 | [metrics.block_min_coord, metrics.line_height], | 1746 | [metrics.block_min_coord, metrics.line_height], |
crates/draw/src/render/pdf.rs created+1004| ... | @@ -0,0 +1,1004 @@ | ||
| 1 | //! Layers written as the sheets of a PDF: glyphs as text in subset fonts, which viewers | ||
| 2 | //! select, search and copy, shapes and ink as vector paths, pictures and icons as images. | ||
| 3 | |||
| 4 | use super::{ | ||
| 5 | GlyphRun, Layer, PathStyle, Primitive, RasterImage, RenderError, Stroke, icon, srgb_byte, | ||
| 6 | }; | ||
| 7 | use parley::fontique::Blob; | ||
| 8 | use 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 | }; | ||
| 15 | use std::collections::{BTreeMap, HashMap, btree_map::Entry}; | ||
| 16 | use 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. | ||
| 24 | pub 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`. | ||
| 32 | pub 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 | |||
| 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 | |||
| 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 | |||
| 95 | struct 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)] | ||
| 110 | enum 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. | ||
| 128 | struct 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. | ||
| 140 | struct Used { | ||
| 141 | content: Ref, | ||
| 142 | fonts: Vec<usize>, | ||
| 143 | images: Vec<Ref>, | ||
| 144 | states: Vec<Ref>, | ||
| 145 | } | ||
| 146 | |||
| 147 | impl 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 | |||
| 155 | fn compress(data: &[u8]) -> Vec<u8> { | ||
| 156 | miniz_oxide::deflate::compress_to_vec_zlib(data, 6) | ||
| 157 | } | ||
| 158 | |||
| 159 | /// sRGB components of a linear colour. | ||
| 160 | fn 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. | ||
| 165 | const 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. | ||
| 169 | fn 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 | |||
| 203 | fn 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. | ||
| 213 | fn 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 | |||
| 236 | impl Document { | ||
| 237 | fn reserve(&mut self) -> Ref { | ||
| 238 | let reference = Ref::new(self.next); | ||
| 239 | self.next += 1; | ||
| 240 | reference | ||
| 241 | } | ||
| 242 | |||
| 243 | fn sheet(&mut self, sheet: &Sheet<'_>, content_ref: Ref) -> Result<Used, RenderError> { | ||
| 244 | let mut used = Used { | ||
| 245 | content: content_ref, | ||
| 246 | fonts: Vec::new(), | ||
| 247 | images: Vec::new(), | ||
| 248 | states: Vec::new(), | ||
| 249 | }; | ||
| 250 | let mut content = Content::new(); | ||
| 251 | // Layers measure down from the top left, as the renderer's do. | ||
| 252 | content.transform([1.0, 0.0, 0.0, -1.0, 0.0, sheet.size[1]]); | ||
| 253 | for layer in &sheet.layers { | ||
| 254 | content.save_state(); | ||
| 255 | if let Some([left, top, right, bottom]) = layer.clip { | ||
| 256 | content | ||
| 257 | .rect(left, top, right - left, bottom - top) | ||
| 258 | .clip_nonzero() | ||
| 259 | .end_path(); | ||
| 260 | } | ||
| 261 | self.rows = layer | ||
| 262 | .clip | ||
| 263 | .map(|clip| [clip[1], clip[3]].map(|y| (y - layer.origin[1]) / layer.scale)); | ||
| 264 | content.transform([ | ||
| 265 | layer.scale, | ||
| 266 | 0.0, | ||
| 267 | 0.0, | ||
| 268 | layer.scale, | ||
| 269 | layer.origin[0], | ||
| 270 | layer.origin[1], | ||
| 271 | ]); | ||
| 272 | for primitive in layer.primitives { | ||
| 273 | self.primitive(&mut content, &mut used, primitive)?; | ||
| 274 | } | ||
| 275 | content.restore_state(); | ||
| 276 | } | ||
| 277 | let data = compress(&content.finish()); | ||
| 278 | self.pdf | ||
| 279 | .stream(content_ref, &data) | ||
| 280 | .filter(Filter::FlateDecode); | ||
| 281 | Ok(used) | ||
| 282 | } | ||
| 283 | |||
| 284 | /// Sets the fill, or with `stroke` the stroke, to `color` with its opacity. | ||
| 285 | fn paint(&mut self, content: &mut Content, used: &mut Used, color: [f32; 4], stroke: bool) { | ||
| 286 | let [red, green, blue] = encoded(color); | ||
| 287 | if stroke { | ||
| 288 | content.set_stroke_rgb(red, green, blue); | ||
| 289 | } else { | ||
| 290 | content.set_fill_rgb(red, green, blue); | ||
| 291 | } | ||
| 292 | if color[3] < 1.0 { | ||
| 293 | let state = self.state(color[3], false); | ||
| 294 | Used::add(&mut used.states, state); | ||
| 295 | content.set_parameters(Name(format!("G{}", state.get()).as_bytes())); | ||
| 296 | } | ||
| 297 | } | ||
| 298 | |||
| 299 | fn state(&mut self, opacity: f32, multiply: bool) -> Ref { | ||
| 300 | let key = (opacity.clamp(0.0, 1.0).to_bits(), multiply); | ||
| 301 | if let Some(state) = self.states.get(&key) { | ||
| 302 | return *state; | ||
| 303 | } | ||
| 304 | let state = self.reserve(); | ||
| 305 | let mut writer = self.pdf.ext_graphics(state); | ||
| 306 | writer | ||
| 307 | .non_stroking_alpha(opacity.clamp(0.0, 1.0)) | ||
| 308 | .stroking_alpha(opacity.clamp(0.0, 1.0)); | ||
| 309 | if multiply { | ||
| 310 | writer.blend_mode(BlendMode::Multiply); | ||
| 311 | } | ||
| 312 | writer.finish(); | ||
| 313 | self.states.insert(key, state); | ||
| 314 | state | ||
| 315 | } | ||
| 316 | |||
| 317 | fn primitive( | ||
| 318 | &mut self, | ||
| 319 | content: &mut Content, | ||
| 320 | used: &mut Used, | ||
| 321 | primitive: &Primitive<'_>, | ||
| 322 | ) -> Result<(), RenderError> { | ||
| 323 | match primitive { | ||
| 324 | Primitive::Text { | ||
| 325 | text, | ||
| 326 | origin, | ||
| 327 | clip, | ||
| 328 | ink, | ||
| 329 | } => { | ||
| 330 | content.save_state(); | ||
| 331 | if let Some([left, top, right, bottom]) = *clip { | ||
| 332 | content | ||
| 333 | .rect(left, top, right - left, bottom - top) | ||
| 334 | .clip_nonzero() | ||
| 335 | .end_path(); | ||
| 336 | } | ||
| 337 | text.runs(&mut |run| self.run(content, used, run, *origin, *ink))?; | ||
| 338 | content.restore_state(); | ||
| 339 | } | ||
| 340 | Primitive::Icon { | ||
| 341 | sources, | ||
| 342 | origin, | ||
| 343 | size, | ||
| 344 | tint, | ||
| 345 | palette, | ||
| 346 | } => { | ||
| 347 | // Icons are small art; eight pixels a point keeps them sharp on paper. | ||
| 348 | let side = (size * 8.0).ceil().clamp(16.0, 512.0) as u32; | ||
| 349 | let key = ImageKey::Icon { | ||
| 350 | sources: sources.as_ptr() as usize, | ||
| 351 | size: side, | ||
| 352 | tint: [tint[0], tint[1], tint[2]].map(f32::to_bits), | ||
| 353 | palette: palette.bits(), | ||
| 354 | }; | ||
| 355 | let image = match self.images.get(&key) { | ||
| 356 | Some(image) => *image, | ||
| 357 | None => { | ||
| 358 | let raster = | ||
| 359 | icon::rasterize(sources, side, [tint[0], tint[1], tint[2]], palette); | ||
| 360 | let image = self.image([side; 2], &raster.data, false); | ||
| 361 | self.images.insert(key, image); | ||
| 362 | image | ||
| 363 | } | ||
| 364 | }; | ||
| 365 | content.save_state(); | ||
| 366 | if tint[3] < 1.0 { | ||
| 367 | let state = self.state(tint[3], false); | ||
| 368 | Used::add(&mut used.states, state); | ||
| 369 | content.set_parameters(Name(format!("G{}", state.get()).as_bytes())); | ||
| 370 | } | ||
| 371 | self.place( | ||
| 372 | content, | ||
| 373 | used, | ||
| 374 | image, | ||
| 375 | [origin[0], origin[1], origin[0] + size, origin[1] + size], | ||
| 376 | ); | ||
| 377 | content.restore_state(); | ||
| 378 | } | ||
| 379 | Primitive::Path { | ||
| 380 | data, | ||
| 381 | origin, | ||
| 382 | style, | ||
| 383 | colors, | ||
| 384 | } => { | ||
| 385 | let fill = matches!(style, PathStyle::Fill); | ||
| 386 | let (PathStyle::Fill | PathStyle::Stroke(_)) = style else { | ||
| 387 | return Ok(()); | ||
| 388 | }; | ||
| 389 | content.save_state(); | ||
| 390 | content.transform([1.0, 0.0, 0.0, 1.0, origin[0], origin[1]]); | ||
| 391 | let average = std::array::from_fn(|at| (colors[0][at] + colors[1][at]) / 2.0); | ||
| 392 | self.paint(content, used, average, !fill); | ||
| 393 | let mut last = [0.0_f32; 2]; | ||
| 394 | for command in data.commands() { | ||
| 395 | match command { | ||
| 396 | Command::MoveTo(to) => { | ||
| 397 | content.move_to(to.x, to.y); | ||
| 398 | last = [to.x, to.y]; | ||
| 399 | } | ||
| 400 | Command::LineTo(to) => { | ||
| 401 | content.line_to(to.x, to.y); | ||
| 402 | last = [to.x, to.y]; | ||
| 403 | } | ||
| 404 | Command::CurveTo(one, two, to) => { | ||
| 405 | content.cubic_to(one.x, one.y, two.x, two.y, to.x, to.y); | ||
| 406 | last = [to.x, to.y]; | ||
| 407 | } | ||
| 408 | Command::QuadTo(control, to) => { | ||
| 409 | let near = | ||
| 410 | |from: f32, control: f32| from + (control - from) * 2.0 / 3.0; | ||
| 411 | content.cubic_to( | ||
| 412 | near(last[0], control.x), | ||
| 413 | near(last[1], control.y), | ||
| 414 | near(to.x, control.x), | ||
| 415 | near(to.y, control.y), | ||
| 416 | to.x, | ||
| 417 | to.y, | ||
| 418 | ); | ||
| 419 | last = [to.x, to.y]; | ||
| 420 | } | ||
| 421 | Command::Close => { | ||
| 422 | content.close_path(); | ||
| 423 | } | ||
| 424 | } | ||
| 425 | } | ||
| 426 | match style { | ||
| 427 | PathStyle::Stroke(width) => { | ||
| 428 | content | ||
| 429 | .set_line_width(*width) | ||
| 430 | .set_line_cap(LineCapStyle::RoundCap) | ||
| 431 | .set_line_join(LineJoinStyle::RoundJoin) | ||
| 432 | .stroke(); | ||
| 433 | } | ||
| 434 | _ => { | ||
| 435 | content.fill_nonzero(); | ||
| 436 | } | ||
| 437 | } | ||
| 438 | content.restore_state(); | ||
| 439 | } | ||
| 440 | Primitive::Rect { rect, color } => { | ||
| 441 | content.save_state(); | ||
| 442 | self.paint(content, used, *color, false); | ||
| 443 | content | ||
| 444 | .rect(rect[0], rect[1], rect[2] - rect[0], rect[3] - rect[1]) | ||
| 445 | .fill_nonzero(); | ||
| 446 | content.restore_state(); | ||
| 447 | } | ||
| 448 | Primitive::RoundedRect { | ||
| 449 | rect, | ||
| 450 | radius, | ||
| 451 | stroke, | ||
| 452 | color, | ||
| 453 | } => { | ||
| 454 | content.save_state(); | ||
| 455 | self.paint(content, used, *color, stroke.is_some()); | ||
| 456 | rounded(content, *rect, *radius); | ||
| 457 | match stroke { | ||
| 458 | None => { | ||
| 459 | content.fill_nonzero(); | ||
| 460 | } | ||
| 461 | Some(Stroke::Solid(width)) => { | ||
| 462 | content.set_line_width(*width).stroke(); | ||
| 463 | } | ||
| 464 | Some(Stroke::Dashed(width)) => { | ||
| 465 | content | ||
| 466 | .set_line_width(*width) | ||
| 467 | .set_dash_pattern([width * 2.0, width * 2.0], 0.0) | ||
| 468 | .stroke(); | ||
| 469 | } | ||
| 470 | } | ||
| 471 | content.restore_state(); | ||
| 472 | } | ||
| 473 | Primitive::Gradient { | ||
| 474 | rect, | ||
| 475 | radius, | ||
| 476 | colors, | ||
| 477 | } => { | ||
| 478 | content.save_state(); | ||
| 479 | let average = std::array::from_fn(|at| (colors[0][at] + colors[1][at]) / 2.0); | ||
| 480 | self.paint(content, used, average, false); | ||
| 481 | rounded(content, *rect, *radius); | ||
| 482 | content.fill_nonzero(); | ||
| 483 | content.restore_state(); | ||
| 484 | } | ||
| 485 | // Soft shadows are for the screen. | ||
| 486 | Primitive::Shadow { .. } => {} | ||
| 487 | Primitive::Image { image, rect } => { | ||
| 488 | let key = ImageKey::Raster(image.id()); | ||
| 489 | let reference = match self.images.get(&key) { | ||
| 490 | Some(reference) => *reference, | ||
| 491 | None => { | ||
| 492 | let reference = self.raster(image); | ||
| 493 | self.images.insert(key, reference); | ||
| 494 | reference | ||
| 495 | } | ||
| 496 | }; | ||
| 497 | self.place(content, used, reference, *rect); | ||
| 498 | } | ||
| 499 | Primitive::Segment { | ||
| 500 | from, | ||
| 501 | to, | ||
| 502 | width, | ||
| 503 | round, | ||
| 504 | color, | ||
| 505 | } => { | ||
| 506 | content.save_state(); | ||
| 507 | self.paint(content, used, *color, true); | ||
| 508 | content | ||
| 509 | .set_line_width(*width) | ||
| 510 | .set_line_cap(if *round { | ||
| 511 | LineCapStyle::RoundCap | ||
| 512 | } else { | ||
| 513 | LineCapStyle::ProjectingSquareCap | ||
| 514 | }) | ||
| 515 | .move_to(from[0], from[1]) | ||
| 516 | .line_to(to[0], to[1]) | ||
| 517 | .stroke(); | ||
| 518 | content.restore_state(); | ||
| 519 | } | ||
| 520 | Primitive::Taper { | ||
| 521 | from, | ||
| 522 | to, | ||
| 523 | widths, | ||
| 524 | color, | ||
| 525 | } => { | ||
| 526 | content.save_state(); | ||
| 527 | self.paint(content, used, *color, false); | ||
| 528 | let [from_radius, to_radius] = widths.map(|width| width / 2.0); | ||
| 529 | // Each part fills on its own, as their windings differ. | ||
| 530 | circle(content, *from, from_radius); | ||
| 531 | content.fill_nonzero(); | ||
| 532 | circle(content, *to, to_radius); | ||
| 533 | content.fill_nonzero(); | ||
| 534 | let [dx, dy] = [to[0] - from[0], to[1] - from[1]]; | ||
| 535 | let length = dx.hypot(dy); | ||
| 536 | if length > (from_radius - to_radius).abs() { | ||
| 537 | // The lines touching both ends' circles. | ||
| 538 | let [ux, uy] = [dx / length, dy / length]; | ||
| 539 | let sine = (from_radius - to_radius) / length; | ||
| 540 | let cosine = (1.0 - sine * sine).sqrt(); | ||
| 541 | let touch = |center: &[f32; 2], radius: f32, side: f32| { | ||
| 542 | [ | ||
| 543 | center[0] + radius * (-uy * side * cosine + ux * sine), | ||
| 544 | center[1] + radius * (ux * side * cosine + uy * sine), | ||
| 545 | ] | ||
| 546 | }; | ||
| 547 | let corners = [ | ||
| 548 | touch(from, from_radius, 1.0), | ||
| 549 | touch(to, to_radius, 1.0), | ||
| 550 | touch(to, to_radius, -1.0), | ||
| 551 | touch(from, from_radius, -1.0), | ||
| 552 | ]; | ||
| 553 | content.move_to(corners[0][0], corners[0][1]); | ||
| 554 | for corner in &corners[1..] { | ||
| 555 | content.line_to(corner[0], corner[1]); | ||
| 556 | } | ||
| 557 | content.close_path().fill_nonzero(); | ||
| 558 | } | ||
| 559 | content.restore_state(); | ||
| 560 | } | ||
| 561 | Primitive::Highlight { | ||
| 562 | from, | ||
| 563 | to, | ||
| 564 | width, | ||
| 565 | color, | ||
| 566 | } => { | ||
| 567 | content.save_state(); | ||
| 568 | let state = self.state(color[3], true); | ||
| 569 | Used::add(&mut used.states, state); | ||
| 570 | content.set_parameters(Name(format!("G{}", state.get()).as_bytes())); | ||
| 571 | let [red, green, blue] = encoded(*color); | ||
| 572 | content | ||
| 573 | .set_stroke_rgb(red, green, blue) | ||
| 574 | .set_line_width(*width) | ||
| 575 | .set_line_cap(LineCapStyle::ProjectingSquareCap) | ||
| 576 | .move_to(from[0], from[1]) | ||
| 577 | .line_to(to[0], to[1]) | ||
| 578 | .stroke(); | ||
| 579 | content.restore_state(); | ||
| 580 | } | ||
| 581 | } | ||
| 582 | Ok(()) | ||
| 583 | } | ||
| 584 | |||
| 585 | /// Draws image XObject `image` filling `rect`. | ||
| 586 | fn place(&mut self, content: &mut Content, used: &mut Used, image: Ref, rect: [f32; 4]) { | ||
| 587 | Used::add(&mut used.images, image); | ||
| 588 | content.save_state(); | ||
| 589 | // Image space runs up from the bottom left. | ||
| 590 | content.transform([ | ||
| 591 | rect[2] - rect[0], | ||
| 592 | 0.0, | ||
| 593 | 0.0, | ||
| 594 | rect[1] - rect[3], | ||
| 595 | rect[0], | ||
| 596 | rect[3], | ||
| 597 | ]); | ||
| 598 | content.x_object(Name(format!("I{}", image.get()).as_bytes())); | ||
| 599 | content.restore_state(); | ||
| 600 | } | ||
| 601 | |||
| 602 | /// A picture as an image XObject: opaque photographs as JPEG, others losslessly. | ||
| 603 | fn raster(&mut self, image: &RasterImage) -> Ref { | ||
| 604 | // Pixels are premultiplied in linear light. | ||
| 605 | let straight: Vec<u8> = image | ||
| 606 | .pixels() | ||
| 607 | .chunks_exact(4) | ||
| 608 | .flat_map(|pixel| { | ||
| 609 | let alpha = pixel[3]; | ||
| 610 | if alpha == 255 || alpha == 0 { | ||
| 611 | return [pixel[0], pixel[1], pixel[2], alpha]; | ||
| 612 | } | ||
| 613 | let [red, green, blue] = [pixel[0], pixel[1], pixel[2]].map(|byte| { | ||
| 614 | let linear = super::linear(f32::from(byte) / 255.0); | ||
| 615 | srgb_byte((linear * 255.0 / f32::from(alpha)).min(1.0)) | ||
| 616 | }); | ||
| 617 | [red, green, blue, alpha] | ||
| 618 | }) | ||
| 619 | .collect(); | ||
| 620 | let size = image.size(); | ||
| 621 | let opaque = straight.chunks_exact(4).all(|pixel| pixel[3] == 255); | ||
| 622 | if opaque && size[0] * size[1] >= 64 * 64 { | ||
| 623 | let rgb: Vec<u8> = straight | ||
| 624 | .chunks_exact(4) | ||
| 625 | .flat_map(|pixel| [pixel[0], pixel[1], pixel[2]]) | ||
| 626 | .collect(); | ||
| 627 | let mut jpeg = Vec::new(); | ||
| 628 | let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 92); | ||
| 629 | if image::ImageEncoder::write_image( | ||
| 630 | encoder, | ||
| 631 | &rgb, | ||
| 632 | size[0], | ||
| 633 | size[1], | ||
| 634 | image::ExtendedColorType::Rgb8, | ||
| 635 | ) | ||
| 636 | .is_ok() | ||
| 637 | { | ||
| 638 | let reference = self.reserve(); | ||
| 639 | let mut writer = self.pdf.image_xobject(reference, &jpeg); | ||
| 640 | writer.filter(Filter::DctDecode); | ||
| 641 | writer | ||
| 642 | .width(size[0] as i32) | ||
| 643 | .height(size[1] as i32) | ||
| 644 | .color_space_name(Name(b"DeviceRGB")) | ||
| 645 | .bits_per_component(8) | ||
| 646 | .interpolate(true); | ||
| 647 | return reference; | ||
| 648 | } | ||
| 649 | } | ||
| 650 | self.image(size, &straight, true) | ||
| 651 | } | ||
| 652 | |||
| 653 | /// Straight sRGB RGBA pixels as a losslessly compressed image XObject. | ||
| 654 | fn image(&mut self, [width, height]: [u32; 2], rgba: &[u8], interpolate: bool) -> Ref { | ||
| 655 | let rgb: Vec<u8> = rgba | ||
| 656 | .chunks_exact(4) | ||
| 657 | .flat_map(|pixel| [pixel[0], pixel[1], pixel[2]]) | ||
| 658 | .collect(); | ||
| 659 | let mask = rgba.chunks_exact(4).any(|pixel| pixel[3] != 255).then(|| { | ||
| 660 | let alpha: Vec<u8> = rgba.chunks_exact(4).map(|pixel| pixel[3]).collect(); | ||
| 661 | let mask = self.reserve(); | ||
| 662 | let data = compress(&alpha); | ||
| 663 | let mut writer = self.pdf.image_xobject(mask, &data); | ||
| 664 | writer.filter(Filter::FlateDecode); | ||
| 665 | writer | ||
| 666 | .width(width as i32) | ||
| 667 | .height(height as i32) | ||
| 668 | .color_space_name(Name(b"DeviceGray")) | ||
| 669 | .bits_per_component(8) | ||
| 670 | .interpolate(interpolate); | ||
| 671 | mask | ||
| 672 | }); | ||
| 673 | let reference = self.reserve(); | ||
| 674 | let data = compress(&rgb); | ||
| 675 | let mut writer = self.pdf.image_xobject(reference, &data); | ||
| 676 | writer.filter(Filter::FlateDecode); | ||
| 677 | writer | ||
| 678 | .width(width as i32) | ||
| 679 | .height(height as i32) | ||
| 680 | .color_space_name(Name(b"DeviceRGB")) | ||
| 681 | .bits_per_component(8) | ||
| 682 | .interpolate(interpolate); | ||
| 683 | if let Some(mask) = mask { | ||
| 684 | writer.s_mask(mask); | ||
| 685 | } | ||
| 686 | reference | ||
| 687 | } | ||
| 688 | |||
| 689 | /// The document's face for a run's font, added on first use. | ||
| 690 | fn font(&mut self, data: &Blob<u8>, index: u32) -> Result<usize, RenderError> { | ||
| 691 | if let Some(found) = self | ||
| 692 | .fonts | ||
| 693 | .iter() | ||
| 694 | .position(|font| font.data.id() == data.id() && font.index == index) | ||
| 695 | { | ||
| 696 | return Ok(found); | ||
| 697 | } | ||
| 698 | let face = FontRef::from_index(data.as_ref(), index as usize) | ||
| 699 | .ok_or(RenderError::UnreadableFont)?; | ||
| 700 | let outlined = face.table(tag_from_bytes(b"glyf")).is_some() | ||
| 701 | || face.table(tag_from_bytes(b"CFF ")).is_some(); | ||
| 702 | let reference = self.reserve(); | ||
| 703 | self.fonts.push(Font { | ||
| 704 | reference, | ||
| 705 | data: data.clone(), | ||
| 706 | index, | ||
| 707 | outlined, | ||
| 708 | glyphs: subsetter::GlyphRemapper::new(), | ||
| 709 | text: BTreeMap::new(), | ||
| 710 | }); | ||
| 711 | Ok(self.fonts.len() - 1) | ||
| 712 | } | ||
| 713 | |||
| 714 | fn run( | ||
| 715 | &mut self, | ||
| 716 | content: &mut Content, | ||
| 717 | used: &mut Used, | ||
| 718 | run: GlyphRun<'_>, | ||
| 719 | origin: [f32; 2], | ||
| 720 | ink: [f32; 4], | ||
| 721 | ) -> Result<(), RenderError> { | ||
| 722 | let [top, bottom] = [ | ||
| 723 | origin[1] + run.line[0], | ||
| 724 | origin[1] + run.line[0] + run.line[1], | ||
| 725 | ]; | ||
| 726 | if self | ||
| 727 | .rows | ||
| 728 | .is_some_and(|[first, last]| bottom <= first || top >= last) | ||
| 729 | { | ||
| 730 | return Ok(()); | ||
| 731 | } | ||
| 732 | let font = self.font(run.font, run.index)?; | ||
| 733 | let color = run.color.unwrap_or(ink); | ||
| 734 | let glyphs: Vec<_> = run.glyphs.collect(); | ||
| 735 | let texts = glyph_text(run.text, run.clusters, glyphs.len()); | ||
| 736 | content.save_state(); | ||
| 737 | if self.fonts[font].outlined { | ||
| 738 | Used::add(&mut used.fonts, font); | ||
| 739 | self.paint(content, used, color, false); | ||
| 740 | let face = FontRef::from_index(run.font.as_ref(), run.index as usize) | ||
| 741 | .ok_or(RenderError::UnreadableFont)?; | ||
| 742 | let units = f32::from(face.metrics(&[]).units_per_em.max(1)); | ||
| 743 | let advances = face.glyph_metrics(&[]); | ||
| 744 | let name = format!("F{font}"); | ||
| 745 | content | ||
| 746 | .begin_text() | ||
| 747 | .set_font(Name(name.as_bytes()), run.size); | ||
| 748 | if run.embolden { | ||
| 749 | self.paint(content, used, color, true); | ||
| 750 | content | ||
| 751 | .set_text_rendering_mode(TextRenderingMode::FillStroke) | ||
| 752 | .set_line_width(run.size / 24.0); | ||
| 753 | } | ||
| 754 | let shear = run.skew.map_or(0.0, |degrees| degrees.to_radians().tan()); | ||
| 755 | let mut shown: Vec<(u16, f32)> = Vec::new(); | ||
| 756 | let mut pen: Option<[f32; 2]> = None; | ||
| 757 | let flush = |content: &mut Content, shown: &mut Vec<(u16, f32)>| { | ||
| 758 | if shown.is_empty() { | ||
| 759 | return; | ||
| 760 | } | ||
| 761 | let mut positioned = content.show_positioned(); | ||
| 762 | let mut items = positioned.items(); | ||
| 763 | for (glyph, adjust) in shown.drain(..) { | ||
| 764 | if adjust != 0.0 { | ||
| 765 | items.adjust(adjust); | ||
| 766 | } | ||
| 767 | items.show(Str(&glyph.to_be_bytes())); | ||
| 768 | } | ||
| 769 | }; | ||
| 770 | for (glyph, text) in glyphs.iter().zip(texts) { | ||
| 771 | let Ok(id) = u16::try_from(glyph.id) else { | ||
| 772 | continue; | ||
| 773 | }; | ||
| 774 | let face = &mut self.fonts[font]; | ||
| 775 | let cid = face.glyphs.remap(id); | ||
| 776 | let [x, y] = [origin[0] + glyph.x, origin[1] + glyph.y]; | ||
| 777 | let conflict = !text.is_empty() | ||
| 778 | && match face.text.entry(cid) { | ||
| 779 | Entry::Vacant(entry) => { | ||
| 780 | entry.insert(text.clone()); | ||
| 781 | false | ||
| 782 | } | ||
| 783 | Entry::Occupied(entry) => *entry.get() != text, | ||
| 784 | }; | ||
| 785 | let advance = advances.advance_width(id) / units * run.size; | ||
| 786 | match pen { | ||
| 787 | Some([at, line]) if !conflict && (line - y).abs() < 0.001 => { | ||
| 788 | shown.push((cid, (at - x) * 1000.0 / run.size)); | ||
| 789 | } | ||
| 790 | _ => { | ||
| 791 | flush(content, &mut shown); | ||
| 792 | content.set_text_matrix([1.0, 0.0, shear, -1.0, x, y]); | ||
| 793 | if conflict { | ||
| 794 | // A glyph shared by different text says what it shows here. | ||
| 795 | content | ||
| 796 | .begin_marked_content_with_properties(Name(b"Span")) | ||
| 797 | .properties() | ||
| 798 | .actual_text(TextStr(&text)); | ||
| 799 | content.show(Str(&cid.to_be_bytes())); | ||
| 800 | content.end_marked_content(); | ||
| 801 | pen = None; | ||
| 802 | continue; | ||
| 803 | } | ||
| 804 | shown.push((cid, 0.0)); | ||
| 805 | } | ||
| 806 | } | ||
| 807 | pen = Some([x + advance, y]); | ||
| 808 | } | ||
| 809 | flush(content, &mut shown); | ||
| 810 | content.end_text(); | ||
| 811 | } else { | ||
| 812 | for glyph in &glyphs { | ||
| 813 | self.glyph_picture(content, used, &run, *glyph, origin, color); | ||
| 814 | } | ||
| 815 | } | ||
| 816 | for decoration in run.decorations { | ||
| 817 | self.paint(content, used, decoration.color.unwrap_or(color), false); | ||
| 818 | content | ||
| 819 | .rect( | ||
| 820 | origin[0] + decoration.x, | ||
| 821 | origin[1] + decoration.y, | ||
| 822 | decoration.width, | ||
| 823 | decoration.thickness, | ||
| 824 | ) | ||
| 825 | .fill_nonzero(); | ||
| 826 | } | ||
| 827 | content.restore_state(); | ||
| 828 | Ok(()) | ||
| 829 | } | ||
| 830 | |||
| 831 | /// A glyph of a face without outlines the PDF can embed, such as a colour emoji, drawn | ||
| 832 | /// as a picture four pixels a point. | ||
| 833 | fn glyph_picture( | ||
| 834 | &mut self, | ||
| 835 | content: &mut Content, | ||
| 836 | used: &mut Used, | ||
| 837 | run: &GlyphRun<'_>, | ||
| 838 | glyph: super::Glyph, | ||
| 839 | origin: [f32; 2], | ||
| 840 | color: [f32; 4], | ||
| 841 | ) { | ||
| 842 | const DENSITY: f32 = 4.0; | ||
| 843 | let Ok(id) = u16::try_from(glyph.id) else { | ||
| 844 | return; | ||
| 845 | }; | ||
| 846 | let Some(face) = FontRef::from_index(run.font.as_ref(), run.index as usize) else { | ||
| 847 | return; | ||
| 848 | }; | ||
| 849 | let size = run.size * DENSITY; | ||
| 850 | let mut scaler = self | ||
| 851 | .scale | ||
| 852 | .builder(face) | ||
| 853 | .size(size) | ||
| 854 | .normalized_coords(run.coords.iter().copied()) | ||
| 855 | .build(); | ||
| 856 | let Some(rendered) = Render::new(&[ | ||
| 857 | Source::ColorOutline(0), | ||
| 858 | Source::ColorBitmap(StrikeWith::BestFit), | ||
| 859 | Source::Outline, | ||
| 860 | ]) | ||
| 861 | .render(&mut scaler, id) else { | ||
| 862 | return; | ||
| 863 | }; | ||
| 864 | let placement = rendered.placement; | ||
| 865 | if placement.width == 0 || placement.height == 0 { | ||
| 866 | return; | ||
| 867 | } | ||
| 868 | let key = ImageKey::Glyph { | ||
| 869 | font: run.font.id(), | ||
| 870 | index: run.index, | ||
| 871 | glyph: glyph.id, | ||
| 872 | size: size.to_bits(), | ||
| 873 | color: [color[0], color[1], color[2]].map(f32::to_bits), | ||
| 874 | }; | ||
| 875 | let image = match self.images.get(&key) { | ||
| 876 | Some(image) => *image, | ||
| 877 | None => { | ||
| 878 | let [red, green, blue] = encoded(color).map(|value| (value * 255.0).round() as u8); | ||
| 879 | let rgba: Vec<u8> = match rendered.content { | ||
| 880 | Pixels::Mask => rendered | ||
| 881 | .data | ||
| 882 | .iter() | ||
| 883 | .flat_map(|coverage| [red, green, blue, *coverage]) | ||
| 884 | .collect(), | ||
| 885 | Pixels::Color | Pixels::SubpixelMask => rendered.data.clone(), | ||
| 886 | }; | ||
| 887 | let image = self.image([placement.width, placement.height], &rgba, true); | ||
| 888 | self.images.insert(key, image); | ||
| 889 | image | ||
| 890 | } | ||
| 891 | }; | ||
| 892 | let [x, y] = [ | ||
| 893 | origin[0] + glyph.x + placement.left as f32 / DENSITY, | ||
| 894 | origin[1] + glyph.y - placement.top as f32 / DENSITY, | ||
| 895 | ]; | ||
| 896 | self.place( | ||
| 897 | content, | ||
| 898 | used, | ||
| 899 | image, | ||
| 900 | [ | ||
| 901 | x, | ||
| 902 | y, | ||
| 903 | x + placement.width as f32 / DENSITY, | ||
| 904 | y + placement.height as f32 / DENSITY, | ||
| 905 | ], | ||
| 906 | ); | ||
| 907 | } | ||
| 908 | |||
| 909 | /// Embeds `font`'s subset as a CID-keyed Type 0 font with a ToUnicode map. | ||
| 910 | fn write_font(&mut self, font: Font) -> Result<(), RenderError> { | ||
| 911 | let data = font.data.as_ref(); | ||
| 912 | let face = | ||
| 913 | FontRef::from_index(data, font.index as usize).ok_or(RenderError::UnreadableFont)?; | ||
| 914 | let subset = subsetter::subset(data, font.index, &font.glyphs) | ||
| 915 | .map_err(|_| RenderError::UnreadableFont)?; | ||
| 916 | let cff = face.table(tag_from_bytes(b"CFF ")).is_some(); | ||
| 917 | let metrics = face.metrics(&[]); | ||
| 918 | let scale = 1000.0 / f32::from(metrics.units_per_em.max(1)); | ||
| 919 | let advances = face.glyph_metrics(&[]); | ||
| 920 | let widths: Vec<f32> = font | ||
| 921 | .glyphs | ||
| 922 | .remapped_gids() | ||
| 923 | .map(|old| advances.advance_width(old) * scale) | ||
| 924 | .collect(); | ||
| 925 | let postscript: String = face | ||
| 926 | .localized_strings() | ||
| 927 | .find_by_id(StringId::PostScript, None) | ||
| 928 | .map(|name| name.chars().filter(char::is_ascii_alphanumeric).collect()) | ||
| 929 | .filter(|name: &String| !name.is_empty()) | ||
| 930 | .unwrap_or_else(|| "Font".to_owned()); | ||
| 931 | // A subset's name starts with six capitals naming the subset. | ||
| 932 | let mut hash = font.reference.get() as u32; | ||
| 933 | let tag: String = (0..6) | ||
| 934 | .map(|_| { | ||
| 935 | hash = hash.wrapping_mul(1_103_515_245).wrapping_add(12_345); | ||
| 936 | char::from(b'A' + (hash >> 16) as u8 % 26) | ||
| 937 | }) | ||
| 938 | .collect(); | ||
| 939 | let base = format!("{tag}+{postscript}"); | ||
| 940 | let [cid, descriptor, file, map] = [(); 4].map(|_| self.reserve()); | ||
| 941 | let info = SystemInfo { | ||
| 942 | registry: Str(b"Adobe"), | ||
| 943 | ordering: Str(b"Identity"), | ||
| 944 | supplement: 0, | ||
| 945 | }; | ||
| 946 | self.pdf | ||
| 947 | .type0_font(font.reference) | ||
| 948 | .base_font(Name(base.as_bytes())) | ||
| 949 | .encoding_predefined(Name(b"Identity-H")) | ||
| 950 | .descendant_font(cid) | ||
| 951 | .to_unicode(map); | ||
| 952 | let mut writer = self.pdf.cid_font(cid); | ||
| 953 | writer | ||
| 954 | .subtype(if cff { | ||
| 955 | CidFontType::Type0 | ||
| 956 | } else { | ||
| 957 | CidFontType::Type2 | ||
| 958 | }) | ||
| 959 | .base_font(Name(base.as_bytes())) | ||
| 960 | .system_info(info) | ||
| 961 | .font_descriptor(descriptor) | ||
| 962 | .default_width(0.0); | ||
| 963 | if !cff { | ||
| 964 | writer.cid_to_gid_map_predefined(Name(b"Identity")); | ||
| 965 | } | ||
| 966 | writer.widths().consecutive(0, widths); | ||
| 967 | writer.finish(); | ||
| 968 | let mut writer = self.pdf.font_descriptor(descriptor); | ||
| 969 | writer | ||
| 970 | .name(Name(base.as_bytes())) | ||
| 971 | .flags(FontFlags::SYMBOLIC) | ||
| 972 | .bbox(Rect::new( | ||
| 973 | 0.0, | ||
| 974 | -metrics.descent * scale, | ||
| 975 | metrics.max_width * scale, | ||
| 976 | metrics.ascent * scale, | ||
| 977 | )) | ||
| 978 | .italic_angle(0.0) | ||
| 979 | .ascent(metrics.ascent * scale) | ||
| 980 | .descent(-metrics.descent * scale) | ||
| 981 | .cap_height(metrics.cap_height * scale) | ||
| 982 | .stem_v(80.0); | ||
| 983 | if cff { | ||
| 984 | writer.font_file3(file); | ||
| 985 | } else { | ||
| 986 | writer.font_file2(file); | ||
| 987 | } | ||
| 988 | writer.finish(); | ||
| 989 | let compressed = compress(&subset); | ||
| 990 | let mut stream = self.pdf.stream(file, &compressed); | ||
| 991 | stream.filter(Filter::FlateDecode); | ||
| 992 | if cff { | ||
| 993 | stream.pair(Name(b"Subtype"), Name(b"OpenType")); | ||
| 994 | } | ||
| 995 | stream.finish(); | ||
| 996 | let mut cmap = UnicodeCmap::new(Name(b"Custom"), info); | ||
| 997 | for (glyph, text) in &font.text { | ||
| 998 | cmap.pair_with_multiple(*glyph, text.chars()); | ||
| 999 | } | ||
| 1000 | let cmap = compress(&cmap.finish()); | ||
| 1001 | self.pdf.stream(map, &cmap).filter(Filter::FlateDecode); | ||
| 1002 | Ok(()) | ||
| 1003 | } | ||
| 1004 | } | ||
crates/draw/src/render/text.rs+23-2| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | use crate::RenderError; | 1 | use crate::RenderError; |
| 2 | use parley::fontique::Blob; | 2 | use parley::fontique::Blob; |
| 3 | use std::ops::Range; | ||
| 3 | 4 | ||
| 4 | /// Text the renderer can paint, as glyph runs in layer units. Owners recover their | 5 | /// Text the renderer can paint, as glyph runs in layer units. Owners recover their |
| 5 | /// concrete text from a primitive through `Any`. | 6 | /// concrete text from a primitive through `Any`. |
| ... | @@ -32,6 +33,11 @@ pub struct GlyphRun<'a> { | ... | @@ -32,6 +33,11 @@ pub struct GlyphRun<'a> { |
| 32 | pub glyphs: &'a mut dyn Iterator<Item = Glyph>, | 33 | pub glyphs: &'a mut dyn Iterator<Item = Glyph>, |
| 33 | /// Underline and strikethrough, painted after the glyphs. | 34 | /// Underline and strikethrough, painted after the glyphs. |
| 34 | pub decorations: &'a [Decoration], | 35 | pub decorations: &'a [Decoration], |
| 36 | /// The source the glyphs show, for a document's text layer; the renderer ignores it. | ||
| 37 | pub text: &'a str, | ||
| 38 | /// Each cluster's bytes of `text` and how many glyphs show it, in `glyphs`' order; a | ||
| 39 | /// ligature's later clusters take none. | ||
| 40 | pub clusters: &'a mut dyn Iterator<Item = (Range<usize>, usize)>, | ||
| 35 | } | 41 | } |
| 36 | 42 | ||
| 37 | /// A glyph's baseline position. | 43 | /// A glyph's baseline position. |
| ... | @@ -53,10 +59,12 @@ pub struct Decoration { | ... | @@ -53,10 +59,12 @@ pub struct Decoration { |
| 53 | pub color: Option<[f32; 4]>, | 59 | pub color: Option<[f32; 4]>, |
| 54 | } | 60 | } |
| 55 | 61 | ||
| 56 | /// Paints one parley glyph run with its line's baseline moved to `baseline`, its glyphs | 62 | /// Paints one parley glyph run of `text` laid out, with its line's baseline moved to |
| 57 | /// raised by `rise` and `backdrop` behind them. | 63 | /// `baseline`, its glyphs raised by `rise` and `backdrop` behind them. |
| 64 | #[allow(clippy::too_many_arguments)] | ||
| 58 | pub fn paint_parley_run<B: parley::Brush>( | 65 | pub fn paint_parley_run<B: parley::Brush>( |
| 59 | run: &parley::GlyphRun<'_, B>, | 66 | run: &parley::GlyphRun<'_, B>, |
| 67 | text: &str, | ||
| 60 | baseline: f32, | 68 | baseline: f32, |
| 61 | rise: f32, | 69 | rise: f32, |
| 62 | line: [f32; 2], | 70 | line: [f32; 2], |
| ... | @@ -104,6 +112,17 @@ pub fn paint_parley_run<B: parley::Brush>( | ... | @@ -104,6 +112,17 @@ pub fn paint_parley_run<B: parley::Brush>( |
| 104 | x: glyph.x, | 112 | x: glyph.x, |
| 105 | y: glyph.y + baseline - line_baseline - rise, | 113 | y: glyph.y + baseline - line_baseline - rise, |
| 106 | }); | 114 | }); |
| 115 | // The run's clusters are those of its style within its span of the line; walked only | ||
| 116 | // when a text layer asks. | ||
| 117 | let [start, end] = [run.offset(), run.offset() + run.advance()]; | ||
| 118 | let mut at = None; | ||
| 119 | let mut clusters = run.run().visual_clusters().filter_map(|cluster| { | ||
| 120 | let x = *at.get_or_insert_with(|| cluster.visual_offset().unwrap_or(start)); | ||
| 121 | at = Some(x + cluster.advance()); | ||
| 122 | let within = x > start - 0.01 && x < end - 0.01; | ||
| 123 | (cluster.style_index() == run.style_index() && within) | ||
| 124 | .then(|| (cluster.text_range(), cluster.glyphs().count())) | ||
| 125 | }); | ||
| 107 | paint(GlyphRun { | 126 | paint(GlyphRun { |
| 108 | font: &font.data, | 127 | font: &font.data, |
| 109 | index: font.index, | 128 | index: font.index, |
| ... | @@ -116,5 +135,7 @@ pub fn paint_parley_run<B: parley::Brush>( | ... | @@ -116,5 +135,7 @@ pub fn paint_parley_run<B: parley::Brush>( |
| 116 | line, | 135 | line, |
| 117 | glyphs: &mut glyphs, | 136 | glyphs: &mut glyphs, |
| 118 | decorations: &decorations, | 137 | decorations: &decorations, |
| 138 | text, | ||
| 139 | clusters: &mut clusters, | ||
| 119 | }) | 140 | }) |
| 120 | } | 141 | } |
crates/mobile/src/lib.rs+5-21| ... | @@ -29,7 +29,7 @@ use draw::edit::{Key, NamedKey, SelectionUnit}; | ... | @@ -29,7 +29,7 @@ use draw::edit::{Key, NamedKey, SelectionUnit}; |
| 29 | use onestore::{ | 29 | use onestore::{ |
| 30 | ExGuid, | 30 | ExGuid, |
| 31 | op::{Edit, Op, PageOp}, | 31 | op::{Edit, Op, PageOp}, |
| 32 | page::{Image, Page, ink::ShapeKind}, | 32 | page::{Page, ink::ShapeKind}, |
| 33 | }; | 33 | }; |
| 34 | use parley::{Affinity, BoundingBox}; | 34 | use parley::{Affinity, BoundingBox}; |
| 35 | use std::{ | 35 | use std::{ |
| ... | @@ -503,25 +503,9 @@ impl Canvas { | ... | @@ -503,25 +503,9 @@ impl Canvas { |
| 503 | Ok(moved(self.page.set_paper(color, rules, Some(art))?)) | 503 | Ok(moved(self.page.set_paper(color, rules, Some(art))?)) |
| 504 | } | 504 | } |
| 505 | 505 | ||
| 506 | /// Puts a picture of `size` points after the caret's paragraph. | 506 | /// Puts a picture of `size` points at the caret. |
| 507 | fn insert_picture(&mut self, bytes: &[u8], size: [f32; 2]) -> Result<bool> { | 507 | fn insert_picture(&mut self, bytes: &[u8], size: [f32; 2]) -> Result<bool> { |
| 508 | let image = Image { | 508 | Ok(moved(self.page.insert_picture(bytes.to_vec(), size)?)) |
| 509 | id: onestore::page::text::new_id()?, | ||
| 510 | layout: onestore::document::Layout { | ||
| 511 | max_width: Some(size[0]), | ||
| 512 | max_height: Some(size[1]), | ||
| 513 | ..Default::default() | ||
| 514 | }, | ||
| 515 | size: Some(size), | ||
| 516 | bytes: Some(bytes.into()), | ||
| 517 | display: None, | ||
| 518 | alt: None, | ||
| 519 | background: false, | ||
| 520 | printout: None, | ||
| 521 | }; | ||
| 522 | let page = &mut self.page; | ||
| 523 | page.editor.insert_picture(&mut page.engine, image)?; | ||
| 524 | Ok(true) | ||
| 525 | } | 509 | } |
| 526 | 510 | ||
| 527 | /// Gives the page date `seconds` since 1970 (keeping the stored fraction of a second), | 511 | /// Gives the page date `seconds` since 1970 (keeping the stored fraction of a second), |
| ... | @@ -1257,8 +1241,8 @@ pub extern "C" fn sb_view_page_text(view: &View) -> *mut c_char { | ... | @@ -1257,8 +1241,8 @@ pub extern "C" fn sb_view_page_text(view: &View) -> *mut c_char { |
| 1257 | owned(view.canvas.page_text()) | 1241 | owned(view.canvas.page_text()) |
| 1258 | } | 1242 | } |
| 1259 | 1243 | ||
| 1260 | /// Puts the picture file `bytes` (JPEG or PNG), `width` × `height` points, after the | 1244 | /// Puts the picture file `bytes` (JPEG or PNG), `width` × `height` points, at the caret: |
| 1261 | /// caret's paragraph. | 1245 | /// splitting its paragraph, or on the page at a caret on blank page. |
| 1262 | /// | 1246 | /// |
| 1263 | /// # Safety | 1247 | /// # Safety |
| 1264 | /// `bytes` holds `length` bytes. | 1248 | /// `bytes` holds `length` bytes. |
crates/mobile/src/tests.rs+1-1| ... | @@ -430,7 +430,7 @@ fn find_selects_the_match_and_formatting_reports_and_toggles() { | ... | @@ -430,7 +430,7 @@ fn find_selects_the_match_and_formatting_reports_and_toggles() { |
| 430 | } | 430 | } |
| 431 | 431 | ||
| 432 | #[test] | 432 | #[test] |
| 433 | fn a_picture_goes_after_the_caret_paragraph_and_undoes() { | 433 | fn a_picture_goes_in_at_the_caret_and_undoes() { |
| 434 | let (_directory, section) = features(); | 434 | let (_directory, section) = features(); |
| 435 | let mut canvas = canvas(&section, "Paragraph controls"); | 435 | let mut canvas = canvas(&section, "Paragraph controls"); |
| 436 | focus(&mut canvas, "Collapsed parent"); | 436 | focus(&mut canvas, "Collapsed parent"); |
crates/notebook/Cargo.toml+3| ... | @@ -23,6 +23,9 @@ zeroize = { version = "1.9.0", optional = true } | ... | @@ -23,6 +23,9 @@ zeroize = { version = "1.9.0", optional = true } |
| 23 | [target.'cfg(target_vendor = "apple")'.dependencies] | 23 | [target.'cfg(target_vendor = "apple")'.dependencies] |
| 24 | nix = { version = "0.31", default-features = false, features = ["fs"] } | 24 | nix = { version = "0.31", default-features = false, features = ["fs"] } |
| 25 | 25 | ||
| 26 | [target.'cfg(windows)'.dependencies] | ||
| 27 | windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] } | ||
| 28 | |||
| 26 | [dev-dependencies] | 29 | [dev-dependencies] |
| 27 | libc = "0.2" | 30 | libc = "0.2" |
| 28 | serde_json = "1" | 31 | serde_json = "1" |
crates/notebook/src/lib.rs+2-1| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | #![forbid(unsafe_code)] | 1 | // One Win32 call, where std has no safe form: `session`'s hidden attribute. |
| 2 | #![deny(unsafe_code)] | ||
| 2 | #![doc = include_str!("../README.md")] | 3 | #![doc = include_str!("../README.md")] |
| 3 | 4 | ||
| 4 | pub mod discover; | 5 | pub mod discover; |
crates/notebook/src/session.rs+26-2| ... | @@ -168,7 +168,9 @@ impl Storage for Directory { | ... | @@ -168,7 +168,9 @@ impl Storage for Directory { |
| 168 | Ok(std::fs::create_dir(self.path(path))?) | 168 | Ok(std::fs::create_dir(self.path(path))?) |
| 169 | } | 169 | } |
| 170 | 170 | ||
| 171 | /// macOS keeps the attribute as `UF_HIDDEN`, and passes it on to a share it mounted. | 171 | /// macOS keeps the attribute as `UF_HIDDEN`, and passes it on to a share it mounted; |
| 172 | /// Linux has no attribute to set, a dot folder being hidden there already. | ||
| 173 | #[cfg_attr(windows, allow(unsafe_code))] | ||
| 172 | fn hide(&self, path: &str) -> Result<()> { | 174 | fn hide(&self, path: &str) -> Result<()> { |
| 173 | #[cfg(target_vendor = "apple")] | 175 | #[cfg(target_vendor = "apple")] |
| 174 | { | 176 | { |
| ... | @@ -181,7 +183,29 @@ impl Storage for Directory { | ... | @@ -181,7 +183,29 @@ impl Storage for Directory { |
| 181 | .map_err(io::Error::from)?; | 183 | .map_err(io::Error::from)?; |
| 182 | } | 184 | } |
| 183 | } | 185 | } |
| 184 | #[cfg(not(target_vendor = "apple"))] | 186 | #[cfg(windows)] |
| 187 | { | ||
| 188 | use std::os::windows::ffi::OsStrExt; | ||
| 189 | use windows_sys::Win32::Storage::FileSystem::{ | ||
| 190 | FILE_ATTRIBUTE_HIDDEN, GetFileAttributesW, INVALID_FILE_ATTRIBUTES, | ||
| 191 | SetFileAttributesW, | ||
| 192 | }; | ||
| 193 | let path: Vec<u16> = self | ||
| 194 | .path(path) | ||
| 195 | .as_os_str() | ||
| 196 | .encode_wide() | ||
| 197 | .chain([0]) | ||
| 198 | .collect(); | ||
| 199 | // SAFETY: `path` is a NUL-terminated UTF-16 string that outlives both calls. | ||
| 200 | let attributes = unsafe { GetFileAttributesW(path.as_ptr()) }; | ||
| 201 | if attributes == INVALID_FILE_ATTRIBUTES | ||
| 202 | || unsafe { SetFileAttributesW(path.as_ptr(), attributes | FILE_ATTRIBUTE_HIDDEN) } | ||
| 203 | == 0 | ||
| 204 | { | ||
| 205 | return Err(io::Error::last_os_error().into()); | ||
| 206 | } | ||
| 207 | } | ||
| 208 | #[cfg(not(any(target_vendor = "apple", windows)))] | ||
| 185 | let _ = path; | 209 | let _ = path; |
| 186 | Ok(()) | 210 | Ok(()) |
| 187 | } | 211 | } |
crates/onestore/examples/opts29_dump.rs created+29| ... | @@ -0,0 +1,29 @@ | ||
| 1 | use onestore::{ObjectData, PropertySets, RevisionIndex, Store, Value}; | ||
| 2 | use std::{env, fs}; | ||
| 3 | |||
| 4 | fn main() -> Result<(), Box<dyn std::error::Error>> { | ||
| 5 | for path in env::args_os().skip(1) { | ||
| 6 | let bytes = fs::read(&path)?; | ||
| 7 | let store = Store::parse(&bytes)?; | ||
| 8 | let index = RevisionIndex::parse(&store)?; | ||
| 9 | println!("== {path:?}"); | ||
| 10 | for space in index.spaces.keys() { | ||
| 11 | let Ok(resolved) = index.resolve_active(*space) else { continue }; | ||
| 12 | println!("-- space {space:?}"); | ||
| 13 | for (id, object) in &resolved.objects { | ||
| 14 | let ObjectData::Properties(data) = object.data else { continue }; | ||
| 15 | let sets = PropertySets::parse(data)?; | ||
| 16 | println!("{id:?} jcid={:#x}", object.jcid); | ||
| 17 | for p in &sets.sets[0] { | ||
| 18 | let v = match &p.value { | ||
| 19 | Value::Bytes(b) if b.len() <= 64 => format!("{:02x?}", b), | ||
| 20 | Value::Bytes(b) => format!("{} bytes", b.len()), | ||
| 21 | other => format!("{other:?}").chars().take(120).collect(), | ||
| 22 | }; | ||
| 23 | println!(" {:#010x} {v}", p.id); | ||
| 24 | } | ||
| 25 | } | ||
| 26 | } | ||
| 27 | } | ||
| 28 | Ok(()) | ||
| 29 | } | ||
crates/snowbound/Cargo.toml+5-1| ... | @@ -10,7 +10,7 @@ default = ["wgpu"] | ... | @@ -10,7 +10,7 @@ default = ["wgpu"] |
| 10 | wgpu = ["draw/wgpu", "dep:wgpu"] | 10 | wgpu = ["draw/wgpu", "dep:wgpu"] |
| 11 | 11 | ||
| 12 | [dependencies] | 12 | [dependencies] |
| 13 | canvas = { path = "../canvas", features = ["interaction"] } | 13 | canvas = { path = "../canvas", features = ["interaction", "pdf"] } |
| 14 | draw = { path = "../draw", default-features = false, features = ["render"] } | 14 | draw = { path = "../draw", default-features = false, features = ["render"] } |
| 15 | ui = { path = "../ui" } | 15 | ui = { path = "../ui" } |
| 16 | png = "0.18" | 16 | png = "0.18" |
| ... | @@ -39,6 +39,10 @@ objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAl | ... | @@ -39,6 +39,10 @@ objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAl |
| 39 | objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSGeometry", "NSLocale", "NSPathUtilities", "NSTimeZone", "NSURL", "NSArray", "NSRange", "NSError", "NSFileCoordinator", "NSFilePresenter", "NSFileVersion", "NSFileManager", "NSNotification", "NSOperation", "block2"] } | 39 | objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSGeometry", "NSLocale", "NSPathUtilities", "NSTimeZone", "NSURL", "NSArray", "NSRange", "NSError", "NSFileCoordinator", "NSFilePresenter", "NSFileVersion", "NSFileManager", "NSNotification", "NSOperation", "block2"] } |
| 40 | block2 = "0.5.1" | 40 | block2 = "0.5.1" |
| 41 | 41 | ||
| 42 | [target.'cfg(not(target_os = "macos"))'.dependencies] | ||
| 43 | # Clipboard bitmaps; macOS reads the pasteboard's PNG and TIFF itself. | ||
| 44 | arboard = { version = "3.6.1", default-features = false, features = ["image-data"] } | ||
| 45 | |||
| 42 | [target.'cfg(windows)'.dependencies] | 46 | [target.'cfg(windows)'.dependencies] |
| 43 | windows-sys = { version = "0.61", features = [ | 47 | windows-sys = { version = "0.61", features = [ |
| 44 | "Win32_Foundation", | 48 | "Win32_Foundation", |
crates/snowbound/assets/icon/Assets.car created| Binary files /dev/null and b/crates/snowbound/assets/icon/Assets.car differ | |||
crates/snowbound/assets/icon/README.md created+20| ... | @@ -0,0 +1,20 @@ | ||
| 1 | # App icon | ||
| 2 | |||
| 3 | `Assets.car` is `sources/Snowbound-Tahoe.icon` compiled by Xcode 26.0.1's `actool`, | ||
| 4 | which can leave out the flattened renditions later versions always add. macOS 26 draws | ||
| 5 | the 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 | ||
| 7 | the `.icon`, regenerate it with Xcode 26.0.1 (not newer), for the Intel floor: | ||
| 8 | |||
| 9 | ```sh | ||
| 10 | DEVELOPER_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 | ||
| 16 | cp OUT/Assets.car Assets.car | ||
| 17 | ``` | ||
| 18 | |||
| 19 | It warns "Failed to generate flattened icon stack", which is the point. | ||
| 20 | `xcrun assetutil --info Assets.car` should list no `Icon Image` renditions. | ||
crates/snowbound/linux/README.md+2-2| ... | @@ -30,8 +30,8 @@ Snowbound. | ... | @@ -30,8 +30,8 @@ Snowbound. |
| 30 | 30 | ||
| 31 | ## Requirements | 31 | ## Requirements |
| 32 | 32 | ||
| 33 | - x86_64 or aarch64 Linux with glibc 2.31 or newer (Debian 11, Ubuntu 20.04, | 33 | - x86_64 or aarch64 Linux with glibc 2.17 or newer (RHEL 7, Debian 8, |
| 34 | Fedora 32 and later). | 34 | Ubuntu 14.04 and later). |
| 35 | - A Vulkan driver (Mesa's are standard) or, failing that, OpenGL ES 3 through | 35 | - A Vulkan driver (Mesa's are standard) or, failing that, OpenGL ES 3 through |
| 36 | EGL. `WGPU_BACKEND=gl ./bin/snowbound ...` forces OpenGL. | 36 | EGL. `WGPU_BACKEND=gl ./bin/snowbound ...` forces OpenGL. |
| 37 | - fontconfig, and X11 or Wayland with libxkbcommon. | 37 | - fontconfig, and X11 or Wayland with libxkbcommon. |
crates/snowbound/linux/package.sh+3-3| ... | @@ -17,15 +17,15 @@ for arch do | ... | @@ -17,15 +17,15 @@ for arch do |
| 17 | triple=$arch-unknown-linux-gnu | 17 | triple=$arch-unknown-linux-gnu |
| 18 | variable=$(echo "$triple" | tr 'a-z-' 'A-Z_') | 18 | variable=$(echo "$triple" | tr 'a-z-' 'A-Z_') |
| 19 | linker="$tools/cc-$arch" | 19 | linker="$tools/cc-$arch" |
| 20 | # glibc 2.31 reaches back to Debian 11 and Ubuntu 20.04; zig rejects rustc's | 20 | # glibc 2.17, Rust's own floor, reaches back to RHEL 7, Debian 8 and Ubuntu 14.04; zig |
| 21 | # --target, --no-undefined-version and -O1. | 21 | # rejects rustc's --target, --no-undefined-version and -O1. |
| 22 | cat > "$linker" <<EOF | 22 | cat > "$linker" <<EOF |
| 23 | #!/bin/sh | 23 | #!/bin/sh |
| 24 | for arg do | 24 | for arg do |
| 25 | shift | 25 | shift |
| 26 | case "\$arg" in --target=*|-Wl,--no-undefined-version|-Wl,-O1) ;; *) set -- "\$@" "\$arg" ;; esac | 26 | case "\$arg" in --target=*|-Wl,--no-undefined-version|-Wl,-O1) ;; *) set -- "\$@" "\$arg" ;; esac |
| 27 | done | 27 | done |
| 28 | exec zig cc -target $arch-linux-gnu.2.31 "\$@" | 28 | exec zig cc -target $arch-linux-gnu.2.17 "\$@" |
| 29 | EOF | 29 | EOF |
| 30 | chmod +x "$linker" | 30 | chmod +x "$linker" |
| 31 | rustup target add "$triple" >/dev/null | 31 | rustup target add "$triple" >/dev/null |
crates/snowbound/src/commands.rs+47-33| ... | @@ -23,6 +23,9 @@ pub enum Id { | ... | @@ -23,6 +23,9 @@ pub enum Id { |
| 23 | PageVersions, | 23 | PageVersions, |
| 24 | CopyPageLink, | 24 | CopyPageLink, |
| 25 | ShowNotebook, | 25 | ShowNotebook, |
| 26 | ExportPdf, | ||
| 27 | ExportSectionPdf, | ||
| 28 | Print, | ||
| 26 | Undo, | 29 | Undo, |
| 27 | Redo, | 30 | Redo, |
| 28 | Cut, | 31 | Cut, |
| ... | @@ -44,7 +47,7 @@ pub enum Id { | ... | @@ -44,7 +47,7 @@ pub enum Id { |
| 44 | ActualSize, | 47 | ActualSize, |
| 45 | Sidebar, | 48 | Sidebar, |
| 46 | PageList, | 49 | PageList, |
| 47 | DarkPages, | 50 | PagesMatchTheme, |
| 48 | FullPageView, | 51 | FullPageView, |
| 49 | /// OneNote's Hide Spelling Errors, which leaves misspelled words unmarked. | 52 | /// OneNote's Hide Spelling Errors, which leaves misspelled words unmarked. |
| 50 | HideSpelling, | 53 | HideSpelling, |
| ... | @@ -71,6 +74,7 @@ pub enum Id { | ... | @@ -71,6 +74,7 @@ pub enum Id { |
| 71 | Eraser, | 74 | Eraser, |
| 72 | Lasso, | 75 | Lasso, |
| 73 | Shape(ShapeKind), | 76 | Shape(ShapeKind), |
| 77 | SnapToGrid, | ||
| 74 | Toggle(Toggle), | 78 | Toggle(Toggle), |
| 75 | Highlight, | 79 | Highlight, |
| 76 | FontColor, | 80 | FontColor, |
| ... | @@ -235,6 +239,15 @@ pub const COMMANDS: &[Command] = &[ | ... | @@ -235,6 +239,15 @@ pub const COMMANDS: &[Command] = &[ |
| 235 | NONE, | 239 | NONE, |
| 236 | NONE, | 240 | NONE, |
| 237 | ), | 241 | ), |
| 242 | row!(Id::ExportPdf, "Export as PDF…", NONE, NONE), | ||
| 243 | row!(Id::ExportSectionPdf, "Export Section as PDF…", NONE, NONE), | ||
| 244 | // Go to takes OneNote's Ctrl+P, and Pause its Ctrl+Alt+P. | ||
| 245 | row!( | ||
| 246 | Id::Print, | ||
| 247 | "Print…", | ||
| 248 | &[cmd('p').option().shift()], | ||
| 249 | &[cmd('p').option().shift()] | ||
| 250 | ), | ||
| 238 | row!(Id::Undo, "Undo", &[cmd('z')], &[cmd('z')]), | 251 | row!(Id::Undo, "Undo", &[cmd('z')], &[cmd('z')]), |
| 239 | row!( | 252 | row!( |
| 240 | Id::Redo, | 253 | Id::Redo, |
| ... | @@ -296,7 +309,7 @@ pub const COMMANDS: &[Command] = &[ | ... | @@ -296,7 +309,7 @@ pub const COMMANDS: &[Command] = &[ |
| 296 | ), | 309 | ), |
| 297 | row!(Id::Sidebar, "Notebook List", &[cmd('s').control()], NONE), | 310 | row!(Id::Sidebar, "Notebook List", &[cmd('s').control()], NONE), |
| 298 | row!(Id::PageList, "Page List", NONE, NONE), | 311 | row!(Id::PageList, "Page List", NONE, NONE), |
| 299 | row!(Id::DarkPages, "Dark Pages", NONE, NONE), | 312 | row!(Id::PagesMatchTheme, "Pages Match UI Theme", NONE, NONE), |
| 300 | row!(Id::HideSpelling, "Hide Spelling Errors", NONE, NONE), | 313 | row!(Id::HideSpelling, "Hide Spelling Errors", NONE, NONE), |
| 301 | row!( | 314 | row!( |
| 302 | Id::Spelling, | 315 | Id::Spelling, |
| ... | @@ -387,6 +400,7 @@ pub const COMMANDS: &[Command] = &[ | ... | @@ -387,6 +400,7 @@ pub const COMMANDS: &[Command] = &[ |
| 387 | row!(Id::Shape(ShapeKind::Arrow), "Arrow", NONE, NONE), | 400 | row!(Id::Shape(ShapeKind::Arrow), "Arrow", NONE, NONE), |
| 388 | row!(Id::Shape(ShapeKind::Rectangle), "Rectangle", NONE, NONE), | 401 | row!(Id::Shape(ShapeKind::Rectangle), "Rectangle", NONE, NONE), |
| 389 | row!(Id::Shape(ShapeKind::Ellipse), "Oval", NONE, NONE), | 402 | row!(Id::Shape(ShapeKind::Ellipse), "Oval", NONE, NONE), |
| 403 | row!(Id::SnapToGrid, "Snap To Grid", NONE, NONE), | ||
| 390 | row!(Id::Toggle(Toggle::Bold), "Bold", &[cmd('b')], &[cmd('b')]), | 404 | row!(Id::Toggle(Toggle::Bold), "Bold", &[cmd('b')], &[cmd('b')]), |
| 391 | row!( | 405 | row!( |
| 392 | Id::Toggle(Toggle::Italic), | 406 | Id::Toggle(Toggle::Italic), |
| ... | @@ -773,9 +787,14 @@ impl State { | ... | @@ -773,9 +787,14 @@ impl State { |
| 773 | .notebook() | 787 | .notebook() |
| 774 | .is_some_and(|library| library.catalog().is_some()), | 788 | .is_some_and(|library| library.catalog().is_some()), |
| 775 | ), | 789 | ), |
| 776 | Id::NewPage | Id::NewSubpage | Id::CopyPageLink | Id::Find | Id::Search => { | 790 | Id::NewPage |
| 777 | enabled(!modal && session.is_some()) | 791 | | Id::NewSubpage |
| 778 | } | 792 | | Id::CopyPageLink |
| 793 | | Id::Find | ||
| 794 | | Id::Search | ||
| 795 | | Id::ExportPdf | ||
| 796 | | Id::ExportSectionPdf | ||
| 797 | | Id::Print => enabled(!modal && session.is_some()), | ||
| 779 | Id::PageVersions => session.filter(|_| !modal).map_or( | 798 | Id::PageVersions => session.filter(|_| !modal).map_or( |
| 780 | Status { | 799 | Status { |
| 781 | enabled: false, | 800 | enabled: false, |
| ... | @@ -821,10 +840,14 @@ impl State { | ... | @@ -821,10 +840,14 @@ impl State { |
| 821 | Some(crate::pane::Pane::Tags { .. }) | 840 | Some(crate::pane::Pane::Tags { .. }) |
| 822 | )), | 841 | )), |
| 823 | }, | 842 | }, |
| 824 | Id::DarkPages => Status { | 843 | Id::PagesMatchTheme => Status { |
| 825 | enabled: !modal, | 844 | enabled: !modal, |
| 826 | checked: Some(!self.light_pages), | 845 | checked: Some(!self.light_pages), |
| 827 | }, | 846 | }, |
| 847 | Id::SnapToGrid => Status { | ||
| 848 | enabled: !modal, | ||
| 849 | checked: Some(self.view.snap_to_grid), | ||
| 850 | }, | ||
| 828 | Id::HideSpelling => Status { | 851 | Id::HideSpelling => Status { |
| 829 | enabled: !modal && self.spelling.is_some(), | 852 | enabled: !modal && self.spelling.is_some(), |
| 830 | checked: Some(self.hide_spelling), | 853 | checked: Some(self.hide_spelling), |
| ... | @@ -917,26 +940,6 @@ impl State { | ... | @@ -917,26 +940,6 @@ impl State { |
| 917 | Ok(()) | 940 | Ok(()) |
| 918 | } | 941 | } |
| 919 | 942 | ||
| 920 | /// Puts an encoded picture after the caret's paragraph at its size on a 96 dpi screen, | ||
| 921 | /// as OneNote 2010 inserts one. | ||
| 922 | pub(crate) fn insert_picture(&mut self, bytes: Vec<u8>) -> Result<(), Box<dyn Error>> { | ||
| 923 | let pixels = match draw::RasterImage::measure(&bytes) { | ||
| 924 | Ok(pixels) => pixels, | ||
| 925 | Err(_) => { | ||
| 926 | platform::alert( | ||
| 927 | "Couldn't insert the picture", | ||
| 928 | "Choose a PNG, JPEG or GIF picture.", | ||
| 929 | ); | ||
| 930 | return Ok(()); | ||
| 931 | } | ||
| 932 | }; | ||
| 933 | let response = self | ||
| 934 | .view | ||
| 935 | .insert_picture(bytes, pixels.map(|side| side as f32 * 0.75))?; | ||
| 936 | self.respond(response); | ||
| 937 | Ok(()) | ||
| 938 | } | ||
| 939 | |||
| 940 | /// The notebook the open section, or the notebook showing none, belongs to. | 943 | /// The notebook the open section, or the notebook showing none, belongs to. |
| 941 | fn notebook(&self) -> Option<&Arc<crate::Library>> { | 944 | fn notebook(&self) -> Option<&Arc<crate::Library>> { |
| 942 | self.session | 945 | self.session |
| ... | @@ -967,12 +970,10 @@ impl State { | ... | @@ -967,12 +970,10 @@ impl State { |
| 967 | return self.paper_page(color, self.view.editor.rule_lines(), None); | 970 | return self.paper_page(color, self.view.editor.rule_lines(), None); |
| 968 | } | 971 | } |
| 969 | Choice::Art(name) => { | 972 | Choice::Art(name) => { |
| 970 | let editor = &self.view.editor; | 973 | return self.with_art(name.and_then(canvas::template::find), |state, art| { |
| 971 | return self.paper_page( | 974 | let editor = &state.view.editor; |
| 972 | editor.page_color(), | 975 | state.paper_page(editor.page_color(), editor.rule_lines(), Some(art)) |
| 973 | editor.rule_lines(), | 976 | }); |
| 974 | Some(name.and_then(canvas::template::find)), | ||
| 975 | ); | ||
| 976 | } | 977 | } |
| 977 | Choice::RuleLines(lines) => { | 978 | Choice::RuleLines(lines) => { |
| 978 | let lines = lines.map(|index| canvas::template::RULE_LINES[index].1); | 979 | let lines = lines.map(|index| canvas::template::RULE_LINES[index].1); |
| ... | @@ -1031,6 +1032,14 @@ impl State { | ... | @@ -1031,6 +1032,14 @@ impl State { |
| 1031 | platform::reveal(&self.notebook().ok_or("No notebook is open")?.location); | 1032 | platform::reveal(&self.notebook().ok_or("No notebook is open")?.location); |
| 1032 | return Ok(()); | 1033 | return Ok(()); |
| 1033 | } | 1034 | } |
| 1035 | Id::ExportPdf | Id::ExportSectionPdf | Id::Print => { | ||
| 1036 | let scope = if id == Id::ExportSectionPdf { | ||
| 1037 | crate::print::Scope::Section | ||
| 1038 | } else { | ||
| 1039 | crate::print::Scope::Page | ||
| 1040 | }; | ||
| 1041 | return self.print(scope, id != Id::Print); | ||
| 1042 | } | ||
| 1034 | Id::Undo | Id::Redo => { | 1043 | Id::Undo | Id::Redo => { |
| 1035 | let response = self.view.undo(id == Id::Redo)?; | 1044 | let response = self.view.undo(id == Id::Redo)?; |
| 1036 | self.respond(response); | 1045 | self.respond(response); |
| ... | @@ -1147,7 +1156,12 @@ impl State { | ... | @@ -1147,7 +1156,12 @@ impl State { |
| 1147 | self.save_settings(); | 1156 | self.save_settings(); |
| 1148 | return Ok(()); | 1157 | return Ok(()); |
| 1149 | } | 1158 | } |
| 1150 | Id::DarkPages => { | 1159 | Id::SnapToGrid => { |
| 1160 | self.view.snap_to_grid = !self.view.snap_to_grid; | ||
| 1161 | self.save_settings(); | ||
| 1162 | return Ok(()); | ||
| 1163 | } | ||
| 1164 | Id::PagesMatchTheme => { | ||
| 1151 | self.light_pages = !self.light_pages; | 1165 | self.light_pages = !self.light_pages; |
| 1152 | self.follow_color_scheme(); | 1166 | self.follow_color_scheme(); |
| 1153 | self.save_settings(); | 1167 | self.save_settings(); |
crates/snowbound/src/desktop_linux.rs+9-2| ... | @@ -291,8 +291,15 @@ pub fn set_toplevel_icon(window: &Window) -> Option<()> { | ... | @@ -291,8 +291,15 @@ pub fn set_toplevel_icon(window: &Window) -> Option<()> { |
| 291 | ]); | 291 | ]); |
| 292 | } | 292 | } |
| 293 | } | 293 | } |
| 294 | // Safety: memfd_create returns a new descriptor this File then owns. | 294 | // Safety: memfd_create returns a new descriptor this File then owns. A system call, as |
| 295 | let fd = unsafe { libc::memfd_create(c"snowbound-icon".as_ptr(), libc::MFD_CLOEXEC) }; | 295 | // glibc wraps it only from 2.27. |
| 296 | let fd = unsafe { | ||
| 297 | libc::syscall( | ||
| 298 | libc::SYS_memfd_create, | ||
| 299 | c"snowbound-icon".as_ptr(), | ||
| 300 | libc::MFD_CLOEXEC, | ||
| 301 | ) | ||
| 302 | } as i32; | ||
| 296 | if fd < 0 { | 303 | if fd < 0 { |
| 297 | return None; | 304 | return None; |
| 298 | } | 305 | } |
crates/snowbound/src/guide.rs created+106| ... | @@ -0,0 +1,106 @@ | ||
| 1 | //! The Snowbound Guide, a notebook the app carries (`docs/guide-notebook`). It opens from a | ||
| 2 | //! copy in the user's documents, so the one inside the app is never edited. | ||
| 3 | |||
| 4 | use crate::{State, platform}; | ||
| 5 | use notebook::session::Notebook; | ||
| 6 | use std::{error::Error, path::Path}; | ||
| 7 | |||
| 8 | const NAME: &str = "Snowbound Guide"; | ||
| 9 | /// Whether the welcome screen offers the guide; off until Clover has read it through. | ||
| 10 | pub(crate) const OFFERED: bool = false; | ||
| 11 | |||
| 12 | macro_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 | |||
| 25 | const 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 | |||
| 34 | impl 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. | ||
| 49 | fn 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)] | ||
| 70 | mod tests { | ||
| 71 | use super::*; | ||
| 72 | |||
| 73 | /// The carried files are the committed guide's, and the copy reads as that notebook with | ||
| 74 | /// its tag art. | ||
| 75 | #[test] | ||
| 76 | fn the_guide_copies_whole() { | ||
| 77 | let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/guide-notebook"); | ||
| 78 | let mut committed: Vec<String> = std::fs::read_dir(root.join(NAME)) | ||
| 79 | .unwrap() | ||
| 80 | .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) | ||
| 81 | .filter(|name| !name.starts_with('.')) | ||
| 82 | .collect(); | ||
| 83 | committed.sort(); | ||
| 84 | let mut carried: Vec<String> = FILES.iter().map(|(name, _)| name.to_string()).collect(); | ||
| 85 | carried.sort(); | ||
| 86 | assert_eq!(carried, committed); | ||
| 87 | |||
| 88 | let temporary = | ||
| 89 | std::env::temp_dir().join(format!("snowbound-guide-{}", std::process::id())); | ||
| 90 | let _ = std::fs::remove_dir_all(&temporary); | ||
| 91 | let folder = temporary.join(NAME); | ||
| 92 | copy(&folder, &temporary.join("cache")).unwrap(); | ||
| 93 | let notebook = Notebook::open(&folder, temporary.join("cache")).unwrap(); | ||
| 94 | assert_eq!(notebook.catalog().sections.len(), 5); | ||
| 95 | let art = notebook.tag_art().unwrap(); | ||
| 96 | assert_eq!( | ||
| 97 | art.iter() | ||
| 98 | .map(|tag| (tag.name.as_str(), tag.shape)) | ||
| 99 | .collect::<Vec<_>>(), | ||
| 100 | [("Snowflake", 34)] | ||
| 101 | ); | ||
| 102 | let committed = std::fs::read_to_string(root.join(NAME).join(".snowbound/tags.json")); | ||
| 103 | assert!(committed.unwrap().contains(&art[0].art)); | ||
| 104 | std::fs::remove_dir_all(&temporary).unwrap(); | ||
| 105 | } | ||
| 106 | } | ||
crates/snowbound/src/linux.rs+44-2| ... | @@ -75,6 +75,9 @@ pub fn corner_radius(window: &Window) -> f32 { | ... | @@ -75,6 +75,9 @@ pub fn corner_radius(window: &Window) -> f32 { |
| 75 | /// Whether the window erases its own bottom corners to transparent pixels: under winit's | 75 | /// Whether the window erases its own bottom corners to transparent pixels: under winit's |
| 76 | /// Adwaita frame on GNOME's Wayland, which draws libadwaita's window edge. Mutter's X11 | 76 | /// Adwaita frame on GNOME's Wayland, which draws libadwaita's window edge. Mutter's X11 |
| 77 | /// frames stay square at the bottom, as libadwaita's own `ssd-frame` style has them. | 77 | /// frames stay square at the bottom, as libadwaita's own `ssd-frame` style has them. |
| 78 | /// Winit's theme already holds the window to the app's appearance. | ||
| 79 | pub fn follow_appearance(_: &Window, _: Theme) {} | ||
| 80 | |||
| 78 | pub fn cuts_corners() -> bool { | 81 | pub fn cuts_corners() -> bool { |
| 79 | sctk_adwaita::LIBADWAITA_EDGE.load(Ordering::Relaxed) | 82 | sctk_adwaita::LIBADWAITA_EDGE.load(Ordering::Relaxed) |
| 80 | } | 83 | } |
| ... | @@ -197,6 +200,26 @@ impl Clipboard { | ... | @@ -197,6 +200,26 @@ impl Clipboard { |
| 197 | Self::X11(clipboard) => clipboard.get_text()?, | 200 | Self::X11(clipboard) => clipboard.get_text()?, |
| 198 | }) | 201 | }) |
| 199 | } | 202 | } |
| 203 | |||
| 204 | pub fn get_files(&mut self) -> Vec<std::path::PathBuf> { | ||
| 205 | self.through_x11(|clipboard| clipboard.get().file_list().ok()) | ||
| 206 | .unwrap_or_default() | ||
| 207 | } | ||
| 208 | |||
| 209 | pub fn get_picture(&mut self) -> Option<Vec<u8>> { | ||
| 210 | self.through_x11(|clipboard| crate::paste::bitmap(clipboard.get_image().ok()?)) | ||
| 211 | } | ||
| 212 | |||
| 213 | /// Files and pictures come through X11 on Wayland too, whose clipboard Xwayland shares. | ||
| 214 | fn through_x11<T>( | ||
| 215 | &mut self, | ||
| 216 | read: impl FnOnce(&mut arboard::Clipboard) -> Option<T>, | ||
| 217 | ) -> Option<T> { | ||
| 218 | match self { | ||
| 219 | Self::Wayland(_) => read(&mut arboard::Clipboard::new().ok()?), | ||
| 220 | Self::X11(clipboard) => read(clipboard), | ||
| 221 | } | ||
| 222 | } | ||
| 200 | } | 223 | } |
| 201 | 224 | ||
| 202 | /// Whether the system draws the title bar: the window manager, or on Wayland the compositor. | 225 | /// Whether the system draws the title bar: the window manager, or on Wayland the compositor. |
| ... | @@ -254,7 +277,7 @@ fn desktop() -> Desktop { | ... | @@ -254,7 +277,7 @@ fn desktop() -> Desktop { |
| 254 | } | 277 | } |
| 255 | 278 | ||
| 256 | /// The theme over the window's backdrop, as the kit draws it. | 279 | /// The theme over the window's backdrop, as the kit draws it. |
| 257 | pub fn over_backdrop(theme: ui::Theme) -> ui::Theme { | 280 | pub fn over_backdrop(theme: ui::Theme, _: Theme) -> ui::Theme { |
| 258 | theme.over_backdrop() | 281 | theme.over_backdrop() |
| 259 | } | 282 | } |
| 260 | 283 | ||
| ... | @@ -767,7 +790,7 @@ pub fn cache_dir() -> Option<PathBuf> { | ... | @@ -767,7 +790,7 @@ pub fn cache_dir() -> Option<PathBuf> { |
| 767 | pub fn smb_mount(path: &std::path::Path) -> Option<crate::library::Mount> { | 790 | pub fn smb_mount(path: &std::path::Path) -> Option<crate::library::Mount> { |
| 768 | let mounts = std::fs::read_to_string("/proc/self/mounts").ok()?; | 791 | let mounts = std::fs::read_to_string("/proc/self/mounts").ok()?; |
| 769 | // The mount holding the path is the one at its longest ancestor. | 792 | // The mount holding the path is the one at its longest ancestor. |
| 770 | let (source, point, options, within) = mounts | 793 | let (source, _, options, within) = mounts |
| 771 | .lines() | 794 | .lines() |
| 772 | .filter_map(|line| { | 795 | .filter_map(|line| { |
| 773 | let mut fields = line.split_whitespace(); | 796 | let mut fields = line.split_whitespace(); |
| ... | @@ -873,6 +896,25 @@ pub fn settings_dir() -> Option<PathBuf> { | ... | @@ -873,6 +896,25 @@ pub fn settings_dir() -> Option<PathBuf> { |
| 873 | xdg_dir("XDG_CONFIG_HOME", ".config") | 896 | xdg_dir("XDG_CONFIG_HOME", ".config") |
| 874 | } | 897 | } |
| 875 | 898 | ||
| 899 | /// The XDG documents folder `user-dirs.dirs` names, or `~/Documents`. | ||
| 900 | pub fn documents_dir() -> Option<PathBuf> { | ||
| 901 | let home = PathBuf::from(std::env::var_os("HOME")?); | ||
| 902 | let dirs = xdg_base("XDG_CONFIG_HOME", ".config")?.join("user-dirs.dirs"); | ||
| 903 | let named = std::fs::read_to_string(dirs).ok().and_then(|text| { | ||
| 904 | text.lines().find_map(|line| { | ||
| 905 | let value = line | ||
| 906 | .strip_prefix("XDG_DOCUMENTS_DIR=")? | ||
| 907 | .trim() | ||
| 908 | .trim_matches('"'); | ||
| 909 | match value.strip_prefix("$HOME") { | ||
| 910 | Some(rest) => Some(home.join(rest.trim_start_matches('/'))), | ||
| 911 | None => Some(PathBuf::from(value)).filter(|path| path.is_absolute()), | ||
| 912 | } | ||
| 913 | }) | ||
| 914 | }); | ||
| 915 | Some(named.unwrap_or_else(|| home.join("Documents"))) | ||
| 916 | } | ||
| 917 | |||
| 876 | /// The app's folder in the XDG base directory `variable` names, or in `fallback` under home. | 918 | /// The app's folder in the XDG base directory `variable` names, or in `fallback` under home. |
| 877 | fn xdg_dir(variable: &str, fallback: &str) -> Option<PathBuf> { | 919 | fn xdg_dir(variable: &str, fallback: &str) -> Option<PathBuf> { |
| 878 | Some(xdg_base(variable, fallback)?.join("snowbound")) | 920 | Some(xdg_base(variable, fallback)?.join("snowbound")) |
crates/snowbound/src/macos.rs+76-6| ... | @@ -109,6 +109,9 @@ pub fn corner_radius(_: &Window) -> f32 { | ... | @@ -109,6 +109,9 @@ pub fn corner_radius(_: &Window) -> f32 { |
| 109 | 109 | ||
| 110 | /// AppKit clips the window's corners itself, except where the OpenGL surface covers | 110 | /// AppKit clips the window's corners itself, except where the OpenGL surface covers |
| 111 | /// 10.6's textured window, whose rounded bottom corners the app leaves transparent. | 111 | /// 10.6's textured window, whose rounded bottom corners the app leaves transparent. |
| 112 | /// Winit's theme already holds the window to the app's appearance. | ||
| 113 | pub fn follow_appearance(_: &Window, _: winit::window::Theme) {} | ||
| 114 | |||
| 112 | pub fn cuts_corners() -> bool { | 115 | pub fn cuts_corners() -> bool { |
| 113 | crate::aqua::before_lion() | 116 | crate::aqua::before_lion() |
| 114 | } | 117 | } |
| ... | @@ -185,6 +188,50 @@ impl Clipboard { | ... | @@ -185,6 +188,50 @@ impl Clipboard { |
| 185 | pub fn get_text(&mut self) -> Result<String, arboard::Error> { | 188 | pub fn get_text(&mut self) -> Result<String, arboard::Error> { |
| 186 | self.0.get_text() | 189 | self.0.get_text() |
| 187 | } | 190 | } |
| 191 | |||
| 192 | pub fn get_files(&mut self) -> Vec<std::path::PathBuf> { | ||
| 193 | self.0.get().file_list().unwrap_or_default() | ||
| 194 | } | ||
| 195 | |||
| 196 | /// The pasteboard's PNG as copied, keeping its resolution, or else its TIFF as a PNG. | ||
| 197 | pub fn get_picture(&mut self) -> Option<Vec<u8>> { | ||
| 198 | unsafe { | ||
| 199 | let board: Retained<AnyObject> = | ||
| 200 | msg_send_id![AnyClass::get("NSPasteboard")?, generalPasteboard]; | ||
| 201 | let data = |kind: &str| -> Option<Retained<AnyObject>> { | ||
| 202 | msg_send_id![&board, dataForType: &*NSString::from_str(kind)] | ||
| 203 | }; | ||
| 204 | let png = match data("public.png") { | ||
| 205 | Some(png) => png, | ||
| 206 | None => tiff_png(&*data("public.tiff")?)?, | ||
| 207 | }; | ||
| 208 | Some(ns_data_bytes(&png)) | ||
| 209 | } | ||
| 210 | } | ||
| 211 | } | ||
| 212 | |||
| 213 | /// TIFF `data` as PNG data, keeping its resolution. | ||
| 214 | unsafe 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 | |||
| 226 | unsafe fn ns_data_bytes(data: &AnyObject) -> Vec<u8> { | ||
| 227 | unsafe { | ||
| 228 | let length: usize = msg_send![data, length]; | ||
| 229 | if length == 0 { | ||
| 230 | return Vec::new(); | ||
| 231 | } | ||
| 232 | let bytes: *const u8 = msg_send![data, bytes]; | ||
| 233 | std::slice::from_raw_parts(bytes, length).to_vec() | ||
| 234 | } | ||
| 188 | } | 235 | } |
| 189 | 236 | ||
| 190 | /// AppKit draws the traffic lights. | 237 | /// AppKit draws the traffic lights. |
| ... | @@ -246,9 +293,14 @@ pub fn represent(window: &Window, file: Option<&std::path::Path>) { | ... | @@ -246,9 +293,14 @@ pub fn represent(window: &Window, file: Option<&std::path::Path>) { |
| 246 | } | 293 | } |
| 247 | } | 294 | } |
| 248 | 295 | ||
| 249 | /// The theme over the window's backdrop, as the kit draws it. | 296 | /// The theme over the window's backdrop, as the kit draws it. 10.6's textured gradient is |
| 250 | pub fn over_backdrop(theme: ui::Theme) -> ui::Theme { | 297 | /// light only, so a dark theme paints over it. |
| 251 | theme.over_backdrop() | 298 | pub fn over_backdrop(theme: ui::Theme, appearance: winit::window::Theme) -> ui::Theme { |
| 299 | if crate::aqua::before_lion() && appearance == winit::window::Theme::Dark { | ||
| 300 | theme | ||
| 301 | } else { | ||
| 302 | theme.over_backdrop() | ||
| 303 | } | ||
| 252 | } | 304 | } |
| 253 | 305 | ||
| 254 | /// None: the kit's own menus stand in for AppKit's. | 306 | /// None: the kit's own menus stand in for AppKit's. |
| ... | @@ -634,6 +686,10 @@ pub fn settings_dir() -> Option<std::path::PathBuf> { | ... | @@ -634,6 +686,10 @@ pub fn settings_dir() -> Option<std::path::PathBuf> { |
| 634 | ) | 686 | ) |
| 635 | } | 687 | } |
| 636 | 688 | ||
| 689 | pub fn documents_dir() -> Option<std::path::PathBuf> { | ||
| 690 | Some(std::path::PathBuf::from(std::env::var_os("HOME")?).join("Documents")) | ||
| 691 | } | ||
| 692 | |||
| 637 | /// Asks for a notebook folder or a notebook file with the system's open panel, titled | 693 | /// Asks for a notebook folder or a notebook file with the system's open panel, titled |
| 638 | /// `title`. | 694 | /// `title`. |
| 639 | pub fn pick_notebook(title: &str) -> Option<std::path::PathBuf> { | 695 | pub fn pick_notebook(title: &str) -> Option<std::path::PathBuf> { |
| ... | @@ -899,7 +955,10 @@ pub fn edit_date( | ... | @@ -899,7 +955,10 @@ pub fn edit_date( |
| 899 | DateField::Date => NSDatePickerElementFlags::NSDatePickerElementFlagYearMonthDay, | 955 | DateField::Date => NSDatePickerElementFlags::NSDatePickerElementFlagYearMonthDay, |
| 900 | DateField::Time => NSDatePickerElementFlags::NSDatePickerElementFlagHourMinute, | 956 | DateField::Time => NSDatePickerElementFlags::NSDatePickerElementFlagHourMinute, |
| 901 | }); | 957 | }); |
| 902 | picker.setPresentsCalendarOverlay(true); | 958 | // macOS 10.15's. |
| 959 | if msg_send![&picker, respondsToSelector: sel!(setPresentsCalendarOverlay:)] { | ||
| 960 | picker.setPresentsCalendarOverlay(true); | ||
| 961 | } | ||
| 903 | picker.setCalendar(Some(&calendar)); | 962 | picker.setCalendar(Some(&calendar)); |
| 904 | picker.setTimeZone(Some(&calendar.timeZone())); | 963 | picker.setTimeZone(Some(&calendar.timeZone())); |
| 905 | picker.setDateValue(&NSDate::dateWithTimeIntervalSince1970( | 964 | picker.setDateValue(&NSDate::dateWithTimeIntervalSince1970( |
| ... | @@ -1106,10 +1165,21 @@ pub fn text_colors(window: &Window) -> [[f32; 4]; 3] { | ... | @@ -1106,10 +1165,21 @@ pub fn text_colors(window: &Window) -> [[f32; 4]; 3] { |
| 1106 | let previous: Option<Retained<AnyObject>> = msg_send_id![class, currentAppearance]; | 1165 | let previous: Option<Retained<AnyObject>> = msg_send_id![class, currentAppearance]; |
| 1107 | let _: () = msg_send![class, setCurrentAppearance: &*appearance]; | 1166 | let _: () = msg_send![class, setCurrentAppearance: &*appearance]; |
| 1108 | let space = NSColorSpace::sRGBColorSpace(); | 1167 | let space = NSColorSpace::sRGBColorSpace(); |
| 1168 | let responds = | ||
| 1169 | |selector: Sel| -> bool { msg_send![NSColor::class(), respondsToSelector: selector] }; | ||
| 1170 | // macOS 14 and 10.14 introduced the first and last. | ||
| 1109 | let colors = [ | 1171 | let colors = [ |
| 1110 | NSColor::textInsertionPointColor(), | 1172 | if responds(sel!(textInsertionPointColor)) { |
| 1173 | NSColor::textInsertionPointColor() | ||
| 1174 | } else { | ||
| 1175 | NSColor::textColor() | ||
| 1176 | }, | ||
| 1111 | NSColor::selectedTextBackgroundColor(), | 1177 | NSColor::selectedTextBackgroundColor(), |
| 1112 | NSColor::unemphasizedSelectedTextBackgroundColor(), | 1178 | if responds(sel!(unemphasizedSelectedTextBackgroundColor)) { |
| 1179 | NSColor::unemphasizedSelectedTextBackgroundColor() | ||
| 1180 | } else { | ||
| 1181 | msg_send_id![NSColor::class(), secondarySelectedControlColor] | ||
| 1182 | }, | ||
| 1113 | ] | 1183 | ] |
| 1114 | .map(|color| { | 1184 | .map(|color| { |
| 1115 | let color = color | 1185 | let color = color |
crates/snowbound/src/main.rs+115-28| ... | @@ -10,6 +10,7 @@ mod conflict_render; | ... | @@ -10,6 +10,7 @@ mod conflict_render; |
| 10 | #[cfg(target_os = "linux")] | 10 | #[cfg(target_os = "linux")] |
| 11 | #[path = "desktop_linux.rs"] | 11 | #[path = "desktop_linux.rs"] |
| 12 | mod desktop; | 12 | mod desktop; |
| 13 | mod guide; | ||
| 13 | mod history; | 14 | mod history; |
| 14 | #[cfg_attr(not(target_os = "macos"), path = "icloud_linux.rs")] | 15 | #[cfg_attr(not(target_os = "macos"), path = "icloud_linux.rs")] |
| 15 | #[cfg_attr(target_os = "macos", path = "icloud_macos.rs")] | 16 | #[cfg_attr(target_os = "macos", path = "icloud_macos.rs")] |
| ... | @@ -29,10 +30,16 @@ mod navigation; | ... | @@ -29,10 +30,16 @@ mod navigation; |
| 29 | mod options; | 30 | mod options; |
| 30 | mod palette; | 31 | mod palette; |
| 31 | mod pane; | 32 | mod pane; |
| 33 | mod paste; | ||
| 32 | #[cfg_attr(target_os = "linux", path = "linux.rs")] | 34 | #[cfg_attr(target_os = "linux", path = "linux.rs")] |
| 33 | #[cfg_attr(target_os = "macos", path = "macos.rs")] | 35 | #[cfg_attr(target_os = "macos", path = "macos.rs")] |
| 34 | #[cfg_attr(windows, path = "windows.rs")] | 36 | #[cfg_attr(windows, path = "windows.rs")] |
| 35 | mod platform; | 37 | mod platform; |
| 38 | mod 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")] | ||
| 42 | mod printer; | ||
| 36 | mod recording; | 43 | mod recording; |
| 37 | mod rename; | 44 | mod rename; |
| 38 | mod screenshot; | 45 | mod screenshot; |
| ... | @@ -96,6 +103,9 @@ const TAB_ROW: f32 = 28.0; | ... | @@ -96,6 +103,9 @@ const TAB_ROW: f32 = 28.0; |
| 96 | /// Width of the section colour around the page, and the rows' margin at the window's sides. | 103 | /// Width of the section colour around the page, and the rows' margin at the window's sides. |
| 97 | const FRAME: f32 = 6.0; | 104 | const FRAME: f32 = 6.0; |
| 98 | const PAGE_LIST: f32 = 240.0; | 105 | const 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. | ||
| 108 | const MIN_SIZE: [f32; 2] = [600.0, 400.0]; | ||
| 99 | /// Extensions of the pictures Insert, Picture offers: those the page both stores and draws. | 109 | /// Extensions of the pictures Insert, Picture offers: those the page both stores and draws. |
| 100 | const PICTURE_TYPES: [&str; 4] = ["png", "jpg", "jpeg", "gif"]; | 110 | const PICTURE_TYPES: [&str; 4] = ["png", "jpg", "jpeg", "gif"]; |
| 101 | /// Font sizes the size box offers, OneNote's list in points. | 111 | /// Font sizes the size box offers, OneNote's list in points. |
| ... | @@ -163,7 +173,8 @@ const PILL_MARGIN: f32 = 3.0; | ... | @@ -163,7 +173,8 @@ const PILL_MARGIN: f32 = 3.0; |
| 163 | const DATE_UNCHOSEN: &str = "Choose another date or time."; | 173 | const DATE_UNCHOSEN: &str = "Choose another date or time."; |
| 164 | const DATE_OUT_OF_RANGE: &str = "This date is outside the notebook's supported range."; | 174 | const DATE_OUT_OF_RANGE: &str = "This date is outside the notebook's supported range."; |
| 165 | 175 | ||
| 166 | #[derive(Debug)] | 176 | type Continuation = Box<dyn FnOnce(&mut State) -> Result<(), Box<dyn Error>> + Send>; |
| 177 | |||
| 167 | enum UserEvent { | 178 | enum UserEvent { |
| 168 | Quit, | 179 | Quit, |
| 169 | /// Text AppKit inserts outside key events, such as the character palette's. | 180 | /// Text AppKit inserts outside key events, such as the character palette's. |
| ... | @@ -187,6 +198,8 @@ enum UserEvent { | ... | @@ -187,6 +198,8 @@ enum UserEvent { |
| 187 | /// The desktop's colours changed, where the window system doesn't say so itself. | 198 | /// The desktop's colours changed, where the window system doesn't say so itself. |
| 188 | #[cfg_attr(target_os = "macos", allow(dead_code))] | 199 | #[cfg_attr(target_os = "macos", allow(dead_code))] |
| 189 | Appearance, | 200 | Appearance, |
| 201 | /// What follows work a thread of its own finished, run on the event loop's. | ||
| 202 | Then(Continuation), | ||
| 190 | /// The iCloud account signed out, signed in or switched, or iCloud Drive was turned off. | 203 | /// The iCloud account signed out, signed in or switched, or iCloud Drive was turned off. |
| 191 | #[cfg_attr(not(target_os = "macos"), allow(dead_code))] | 204 | #[cfg_attr(not(target_os = "macos"), allow(dead_code))] |
| 192 | ICloudAccount, | 205 | ICloudAccount, |
| ... | @@ -209,11 +222,21 @@ impl Clipboard { | ... | @@ -209,11 +222,21 @@ impl Clipboard { |
| 209 | Ok(()) | 222 | Ok(()) |
| 210 | } | 223 | } |
| 211 | 224 | ||
| 212 | fn get_text(&mut self) -> Result<String, Box<dyn Error>> { | 225 | fn pasted(&mut self) -> Option<paste::Pasted> { |
| 213 | Ok(match self { | 226 | match self { |
| 214 | Self::System(clipboard) => clipboard.get_text()?, | 227 | Self::System(clipboard) => { |
| 215 | Self::Memory(held) => held.clone(), | 228 | let files = clipboard.get_files(); |
| 216 | }) | 229 | if !files.is_empty() { |
| 230 | Some(paste::Pasted::Files(files)) | ||
| 231 | } else if let Some(text) = clipboard.get_text().ok().filter(|text| !text.is_empty()) | ||
| 232 | { | ||
| 233 | Some(paste::Pasted::Text(text)) | ||
| 234 | } else { | ||
| 235 | clipboard.get_picture().map(paste::Pasted::Picture) | ||
| 236 | } | ||
| 237 | } | ||
| 238 | Self::Memory(held) => Some(paste::Pasted::Text(held.clone())), | ||
| 239 | } | ||
| 217 | } | 240 | } |
| 218 | } | 241 | } |
| 219 | 242 | ||
| ... | @@ -226,6 +249,8 @@ impl std::task::Wake for Redraw { | ... | @@ -226,6 +249,8 @@ impl std::task::Wake for Redraw { |
| 226 | #[derive(Debug)] | 249 | #[derive(Debug)] |
| 227 | enum Replay { | 250 | enum Replay { |
| 228 | Input(ui::Event), | 251 | Input(ui::Event), |
| 252 | /// A trackpad pinch scaling the page by the factor. | ||
| 253 | Pinch(f32), | ||
| 229 | /// Paints the next frame into a PNG as well as the window. | 254 | /// Paints the next frame into a PNG as well as the window. |
| 230 | Snapshot(PathBuf), | 255 | Snapshot(PathBuf), |
| 231 | /// Writes the window's accessibility tree as text. | 256 | /// Writes the window's accessibility tree as text. |
| ... | @@ -496,6 +521,8 @@ enum Command { | ... | @@ -496,6 +521,8 @@ enum Command { |
| 496 | NewNotebook, | 521 | NewNotebook, |
| 497 | /// Opens the notebooks in the app's iCloud Drive folder, making one there if it has none. | 522 | /// Opens the notebooks in the app's iCloud Drive folder, making one there if it has none. |
| 498 | UseICloud, | 523 | UseICloud, |
| 524 | /// Opens the Snowbound Guide from the user's documents, copying it there first. | ||
| 525 | OpenGuide, | ||
| 499 | /// Adds Snowbound to the app menu. | 526 | /// Adds Snowbound to the app menu. |
| 500 | #[cfg(target_os = "linux")] | 527 | #[cfg(target_os = "linux")] |
| 501 | Install, | 528 | Install, |
| ... | @@ -622,6 +649,14 @@ struct State { | ... | @@ -622,6 +649,14 @@ struct State { |
| 622 | hide_spelling: bool, | 649 | hide_spelling: bool, |
| 623 | /// Options' "Use pen pressure sensitivity": a tablet pen's strokes follow its pressure. | 650 | /// Options' "Use pen pressure sensitivity": a tablet pen's strokes follow its pressure. |
| 624 | pen_pressure: bool, | 651 | pen_pressure: bool, |
| 652 | /// Options' Default font, which new text and titles take. | ||
| 653 | default_font: settings::DefaultFont, | ||
| 654 | /// Options' "Page tabs appear on the left". | ||
| 655 | page_tabs_left: bool, | ||
| 656 | /// Options' "Navigation bar appears on the left" off: the notebooks on the right. | ||
| 657 | navigation_bar_right: bool, | ||
| 658 | /// Servers Open Notebook from Server signed in to, latest first. | ||
| 659 | servers: Vec<String>, | ||
| 625 | /// The word the Spelling pane shows. | 660 | /// The word the Spelling pane shows. |
| 626 | correction: Option<canvas::interaction::Correction>, | 661 | correction: Option<canvas::interaction::Correction>, |
| 627 | /// The strip's fill with the window focused and not, continuing the system's title bar. | 662 | /// The strip's fill with the window focused and not, continuing the system's title bar. |
| ... | @@ -681,6 +716,8 @@ struct State { | ... | @@ -681,6 +716,8 @@ struct State { |
| 681 | initial_layouts: Vec<(onestore::ExGuid, onestore::document::Layout)>, | 716 | initial_layouts: Vec<(onestore::ExGuid, onestore::document::Layout)>, |
| 682 | initial_date: Option<u64>, | 717 | initial_date: Option<u64>, |
| 683 | occluded: bool, | 718 | occluded: bool, |
| 719 | /// The least width last given the window. | ||
| 720 | min_width: f32, | ||
| 684 | ime_allowed: bool, | 721 | ime_allowed: bool, |
| 685 | clipboard: Clipboard, | 722 | clipboard: Clipboard, |
| 686 | access_adapter: accesskit_winit::Adapter, | 723 | access_adapter: accesskit_winit::Adapter, |
| ... | @@ -746,8 +783,7 @@ impl State { | ... | @@ -746,8 +783,7 @@ impl State { |
| 746 | Input::Notebooks { .. } => "Snowbound".into(), | 783 | Input::Notebooks { .. } => "Snowbound".into(), |
| 747 | }) | 784 | }) |
| 748 | .with_inner_size(LogicalSize::new(1180.0, 760.0)) | 785 | .with_inner_size(LogicalSize::new(1180.0, 760.0)) |
| 749 | // The page list and a page beside it, under the toolbar's text group. | 786 | .with_min_inner_size(LogicalSize::new(MIN_SIZE[0], MIN_SIZE[1])), |
| 750 | .with_min_inner_size(LogicalSize::new(600.0, 400.0)), | ||
| 751 | )?, | 787 | )?, |
| 752 | ); | 788 | ); |
| 753 | platform::install_title_bar(&window); | 789 | platform::install_title_bar(&window); |
| ... | @@ -872,6 +908,7 @@ impl State { | ... | @@ -872,6 +908,7 @@ impl State { |
| 872 | .color_scheme | 908 | .color_scheme |
| 873 | .theme() | 909 | .theme() |
| 874 | .unwrap_or_else(|| platform::appearance(&window)); | 910 | .unwrap_or_else(|| platform::appearance(&window)); |
| 911 | platform::follow_appearance(&window, appearance); | ||
| 875 | let mut ui = Ui::new( | 912 | let mut ui = Ui::new( |
| 876 | theme(appearance, stored.light_pages, backdrop), | 913 | theme(appearance, stored.light_pages, backdrop), |
| 877 | platform::double_click_interval(), | 914 | platform::double_click_interval(), |
| ... | @@ -975,6 +1012,7 @@ impl State { | ... | @@ -975,6 +1012,7 @@ impl State { |
| 975 | initial_date, | 1012 | initial_date, |
| 976 | initial_layouts, | 1013 | initial_layouts, |
| 977 | occluded: false, | 1014 | occluded: false, |
| 1015 | min_width: MIN_SIZE[0], | ||
| 978 | ime_allowed: true, | 1016 | ime_allowed: true, |
| 979 | clipboard, | 1017 | clipboard, |
| 980 | loads: mpsc::channel(), | 1018 | loads: mpsc::channel(), |
| ... | @@ -991,8 +1029,13 @@ impl State { | ... | @@ -991,8 +1029,13 @@ impl State { |
| 991 | spelling, | 1029 | spelling, |
| 992 | hide_spelling: stored.hide_spelling, | 1030 | hide_spelling: stored.hide_spelling, |
| 993 | pen_pressure: !stored.ignore_pen_pressure, | 1031 | pen_pressure: !stored.ignore_pen_pressure, |
| 1032 | default_font: stored.default_font, | ||
| 1033 | page_tabs_left: stored.page_tabs_left, | ||
| 1034 | navigation_bar_right: stored.navigation_bar_right, | ||
| 1035 | servers: stored.servers, | ||
| 994 | correction: None, | 1036 | correction: None, |
| 995 | }; | 1037 | }; |
| 1038 | state.view.snap_to_grid = !stored.ignore_grid; | ||
| 996 | // A notebook opened from its server that couldn't sign in asks to, as the Finder does. | 1039 | // A notebook opened from its server that couldn't sign in asks to, as the Finder does. |
| 997 | let unsigned = state.notebooks.iter().find(|library| { | 1040 | let unsigned = state.notebooks.iter().find(|library| { |
| 998 | library.notebook.is_err() && library::server_address(&library.location).is_some() | 1041 | library.notebook.is_err() && library::server_address(&library.location).is_some() |
| ... | @@ -1119,6 +1162,7 @@ impl State { | ... | @@ -1119,6 +1162,7 @@ impl State { |
| 1119 | 1162 | ||
| 1120 | /// Takes `appearance`'s colours. | 1163 | /// Takes `appearance`'s colours. |
| 1121 | fn set_appearance(&mut self, appearance: winit::window::Theme) { | 1164 | fn set_appearance(&mut self, appearance: winit::window::Theme) { |
| 1165 | platform::follow_appearance(&self.window, appearance); | ||
| 1122 | self.ui.theme = theme(appearance, self.light_pages, self.surface.translucent()); | 1166 | self.ui.theme = theme(appearance, self.light_pages, self.surface.translucent()); |
| 1123 | self.titlebar = platform::titlebar(appearance) | 1167 | self.titlebar = platform::titlebar(appearance) |
| 1124 | .filter(|_| !self.surface.translucent()) | 1168 | .filter(|_| !self.surface.translucent()) |
| ... | @@ -1619,11 +1663,11 @@ impl State { | ... | @@ -1619,11 +1663,11 @@ impl State { |
| 1619 | role: Some(accesskit::Role::Toolbar), | 1663 | role: Some(accesskit::Role::Toolbar), |
| 1620 | ..Spec::default() | 1664 | ..Spec::default() |
| 1621 | }; | 1665 | }; |
| 1622 | if strip_row { | 1666 | let row = if strip_row { |
| 1623 | self.ui.open_as(strip(), spec); | 1667 | self.ui.open_as(strip(), spec) |
| 1624 | } else { | 1668 | } else { |
| 1625 | self.ui.open("toolbar", spec); | 1669 | self.ui.open("toolbar", spec) |
| 1626 | } | 1670 | }; |
| 1627 | let lead = if title { platform::LEADING } else { FRAME }; | 1671 | let lead = if title { platform::LEADING } else { FRAME }; |
| 1628 | self.ui.leaf( | 1672 | self.ui.leaf( |
| 1629 | "lead", | 1673 | "lead", |
| ... | @@ -1633,7 +1677,22 @@ impl State { | ... | @@ -1633,7 +1677,22 @@ impl State { |
| 1633 | }, | 1677 | }, |
| 1634 | ); | 1678 | ); |
| 1635 | if tools { | 1679 | if tools { |
| 1680 | // The groups fold in the room the window's controls leave, and whatever still | ||
| 1681 | // overflows is cut there, so the controls always show. | ||
| 1682 | let edge = (TITLE - ui::shell::TOOL) / 2.0; | ||
| 1683 | self.ui.open( | ||
| 1684 | "tools", | ||
| 1685 | Spec { | ||
| 1686 | flags: Flags::CLIP, | ||
| 1687 | size: [fill(), px(TITLE)], | ||
| 1688 | pad: [0.0, edge], | ||
| 1689 | offset: [0.0, -edge], | ||
| 1690 | gap: GAP, | ||
| 1691 | ..Spec::default() | ||
| 1692 | }, | ||
| 1693 | ); | ||
| 1636 | self.tools(theme); | 1694 | self.tools(theme); |
| 1695 | self.ui.close(); | ||
| 1637 | } else { | 1696 | } else { |
| 1638 | self.ui.leaf( | 1697 | self.ui.leaf( |
| 1639 | "space", | 1698 | "space", |
| ... | @@ -1654,6 +1713,15 @@ impl State { | ... | @@ -1654,6 +1713,15 @@ impl State { |
| 1654 | }, | 1713 | }, |
| 1655 | ); | 1714 | ); |
| 1656 | self.ui.close(); | 1715 | self.ui.close(); |
| 1716 | let narrowest = self | ||
| 1717 | .ui | ||
| 1718 | .narrowest(row) | ||
| 1719 | .map_or(MIN_SIZE[0], |row| row.ceil().max(MIN_SIZE[0])); | ||
| 1720 | if narrowest != self.min_width { | ||
| 1721 | self.min_width = narrowest; | ||
| 1722 | self.window | ||
| 1723 | .set_min_inner_size(Some(LogicalSize::new(narrowest, MIN_SIZE[1]))); | ||
| 1724 | } | ||
| 1657 | Ok(()) | 1725 | Ok(()) |
| 1658 | } | 1726 | } |
| 1659 | 1727 | ||
| ... | @@ -2146,13 +2214,17 @@ impl State { | ... | @@ -2146,13 +2214,17 @@ impl State { |
| 2146 | ) | 2214 | ) |
| 2147 | .or(choice); | 2215 | .or(choice); |
| 2148 | // The Draw tab's tools, which Insert's menus list for when they fold, before Insert. | 2216 | // The Draw tab's tools, which Insert's menus list for when they fold, before Insert. |
| 2217 | // OneNote's shape gallery ends with Snap To Grid. | ||
| 2149 | let shapes = [ | 2218 | let shapes = [ |
| 2150 | ShapeKind::Line, | 2219 | ShapeKind::Line, |
| 2151 | ShapeKind::Arrow, | 2220 | ShapeKind::Arrow, |
| 2152 | ShapeKind::Rectangle, | 2221 | ShapeKind::Rectangle, |
| 2153 | ShapeKind::Ellipse, | 2222 | ShapeKind::Ellipse, |
| 2154 | ] | 2223 | ] |
| 2155 | .map(|kind| Run(Cmd::Shape(kind))); | 2224 | .map(|kind| Run(Cmd::Shape(kind))) |
| 2225 | .into_iter() | ||
| 2226 | .chain([Rule, Run(Cmd::SnapToGrid)]) | ||
| 2227 | .collect::<Vec<_>>(); | ||
| 2156 | let drawing = [ | 2228 | let drawing = [ |
| 2157 | Run(Cmd::SelectType), | 2229 | Run(Cmd::SelectType), |
| 2158 | Open("pen", Cmd::Pen), | 2230 | Open("pen", Cmd::Pen), |
| ... | @@ -2161,7 +2233,7 @@ impl State { | ... | @@ -2161,7 +2233,7 @@ impl State { |
| 2161 | Rule, | 2233 | Rule, |
| 2162 | ] | 2234 | ] |
| 2163 | .into_iter() | 2235 | .into_iter() |
| 2164 | .chain(shapes); | 2236 | .chain(shapes.iter().copied()); |
| 2165 | let inserted = [Cmd::Picture, Cmd::Link, Cmd::Date, Cmd::Equation]; | 2237 | let inserted = [Cmd::Picture, Cmd::Link, Cmd::Date, Cmd::Equation]; |
| 2166 | let more = [ | 2238 | let more = [ |
| 2167 | Cmd::ScreenClipping, | 2239 | Cmd::ScreenClipping, |
| ... | @@ -2320,7 +2392,7 @@ impl State { | ... | @@ -2320,7 +2392,7 @@ impl State { |
| 2320 | let views = [ | 2392 | let views = [ |
| 2321 | Run(Cmd::Sidebar), | 2393 | Run(Cmd::Sidebar), |
| 2322 | Run(Cmd::PageList), | 2394 | Run(Cmd::PageList), |
| 2323 | Run(Cmd::DarkPages), | 2395 | Run(Cmd::PagesMatchTheme), |
| 2324 | Run(Cmd::FullPageView), | 2396 | Run(Cmd::FullPageView), |
| 2325 | Rule, | 2397 | Rule, |
| 2326 | Run(Cmd::HideSpelling), | 2398 | Run(Cmd::HideSpelling), |
| ... | @@ -2702,6 +2774,7 @@ impl State { | ... | @@ -2702,6 +2774,7 @@ impl State { |
| 2702 | Command::OpenFromServer(location) => self.open_server(location.as_deref()), | 2774 | Command::OpenFromServer(location) => self.open_server(location.as_deref()), |
| 2703 | Command::NewNotebook => self.new_notebook()?, | 2775 | Command::NewNotebook => self.new_notebook()?, |
| 2704 | Command::UseICloud => self.use_icloud()?, | 2776 | Command::UseICloud => self.use_icloud()?, |
| 2777 | Command::OpenGuide => self.open_guide()?, | ||
| 2705 | #[cfg(target_os = "linux")] | 2778 | #[cfg(target_os = "linux")] |
| 2706 | Command::Install => desktop::install(), | 2779 | Command::Install => desktop::install(), |
| 2707 | Command::CloseNotebook(library) => self.close_notebook(&library), | 2780 | Command::CloseNotebook(library) => self.close_notebook(&library), |
| ... | @@ -2723,12 +2796,7 @@ impl State { | ... | @@ -2723,12 +2796,7 @@ impl State { |
| 2723 | Command::Template(choice) => self.apply_template(choice)?, | 2796 | Command::Template(choice) => self.apply_template(choice)?, |
| 2724 | Command::Page(Request::EditDate(field)) => self.edit_date(field)?, | 2797 | Command::Page(Request::EditDate(field)) => self.edit_date(field)?, |
| 2725 | Command::Page(Request::Copy(text)) => self.clipboard.set_text(text)?, | 2798 | Command::Page(Request::Copy(text)) => self.clipboard.set_text(text)?, |
| 2726 | Command::Page(Request::Paste) => { | 2799 | Command::Page(Request::Paste) => self.paste()?, |
| 2727 | let text = self.clipboard.get_text()?; | ||
| 2728 | let language = canvas::language::lcid(&platform::input_language()); | ||
| 2729 | let response = self.view.paste(&text, language)?; | ||
| 2730 | self.respond(response); | ||
| 2731 | } | ||
| 2732 | Command::Page(Request::OpenLink(address)) => self.open_link(&address)?, | 2800 | Command::Page(Request::OpenLink(address)) => self.open_link(&address)?, |
| 2733 | Command::Page(Request::OpenAttachment(file)) => self.open_attachment(&file)?, | 2801 | Command::Page(Request::OpenAttachment(file)) => self.open_attachment(&file)?, |
| 2734 | Command::Page(Request::Play { file, at_ms }) => self.play(&file, at_ms)?, | 2802 | Command::Page(Request::Play { file, at_ms }) => self.play(&file, at_ms)?, |
| ... | @@ -3608,7 +3676,14 @@ impl State { | ... | @@ -3608,7 +3676,14 @@ impl State { |
| 3608 | /// Zooms the page by `factor` about the pointer, when it is over the page. | 3676 | /// Zooms the page by `factor` about the pointer, when it is over the page. |
| 3609 | fn pinch(&mut self, factor: f32) -> Result<(), Box<dyn Error>> { | 3677 | fn pinch(&mut self, factor: f32) -> Result<(), Box<dyn Error>> { |
| 3610 | if self.over_page() { | 3678 | if self.over_page() { |
| 3611 | let response = self.view.pinch(factor)?; | 3679 | // The pointer here, as the page's own lags by the moves queued for the next frame. |
| 3680 | let [left, top, ..] = self.ui.rect(page()).unwrap_or_default(); | ||
| 3681 | let scale = self.ui.scale(); | ||
| 3682 | let anchor = [ | ||
| 3683 | (self.pointer[0] - left) * scale, | ||
| 3684 | (self.pointer[1] - top) * scale, | ||
| 3685 | ]; | ||
| 3686 | let response = self.view.pinch(factor, anchor)?; | ||
| 3612 | self.respond(response); | 3687 | self.respond(response); |
| 3613 | } | 3688 | } |
| 3614 | Ok(()) | 3689 | Ok(()) |
| ... | @@ -3706,7 +3781,7 @@ fn theme(appearance: winit::window::Theme, light_pages: bool, backdrop: bool) -> | ... | @@ -3706,7 +3781,7 @@ fn theme(appearance: winit::window::Theme, light_pages: bool, backdrop: bool) -> |
| 3706 | [theme.paper, theme.paper_ink] = [light.paper, light.paper_ink]; | 3781 | [theme.paper, theme.paper_ink] = [light.paper, light.paper_ink]; |
| 3707 | } | 3782 | } |
| 3708 | if backdrop { | 3783 | if backdrop { |
| 3709 | platform::over_backdrop(theme) | 3784 | platform::over_backdrop(theme, appearance) |
| 3710 | } else { | 3785 | } else { |
| 3711 | theme | 3786 | theme |
| 3712 | } | 3787 | } |
| ... | @@ -4921,6 +4996,15 @@ impl ApplicationHandler<UserEvent> for App { | ... | @@ -4921,6 +4996,15 @@ impl ApplicationHandler<UserEvent> for App { |
| 4921 | self.close(event_loop); | 4996 | self.close(event_loop); |
| 4922 | return; | 4997 | return; |
| 4923 | } | 4998 | } |
| 4999 | UserEvent::Then(then) => { | ||
| 5000 | if let Some(state) = &mut self.state { | ||
| 5001 | if let Err(error) = then(state) { | ||
| 5002 | eprintln!("{error}"); | ||
| 5003 | } | ||
| 5004 | state.window.request_redraw(); | ||
| 5005 | } | ||
| 5006 | return; | ||
| 5007 | } | ||
| 4924 | UserEvent::ICloudAccount => { | 5008 | UserEvent::ICloudAccount => { |
| 4925 | if let Some(state) = &mut self.state { | 5009 | if let Some(state) = &mut self.state { |
| 4926 | state.icloud_account_changed(); | 5010 | state.icloud_account_changed(); |
| ... | @@ -4966,6 +5050,11 @@ impl ApplicationHandler<UserEvent> for App { | ... | @@ -4966,6 +5050,11 @@ impl ApplicationHandler<UserEvent> for App { |
| 4966 | if let Some(state) = &mut self.state { | 5050 | if let Some(state) = &mut self.state { |
| 4967 | match replay { | 5051 | match replay { |
| 4968 | Replay::Input(event) => state.input(event), | 5052 | Replay::Input(event) => state.input(event), |
| 5053 | Replay::Pinch(factor) => { | ||
| 5054 | if let Err(error) = state.pinch(factor) { | ||
| 5055 | eprintln!("{error}"); | ||
| 5056 | } | ||
| 5057 | } | ||
| 4969 | Replay::Snapshot(path) => state.snapshot = Some(path), | 5058 | Replay::Snapshot(path) => state.snapshot = Some(path), |
| 4970 | Replay::Accessibility(path) => { | 5059 | Replay::Accessibility(path) => { |
| 4971 | if let Err(error) = state.write_accessibility(&path) { | 5060 | if let Err(error) = state.write_accessibility(&path) { |
| ... | @@ -5202,16 +5291,13 @@ impl ApplicationHandler<UserEvent> for App { | ... | @@ -5202,16 +5291,13 @@ impl ApplicationHandler<UserEvent> for App { |
| 5202 | 5291 | ||
| 5203 | fn write_png(path: &Path, size: [u32; 2], pixels: &[u8]) -> Result<(), Box<dyn Error>> { | 5292 | fn write_png(path: &Path, size: [u32; 2], pixels: &[u8]) -> Result<(), Box<dyn Error>> { |
| 5204 | let partial = path.with_extension("partial"); | 5293 | let partial = path.with_extension("partial"); |
| 5205 | let mut encoder = png::Encoder::new(std::fs::File::create(&partial)?, size[0], size[1]); | 5294 | std::fs::write(&partial, paste::png(size, pixels)?)?; |
| 5206 | encoder.set_color(png::ColorType::Rgba); | ||
| 5207 | encoder.set_depth(png::BitDepth::Eight); | ||
| 5208 | encoder.write_header()?.write_image_data(pixels)?; | ||
| 5209 | std::fs::rename(partial, path)?; | 5295 | std::fs::rename(partial, path)?; |
| 5210 | Ok(()) | 5296 | Ok(()) |
| 5211 | } | 5297 | } |
| 5212 | 5298 | ||
| 5213 | /// Feeds a development script to the window from another thread, one command per line | 5299 | /// 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 |
| 5215 | /// TEXT`, `modifiers [shift] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`, | 5301 | /// TEXT`, `modifiers [shift] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`, |
| 5216 | /// `accessibility TEXT_PATH`, `appearance light|dark`, `resize WIDTH HEIGHT` and `quit`. | 5302 | /// `accessibility TEXT_PATH`, `appearance light|dark`, `resize WIDTH HEIGHT` and `quit`. |
| 5217 | fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dyn Error>> { | 5303 | fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dyn Error>> { |
| ... | @@ -5241,6 +5327,7 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy | ... | @@ -5241,6 +5327,7 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy |
| 5241 | "none" => None, | 5327 | "none" => None, |
| 5242 | level => Some(level.parse()?), | 5328 | level => Some(level.parse()?), |
| 5243 | }))), | 5329 | }))), |
| 5330 | "pinch" => Ok(Replay::Pinch(rest.parse()?)), | ||
| 5244 | "press" => Ok(Replay::Input(button(true))), | 5331 | "press" => Ok(Replay::Input(button(true))), |
| 5245 | "release" => Ok(Replay::Input(button(false))), | 5332 | "release" => Ok(Replay::Input(button(false))), |
| 5246 | "key" => Ok(Replay::Input(ui::Event::Key { | 5333 | "key" => Ok(Replay::Input(ui::Event::Key { |
crates/snowbound/src/manage.rs+61-27| ... | @@ -7,7 +7,7 @@ use notebook::session::Notebook; | ... | @@ -7,7 +7,7 @@ use notebook::session::Notebook; |
| 7 | use onestore::{ | 7 | use onestore::{ |
| 8 | ExGuid, PageCreation, PageEdit, | 8 | ExGuid, PageCreation, PageEdit, |
| 9 | op::{Edit, Op, PageOp, SectionOp}, | 9 | op::{Edit, Op, PageOp, SectionOp}, |
| 10 | page::{Page, PageObject}, | 10 | page::{Image, Page, PageObject}, |
| 11 | }; | 11 | }; |
| 12 | use std::{error::Error, sync::Arc}; | 12 | use std::{error::Error, sync::Arc}; |
| 13 | 13 | ||
| ... | @@ -124,24 +124,59 @@ impl State { | ... | @@ -124,24 +124,59 @@ impl State { |
| 124 | &mut self, | 124 | &mut self, |
| 125 | choice: crate::templates::Choice, | 125 | choice: crate::templates::Choice, |
| 126 | ) -> Result<(), Box<dyn Error>> { | 126 | ) -> Result<(), Box<dyn Error>> { |
| 127 | self.persist()?; | 127 | use crate::templates::Choice; |
| 128 | let session = self.session.as_ref().ok_or("No section is open")?; | 128 | let template = match choice { |
| 129 | let ops = template_ops(&session.section.page(session.space)?, choice)?; | 129 | Choice::Template(name) => { |
| 130 | self.edit_page(ops) | 130 | Some(canvas::template::find(name).ok_or("That template is not available")?) |
| 131 | } | ||
| 132 | _ => None, | ||
| 133 | }; | ||
| 134 | self.with_art(template, move |state, art| { | ||
| 135 | state.persist()?; | ||
| 136 | let session = state.session.as_ref().ok_or("No section is open")?; | ||
| 137 | let ops = template_ops(&session.section.page(session.space)?, choice, art)?; | ||
| 138 | state.edit_page(ops) | ||
| 139 | }) | ||
| 140 | } | ||
| 141 | |||
| 142 | /// Calls `then` with `template`'s pictures, or none without one. Rasterizing and encoding | ||
| 143 | /// them takes seconds on a slow machine, so they are made on a thread of their own, and | ||
| 144 | /// `then` runs on this one after if the page they were chosen for is still open. | ||
| 145 | pub(crate) fn with_art( | ||
| 146 | &mut self, | ||
| 147 | template: Option<&'static Template>, | ||
| 148 | then: impl FnOnce(&mut State, Vec<Image>) -> Result<(), Box<dyn Error>> + Send + 'static, | ||
| 149 | ) -> Result<(), Box<dyn Error>> { | ||
| 150 | let Some(template) = template else { | ||
| 151 | return then(self, Vec::new()); | ||
| 152 | }; | ||
| 153 | let space = self.session.as_ref().ok_or("No section is open")?.space; | ||
| 154 | let proxy = self.proxy.clone(); | ||
| 155 | std::thread::spawn(move || { | ||
| 156 | let art = template.pictures().map_err(|error| error.to_string()); | ||
| 157 | let _ = proxy.send_event(crate::UserEvent::Then(Box::new(move |state| { | ||
| 158 | if state | ||
| 159 | .session | ||
| 160 | .as_ref() | ||
| 161 | .is_some_and(|open| open.space == space) | ||
| 162 | { | ||
| 163 | then(state, art?) | ||
| 164 | } else { | ||
| 165 | Ok(()) | ||
| 166 | } | ||
| 167 | }))); | ||
| 168 | }); | ||
| 169 | Ok(()) | ||
| 131 | } | 170 | } |
| 132 | 171 | ||
| 133 | /// View, Page Color and Rule Lines: gives the open page `color`, a COLORREF, | 172 | /// View, Page Color and Rule Lines: gives the open page `color`, a COLORREF, |
| 134 | /// `rule_lines` and, when given, `template`'s art or none in place of the art it had, as | 173 | /// `rule_lines` and, when given, `art` in place of the art it had, as one undo step. |
| 135 | /// one undo step. | ||
| 136 | pub(crate) fn paper_page( | 174 | pub(crate) fn paper_page( |
| 137 | &mut self, | 175 | &mut self, |
| 138 | color: Option<u32>, | 176 | color: Option<u32>, |
| 139 | rule_lines: Option<onestore::page::RuleLines>, | 177 | rule_lines: Option<onestore::page::RuleLines>, |
| 140 | art: Option<Option<&Template>>, | 178 | art: Option<Vec<Image>>, |
| 141 | ) -> Result<(), Box<dyn Error>> { | 179 | ) -> Result<(), Box<dyn Error>> { |
| 142 | let art = art | ||
| 143 | .map(|template| template.map_or(Ok(Vec::new()), Template::pictures)) | ||
| 144 | .transpose()?; | ||
| 145 | let response = self.view.set_paper(color, rule_lines, art)?; | 180 | let response = self.view.set_paper(color, rule_lines, art)?; |
| 146 | self.respond(response); | 181 | self.respond(response); |
| 147 | Ok(()) | 182 | Ok(()) |
| ... | @@ -507,14 +542,11 @@ impl State { | ... | @@ -507,14 +542,11 @@ impl State { |
| 507 | pub fn template_ops( | 542 | pub fn template_ops( |
| 508 | page: &Page, | 543 | page: &Page, |
| 509 | choice: crate::templates::Choice, | 544 | choice: crate::templates::Choice, |
| 545 | art: Vec<Image>, | ||
| 510 | ) -> Result<Vec<PageOp>, Box<dyn Error>> { | 546 | ) -> Result<Vec<PageOp>, Box<dyn Error>> { |
| 511 | use crate::templates::Choice; | 547 | use crate::templates::Choice; |
| 512 | let mut ops = match choice { | 548 | let mut ops = match choice { |
| 513 | Choice::Template(name) => art_ops( | 549 | Choice::Template(_) | Choice::Color(_) => art_ops(page, art), |
| 514 | page, | ||
| 515 | Some(canvas::template::find(name).ok_or("That template is not available")?), | ||
| 516 | )?, | ||
| 517 | Choice::Color(_) => art_ops(page, None)?, | ||
| 518 | Choice::More | Choice::Dismiss | Choice::Colors => return Ok(Vec::new()), | 550 | Choice::More | Choice::Dismiss | Choice::Colors => return Ok(Vec::new()), |
| 519 | }; | 551 | }; |
| 520 | match choice { | 552 | match choice { |
| ... | @@ -538,9 +570,9 @@ pub fn template_ops( | ... | @@ -538,9 +570,9 @@ pub fn template_ops( |
| 538 | Ok(ops) | 570 | Ok(ops) |
| 539 | } | 571 | } |
| 540 | 572 | ||
| 541 | /// The ops giving `page` `template`'s art (OneNote's pictures' places, our recreations' | 573 | /// The ops giving `page` `art`, a template's pictures, in place of the background pictures |
| 542 | /// bytes), or none, in place of the background pictures it had. | 574 | /// it had. |
| 543 | pub fn art_ops(page: &Page, template: Option<&Template>) -> Result<Vec<PageOp>, Box<dyn Error>> { | 575 | fn art_ops(page: &Page, art: Vec<Image>) -> Vec<PageOp> { |
| 544 | let mut ops: Vec<PageOp> = page | 576 | let mut ops: Vec<PageOp> = page |
| 545 | .objects | 577 | .objects |
| 546 | .iter() | 578 | .iter() |
| ... | @@ -560,13 +592,11 @@ pub fn art_ops(page: &Page, template: Option<&Template>) -> Result<Vec<PageOp>, | ... | @@ -560,13 +592,11 @@ pub fn art_ops(page: &Page, template: Option<&Template>) -> Result<Vec<PageOp>, |
| 560 | _ => true, | 592 | _ => true, |
| 561 | }) | 593 | }) |
| 562 | .map(PageObject::id); | 594 | .map(PageObject::id); |
| 563 | for image in template.map_or(Ok(Vec::new()), Template::pictures)? { | 595 | ops.extend(art.into_iter().map(|image| PageOp::Add { |
| 564 | ops.push(PageOp::Add { | 596 | object: PageObject::Image(image), |
| 565 | object: PageObject::Image(image), | 597 | before: under, |
| 566 | before: under, | 598 | })); |
| 567 | }); | 599 | ops |
| 568 | } | ||
| 569 | Ok(ops) | ||
| 570 | } | 600 | } |
| 571 | 601 | ||
| 572 | #[cfg(test)] | 602 | #[cfg(test)] |
| ... | @@ -695,7 +725,11 @@ mod tests { | ... | @@ -695,7 +725,11 @@ mod tests { |
| 695 | unreachable!() | 725 | unreachable!() |
| 696 | }; | 726 | }; |
| 697 | let background = |space: ExGuid, choice: Choice| { | 727 | let background = |space: ExGuid, choice: Choice| { |
| 698 | template_ops(&page(&file, space), choice) | 728 | let art = match choice { |
| 729 | Choice::Template(name) => canvas::template::find(name).unwrap().pictures().unwrap(), | ||
| 730 | _ => Vec::new(), | ||
| 731 | }; | ||
| 732 | template_ops(&page(&file, space), choice, art) | ||
| 699 | .unwrap() | 733 | .unwrap() |
| 700 | .into_iter() | 734 | .into_iter() |
| 701 | .map(|op| Op::Page { space, op }) | 735 | .map(|op| Op::Page { space, op }) |
crates/snowbound/src/media_macos.rs+7-2| ... | @@ -232,9 +232,14 @@ fn refused(device: &str, pane: &str) -> String { | ... | @@ -232,9 +232,14 @@ fn refused(device: &str, pane: &str) -> String { |
| 232 | } | 232 | } |
| 233 | 233 | ||
| 234 | /// Whether the system refused media of `kind`: AVAuthorizationStatusRestricted or Denied. | 234 | /// Whether the system refused media of `kind`: AVAuthorizationStatusRestricted or Denied. |
| 235 | /// Before macOS 10.14 it never asks, and nothing is refused. | ||
| 235 | fn denied(kind: &NSString) -> bool { | 236 | fn denied(kind: &NSString) -> bool { |
| 236 | let status: isize = | 237 | let device = class("AVCaptureDevice"); |
| 237 | unsafe { msg_send![class("AVCaptureDevice"), authorizationStatusForMediaType: kind] }; | 238 | let selector = sel!(authorizationStatusForMediaType:); |
| 239 | if !unsafe { msg_send![device, respondsToSelector: selector] } { | ||
| 240 | return false; | ||
| 241 | } | ||
| 242 | let status: isize = unsafe { msg_send![device, authorizationStatusForMediaType: kind] }; | ||
| 238 | matches!(status, 1 | 2) | 243 | matches!(status, 1 | 2) |
| 239 | } | 244 | } |
| 240 | 245 |
crates/snowbound/src/menubar.rs+6-1| ... | @@ -66,6 +66,11 @@ const MENUS: &[Item] = &[ | ... | @@ -66,6 +66,11 @@ const MENUS: &[Item] = &[ |
| 66 | C(Id::PageVersions), | 66 | C(Id::PageVersions), |
| 67 | C(Id::CopyPageLink), | 67 | C(Id::CopyPageLink), |
| 68 | C(Id::ShowNotebook), | 68 | C(Id::ShowNotebook), |
| 69 | S, | ||
| 70 | C(Id::ExportPdf), | ||
| 71 | C(Id::ExportSectionPdf), | ||
| 72 | S, | ||
| 73 | C(Id::Print), | ||
| 69 | ], | 74 | ], |
| 70 | ), | 75 | ), |
| 71 | Item::Menu( | 76 | Item::Menu( |
| ... | @@ -111,7 +116,7 @@ const MENUS: &[Item] = &[ | ... | @@ -111,7 +116,7 @@ const MENUS: &[Item] = &[ |
| 111 | C(Id::PageList), | 116 | C(Id::PageList), |
| 112 | S, | 117 | S, |
| 113 | C(Id::PageColor), | 118 | C(Id::PageColor), |
| 114 | C(Id::DarkPages), | 119 | C(Id::PagesMatchTheme), |
| 115 | C(Id::HideSpelling), | 120 | C(Id::HideSpelling), |
| 116 | C(Id::Spelling), | 121 | C(Id::Spelling), |
| 117 | ], | 122 | ], |
crates/snowbound/src/options.rs+5-11| ... | @@ -164,13 +164,13 @@ impl State { | ... | @@ -164,13 +164,13 @@ impl State { |
| 164 | match options.page { | 164 | match options.page { |
| 165 | Page::General => { | 165 | Page::General => { |
| 166 | heading(ui, &theme, "User Interface Options"); | 166 | heading(ui, &theme, "User Interface Options"); |
| 167 | field(ui, "Color scheme:", |ui| { | 167 | field(ui, "Appearance:", |ui| { |
| 168 | let combo = ui.id("combo"); | 168 | let combo = ui.id("combo"); |
| 169 | let current = SCHEMES | 169 | let current = SCHEMES |
| 170 | .iter() | 170 | .iter() |
| 171 | .find(|(scheme, _)| *scheme == options.color_scheme) | 171 | .find(|(scheme, _)| *scheme == options.color_scheme) |
| 172 | .map_or("", |(_, name)| name); | 172 | .map_or("", |(_, name)| name); |
| 173 | ui::shell::combo(ui, "combo", "Color scheme", current, 140.0, schemes(), true); | 173 | ui::shell::combo(ui, "combo", "Appearance", current, 140.0, schemes(), true); |
| 174 | let items = SCHEMES.map(|(scheme, name)| Item { | 174 | let items = SCHEMES.map(|(scheme, name)| Item { |
| 175 | text: name, | 175 | text: name, |
| 176 | checked: Some(scheme == options.color_scheme), | 176 | checked: Some(scheme == options.color_scheme), |
| ... | @@ -244,16 +244,10 @@ impl State { | ... | @@ -244,16 +244,10 @@ impl State { |
| 244 | } | 244 | } |
| 245 | Page::Display => { | 245 | Page::Display => { |
| 246 | heading(ui, &theme, "Display"); | 246 | heading(ui, &theme, "Display"); |
| 247 | let dark = !options.light_pages; | 247 | let matching = !options.light_pages; |
| 248 | if ui::check_box( | 248 | if ui::check_box(ui, "pages-match-theme", "Pages match UI theme", matching).clicked |
| 249 | ui, | ||
| 250 | "dark-pages", | ||
| 251 | "Pages appear dark in the Dark color scheme", | ||
| 252 | dark, | ||
| 253 | ) | ||
| 254 | .clicked | ||
| 255 | { | 249 | { |
| 256 | options.light_pages = dark; | 250 | options.light_pages = matching; |
| 257 | } | 251 | } |
| 258 | } | 252 | } |
| 259 | Page::Advanced => { | 253 | Page::Advanced => { |
crates/snowbound/src/paste.rs created+184| ... | @@ -0,0 +1,184 @@ | ||
| 1 | //! Pictures and files from the clipboard, and pictures inserted, as OneNote 2010 places them. | ||
| 2 | |||
| 3 | use crate::{State, platform}; | ||
| 4 | use 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. | ||
| 9 | pub(crate) enum Pasted { | ||
| 10 | Files(Vec<PathBuf>), | ||
| 11 | Text(String), | ||
| 12 | Picture(Vec<u8>), | ||
| 13 | } | ||
| 14 | |||
| 15 | impl 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). | ||
| 60 | fn 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. | ||
| 66 | fn 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"))] | ||
| 103 | pub(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. | ||
| 112 | pub(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)] | ||
| 122 | mod 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 | |||
| 4 | use crate::{State, UserEvent, platform, printer}; | ||
| 5 | use onestore::page::Page; | ||
| 6 | use std::{error::Error, path::PathBuf, sync::Arc}; | ||
| 7 | |||
| 8 | #[derive(Clone, Copy, PartialEq, Eq)] | ||
| 9 | pub enum Scope { | ||
| 10 | Page, | ||
| 11 | Section, | ||
| 12 | } | ||
| 13 | |||
| 14 | impl 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. | ||
| 83 | fn 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`. | ||
| 99 | pub(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)] | ||
| 110 | mod 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 | |||
| 4 | use std::{collections::HashMap, error::Error, time::Duration}; | ||
| 5 | use winit::window::Window; | ||
| 6 | use zbus::zvariant::{Fd, OwnedValue, Value}; | ||
| 7 | |||
| 8 | /// Countries whose paper is US Letter; everywhere else prints on A4. | ||
| 9 | const 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. | ||
| 14 | pub 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. | ||
| 23 | fn 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. | ||
| 38 | pub 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 | |||
| 50 | fn 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)] | ||
| 101 | mod 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 | |||
| 5 | use objc2::{ | ||
| 6 | msg_send, msg_send_id, | ||
| 7 | rc::Retained, | ||
| 8 | runtime::{AnyClass, AnyObject, Bool, Sel}, | ||
| 9 | sel, | ||
| 10 | }; | ||
| 11 | use objc2_foundation::{MainThreadMarker, NSSize, NSString}; | ||
| 12 | use std::error::Error; | ||
| 13 | use 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")] | ||
| 20 | unsafe extern "C" {} | ||
| 21 | |||
| 22 | fn 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. | ||
| 27 | pub 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. | ||
| 41 | fn 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`. | ||
| 69 | pub 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)] | ||
| 96 | mod 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 | |||
| 4 | use std::{error::Error, os::windows::ffi::OsStrExt}; | ||
| 5 | use windows_sys::Win32::{ | ||
| 6 | Globalization::{GetLocaleInfoEx, LOCALE_IPAPERSIZE}, | ||
| 7 | UI::{Shell::ShellExecuteW, WindowsAndMessaging::SW_SHOWNORMAL}, | ||
| 8 | }; | ||
| 9 | use winit::{ | ||
| 10 | raw_window_handle::{HasWindowHandle, RawWindowHandle}, | ||
| 11 | window::Window, | ||
| 12 | }; | ||
| 13 | |||
| 14 | fn 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. | ||
| 19 | pub 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 | |||
| 37 | pub fn print(window: &Window, pdf: &[u8], title: &str) -> Result<(), Box<dyn Error>> { | ||
| 38 | let path = crate::print::spooled(pdf, title)?; | ||
| 39 | let owner = match window.window_handle()?.as_raw() { | ||
| 40 | RawWindowHandle::Win32(handle) => handle.hwnd.get() as _, | ||
| 41 | _ => std::ptr::null_mut(), | ||
| 42 | }; | ||
| 43 | for verb in ["print", "open"] { | ||
| 44 | // SAFETY: the strings are NUL-terminated and outlive the call. | ||
| 45 | let done = unsafe { | ||
| 46 | ShellExecuteW( | ||
| 47 | owner, | ||
| 48 | wide(verb).as_ptr(), | ||
| 49 | wide(&path).as_ptr(), | ||
| 50 | std::ptr::null(), | ||
| 51 | std::ptr::null(), | ||
| 52 | SW_SHOWNORMAL, | ||
| 53 | ) | ||
| 54 | }; | ||
| 55 | // ShellExecute's success is a pseudo-handle above 32. | ||
| 56 | if done as isize > 32 { | ||
| 57 | return Ok(()); | ||
| 58 | } | ||
| 59 | } | ||
| 60 | Err("No application on this computer prints or opens PDFs.".into()) | ||
| 61 | } | ||
crates/snowbound/src/screenshot.rs+2-2| ... | @@ -40,8 +40,8 @@ pub(crate) fn prepare() { | ... | @@ -40,8 +40,8 @@ pub(crate) fn prepare() { |
| 40 | 40 | ||
| 41 | impl State { | 41 | impl State { |
| 42 | /// Writes `PREFIX-light.png` and `PREFIX-dark.png`, the page unfocused so no caret shows; | 42 | /// Writes `PREFIX-light.png` and `PREFIX-dark.png`, the page unfocused so no caret shows; |
| 43 | /// as a Snow Leopard window, `PREFIX-snow-leopard.png` and, not key, | 43 | /// as a Snow Leopard window, at 10.6's one pixel per point, also `PREFIX-light-other.png` |
| 44 | /// `PREFIX-snow-leopard-other.png`, at 10.6's one pixel per point. | 44 | /// and `PREFIX-dark-other.png` as the window looks when not key. |
| 45 | pub(crate) fn screenshot(&mut self, prefix: &Path) -> Result<(), Box<dyn Error>> { | 45 | pub(crate) fn screenshot(&mut self, prefix: &Path) -> Result<(), Box<dyn Error>> { |
| 46 | let size = match std::env::var("SNOWBOUND_SCREENSHOT_SIZE") { | 46 | let size = match std::env::var("SNOWBOUND_SCREENSHOT_SIZE") { |
| 47 | Ok(size) => size | 47 | Ok(size) => size |
crates/snowbound/src/screenshot/snow_leopard.rs+17-15| ... | @@ -30,21 +30,23 @@ pub(super) fn screenshot( | ... | @@ -30,21 +30,23 @@ pub(super) fn screenshot( |
| 30 | size: [f32; 2], | 30 | size: [f32; 2], |
| 31 | ) -> Result<(), Box<dyn Error>> { | 31 | ) -> Result<(), Box<dyn Error>> { |
| 32 | let title = state.window.title(); | 32 | let title = state.window.title(); |
| 33 | for (name, key) in [("snow-leopard", true), ("snow-leopard-other", false)] { | 33 | for (name, appearance) in [("light", Appearance::Light), ("dark", Appearance::Dark)] { |
| 34 | state.window.set_theme(Some(Appearance::Light)); | 34 | for (suffix, key) in [("", true), ("-other", false)] { |
| 35 | state.set_appearance(Appearance::Light); | 35 | state.window.set_theme(Some(appearance)); |
| 36 | // Over the textured window's gradient, as `install_backdrop` leaves it on 10.6. | 36 | state.set_appearance(appearance); |
| 37 | state.ui.theme = theme(Appearance::Light, state.light_pages, true); | 37 | // Over the textured window's gradient, as `install_backdrop` leaves it on 10.6. |
| 38 | state.titlebar = [state.ui.theme.strip; 2]; | 38 | state.ui.theme = theme(appearance, state.light_pages, true); |
| 39 | state.ui.window_focused = key; | 39 | state.titlebar = [state.ui.theme.strip; 2]; |
| 40 | state.settle_frame(size, 1.0)?; | 40 | state.ui.window_focused = key; |
| 41 | let content = state.capture()?; | 41 | state.settle_frame(size, 1.0)?; |
| 42 | let bar = title_bar(state, &title, size[0], key)?; | 42 | let content = state.capture()?; |
| 43 | let [width, height] = [size[0] as usize, size[1] as usize]; | 43 | let bar = title_bar(state, &title, size[0], key)?; |
| 44 | let window = window(&content, &bar, [width, height], key)?; | 44 | let [width, height] = [size[0] as usize, size[1] as usize]; |
| 45 | let (canvas, shadowed) = shadow(&window, [width, height + TITLE_LINE], key); | 45 | let window = window(&content, &bar, [width, height], key)?; |
| 46 | let path = PathBuf::from(format!("{}-{name}.png", prefix.display())); | 46 | let (canvas, shadowed) = shadow(&window, [width, height + TITLE_LINE], key); |
| 47 | write_png(&path, canvas.map(|side| side as u32), &shadowed)?; | 47 | let path = PathBuf::from(format!("{}-{name}{suffix}.png", prefix.display())); |
| 48 | write_png(&path, canvas.map(|side| side as u32), &shadowed)?; | ||
| 49 | } | ||
| 48 | } | 50 | } |
| 49 | Ok(()) | 51 | Ok(()) |
| 50 | } | 52 | } |
crates/snowbound/src/settings.rs+43-1| ... | @@ -20,7 +20,7 @@ pub struct Settings { | ... | @@ -20,7 +20,7 @@ pub struct Settings { |
| 20 | /// first saved. | 20 | /// first saved. |
| 21 | pub user_name: Option<String>, | 21 | pub user_name: Option<String>, |
| 22 | pub color_scheme: ColorScheme, | 22 | pub color_scheme: ColorScheme, |
| 23 | /// Keeps pages white in the Dark color scheme. | 23 | /// Keeps pages white in a dark appearance: Pages Match UI Theme off. |
| 24 | pub light_pages: bool, | 24 | pub light_pages: bool, |
| 25 | /// Leaves misspelled words unmarked, as OneNote's Hide Spelling Errors. | 25 | /// Leaves misspelled words unmarked, as OneNote's Hide Spelling Errors. |
| 26 | pub hide_spelling: bool, | 26 | pub hide_spelling: bool, |
| ... | @@ -33,6 +33,35 @@ pub struct Settings { | ... | @@ -33,6 +33,35 @@ pub struct Settings { |
| 33 | /// Draws a tablet pen's strokes at its width, as OneNote 2010 with "Use pen pressure | 33 | /// Draws a tablet pen's strokes at its width, as OneNote 2010 with "Use pen pressure |
| 34 | /// sensitivity" off. | 34 | /// sensitivity" off. |
| 35 | pub ignore_pen_pressure: bool, | 35 | pub ignore_pen_pressure: bool, |
| 36 | /// Places and moves things where they are dropped, as OneNote's Snap To Grid off. | ||
| 37 | pub ignore_grid: bool, | ||
| 38 | /// Options' Default font: new text's font and size, and new titles' font. | ||
| 39 | pub default_font: DefaultFont, | ||
| 40 | /// OneNote's "Page tabs appear on the left". | ||
| 41 | pub page_tabs_left: bool, | ||
| 42 | /// OneNote's "Navigation bar appears on the left" turned off: the notebooks on the right. | ||
| 43 | pub navigation_bar_right: bool, | ||
| 44 | /// Servers Open Notebook from Server signed in to, latest first, as addresses without a | ||
| 45 | /// password. | ||
| 46 | pub servers: Vec<String>, | ||
| 47 | } | ||
| 48 | |||
| 49 | /// The font and size OneNote's Default font gives new text. | ||
| 50 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | ||
| 51 | #[serde(default)] | ||
| 52 | pub struct DefaultFont { | ||
| 53 | pub face: String, | ||
| 54 | pub size: f32, | ||
| 55 | } | ||
| 56 | |||
| 57 | impl Default for DefaultFont { | ||
| 58 | /// OneNote 2010's: Calibri 11. | ||
| 59 | fn default() -> Self { | ||
| 60 | Self { | ||
| 61 | face: "Calibri".into(), | ||
| 62 | size: 11.0, | ||
| 63 | } | ||
| 64 | } | ||
| 36 | } | 65 | } |
| 37 | 66 | ||
| 38 | /// What the toolbar's buttons apply from their menus' last picks. | 67 | /// What the toolbar's buttons apply from their menus' last picks. |
| ... | @@ -144,6 +173,11 @@ impl crate::State { | ... | @@ -144,6 +173,11 @@ impl crate::State { |
| 144 | tags: (self.tags != canvas::editor::NoteTag::defaults()).then(|| self.tags.clone()), | 173 | tags: (self.tags != canvas::editor::NoteTag::defaults()).then(|| self.tags.clone()), |
| 145 | manual_updates: !self.updates.automatic(), | 174 | manual_updates: !self.updates.automatic(), |
| 146 | ignore_pen_pressure: !self.pen_pressure, | 175 | ignore_pen_pressure: !self.pen_pressure, |
| 176 | ignore_grid: !self.view.snap_to_grid, | ||
| 177 | default_font: self.default_font.clone(), | ||
| 178 | page_tabs_left: self.page_tabs_left, | ||
| 179 | navigation_bar_right: self.navigation_bar_right, | ||
| 180 | servers: self.servers.clone(), | ||
| 147 | }; | 181 | }; |
| 148 | if let Err(error) = settings.save(path) { | 182 | if let Err(error) = settings.save(path) { |
| 149 | eprintln!("Cannot save the settings in {}: {error}", path.display()); | 183 | eprintln!("Cannot save the settings in {}: {error}", path.display()); |
| ... | @@ -204,6 +238,14 @@ mod tests { | ... | @@ -204,6 +238,14 @@ mod tests { |
| 204 | }]), | 238 | }]), |
| 205 | manual_updates: true, | 239 | manual_updates: true, |
| 206 | ignore_pen_pressure: true, | 240 | ignore_pen_pressure: true, |
| 241 | ignore_grid: true, | ||
| 242 | default_font: DefaultFont { | ||
| 243 | face: "Georgia".into(), | ||
| 244 | size: 14.0, | ||
| 245 | }, | ||
| 246 | page_tabs_left: true, | ||
| 247 | navigation_bar_right: true, | ||
| 248 | servers: vec!["smb://clover@nas/Notes".into()], | ||
| 207 | }; | 249 | }; |
| 208 | settings.save(&path).unwrap(); | 250 | settings.save(&path).unwrap(); |
| 209 | assert_eq!(Settings::load(&path), settings); | 251 | assert_eq!(Settings::load(&path), settings); |
crates/snowbound/src/sidebar.rs+7-1| ... | @@ -1074,7 +1074,7 @@ impl crate::State { | ... | @@ -1074,7 +1074,7 @@ impl crate::State { |
| 1074 | } | 1074 | } |
| 1075 | 1075 | ||
| 1076 | /// The window below the title bar on the first run, or once every notebook is closed: | 1076 | /// The window below the title bar on the first run, or once every notebook is closed: |
| 1077 | /// what is missing, and the two ways to start. | 1077 | /// what is missing, and the ways to start. |
| 1078 | pub(crate) fn welcome(&mut self, theme: &Theme) { | 1078 | pub(crate) fn welcome(&mut self, theme: &Theme) { |
| 1079 | let id = self.ui.id("welcome"); | 1079 | let id = self.ui.id("welcome"); |
| 1080 | #[cfg(target_os = "linux")] | 1080 | #[cfg(target_os = "linux")] |
| ... | @@ -1121,6 +1121,12 @@ impl crate::State { | ... | @@ -1121,6 +1121,12 @@ impl crate::State { |
| 1121 | crate::Command::OpenFromServer(None), | 1121 | crate::Command::OpenFromServer(None), |
| 1122 | ), | 1122 | ), |
| 1123 | ]) | 1123 | ]) |
| 1124 | .chain(crate::guide::OFFERED.then_some(( | ||
| 1125 | "guide", | ||
| 1126 | art::PAGE, | ||
| 1127 | "Open the Snowbound Guide", | ||
| 1128 | crate::Command::OpenGuide, | ||
| 1129 | ))) | ||
| 1124 | .chain(install) | 1130 | .chain(install) |
| 1125 | .collect(), | 1131 | .collect(), |
| 1126 | ); | 1132 | ); |
crates/snowbound/src/update.rs+28-2| ... | @@ -31,15 +31,39 @@ fn running() -> Option<Version> { | ... | @@ -31,15 +31,39 @@ fn running() -> Option<Version> { |
| 31 | Version::parse(option_env!("SNOWBOUND_BUILD")?) | 31 | Version::parse(option_env!("SNOWBOUND_BUILD")?) |
| 32 | } | 32 | } |
| 33 | 33 | ||
| 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. | ||
| 35 | fn platform() -> String { | 36 | fn platform() -> String { |
| 36 | if cfg!(target_os = "macos") && !cfg!(feature = "wgpu") { | 37 | if translated() { |
| 38 | "macos-aarch64".to_owned() | ||
| 39 | } else if cfg!(target_os = "macos") && !cfg!(feature = "wgpu") { | ||
| 37 | "macos-10.6".to_owned() | 40 | "macos-10.6".to_owned() |
| 38 | } else { | 41 | } else { |
| 39 | format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH) | 42 | format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH) |
| 40 | } | 43 | } |
| 41 | } | 44 | } |
| 42 | 45 | ||
| 46 | #[cfg(all(target_os = "macos", target_arch = "x86_64"))] | ||
| 47 | fn 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")))] | ||
| 63 | fn translated() -> bool { | ||
| 64 | false | ||
| 65 | } | ||
| 66 | |||
| 43 | /// A published build: the day of its commit on `main`, in Los Angeles, and how many of that | 67 | /// A published build: the day of its commit on `main`, in Los Angeles, and how many of that |
| 44 | /// day's commits on `main` lead up to and include it. `2026-09-29-r10` names it in manifests. | 68 | /// day's commits on `main` lead up to and include it. `2026-09-29-r10` names it in manifests. |
| 45 | #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] | 69 | #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] |
| ... | @@ -302,6 +326,8 @@ fn check( | ... | @@ -302,6 +326,8 @@ fn check( |
| 302 | progress: &dyn Fn(Status), | 326 | progress: &dyn Fn(Status), |
| 303 | ) -> Status { | 327 | ) -> Status { |
| 304 | let platform = platform(); | 328 | let platform = platform(); |
| 329 | // Under Rosetta the native build of the same version is still the update. | ||
| 330 | let running = running.filter(|_| !translated()); | ||
| 305 | let fetch = |path: &str, limit| { | 331 | let fetch = |path: &str, limit| { |
| 306 | get(path, limit).map_err(|error| { | 332 | get(path, limit).map_err(|error| { |
| 307 | eprintln!("Cannot fetch {BASE}{path}: {error}"); | 333 | eprintln!("Cannot fetch {BASE}{path}: {error}"); |
crates/snowbound/src/windows.rs+61-6| ... | @@ -175,6 +175,9 @@ pub fn window_attributes() -> WindowAttributes { | ... | @@ -175,6 +175,9 @@ pub fn window_attributes() -> WindowAttributes { |
| 175 | .with_taskbar_icon(icon(32).ok()) | 175 | .with_taskbar_icon(icon(32).ok()) |
| 176 | .with_decorations(caption() != Caption::Drawn) | 176 | .with_decorations(caption() != Caption::Drawn) |
| 177 | .with_undecorated_shadow(true) | 177 | .with_undecorated_shadow(true) |
| 178 | // What GDI paints under the material would show through the row; every 10 draws | ||
| 179 | // with Direct3D 12, which needs no such bitmap. | ||
| 180 | .with_no_redirection_bitmap(caption() == Caption::Drawn && version().0 >= 10) | ||
| 178 | } | 181 | } |
| 179 | 182 | ||
| 180 | /// Takes the window's frame messages ahead of winit's: over glass or Mica, the caption | 183 | /// Takes the window's frame messages ahead of winit's: over glass or Mica, the caption |
| ... | @@ -236,9 +239,6 @@ unsafe extern "system" fn frame_procedure( | ... | @@ -236,9 +239,6 @@ unsafe extern "system" fn frame_procedure( |
| 236 | wm::WM_SETTINGCHANGE | wm::WM_DWMCOLORIZATIONCOLORCHANGED => { | 239 | wm::WM_SETTINGCHANGE | wm::WM_DWMCOLORIZATIONCOLORCHANGED => { |
| 237 | if message == wm::WM_SETTINGCHANGE && setting(lparam) == "ImmersiveColorSet" { | 240 | if message == wm::WM_SETTINGCHANGE && setting(lparam) == "ImmersiveColorSet" { |
| 238 | refresh_color_policy(); | 241 | refresh_color_policy(); |
| 239 | if ACRYLIC.load(Ordering::Relaxed) { | ||
| 240 | accent(hwnd, Accent::Acrylic); | ||
| 241 | } | ||
| 242 | } | 242 | } |
| 243 | if let Some(proxy) = QUIT.get() { | 243 | if let Some(proxy) = QUIT.get() { |
| 244 | let _ = proxy.send_event(crate::UserEvent::Appearance); | 244 | let _ = proxy.send_event(crate::UserEvent::Appearance); |
| ... | @@ -445,6 +445,26 @@ pub fn cuts_corners() -> bool { | ... | @@ -445,6 +445,26 @@ pub fn cuts_corners() -> bool { |
| 445 | /// Whether Windows 10 shows acrylic under the window, which a drag swaps for plain blur. | 445 | /// Whether Windows 10 shows acrylic under the window, which a drag swaps for plain blur. |
| 446 | static ACRYLIC: AtomicBool = AtomicBool::new(false); | 446 | static ACRYLIC: AtomicBool = AtomicBool::new(false); |
| 447 | 447 | ||
| 448 | /// Whether the app's appearance is dark, which Options can set apart from the system's. | ||
| 449 | static DARK: AtomicBool = AtomicBool::new(false); | ||
| 450 | |||
| 451 | /// Holds the frame, its caption buttons and its material to the app's `appearance`. | ||
| 452 | pub 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 | |||
| 448 | enum Accent { | 468 | enum Accent { |
| 449 | Blur, | 469 | Blur, |
| 450 | /// Acrylic in the shell's own tints: Windows 10's light and dark flyouts. | 470 | /// Acrylic in the shell's own tints: Windows 10's light and dark flyouts. |
| ... | @@ -482,8 +502,8 @@ fn accent(hwnd: HWND, accent: Accent) -> bool { | ... | @@ -482,8 +502,8 @@ fn accent(hwnd: HWND, accent: Accent) -> bool { |
| 482 | Accent::Acrylic => Policy { | 502 | Accent::Acrylic => Policy { |
| 483 | state: 4, | 503 | state: 4, |
| 484 | flags: 2, | 504 | flags: 2, |
| 485 | // 0xAABBGGRR, after the apps' colour mode. | 505 | // 0xAABBGGRR, after the app's appearance. |
| 486 | tint: if registry_dword(PERSONALIZE, "AppsUseLightTheme") == Some(0) { | 506 | tint: if DARK.load(Ordering::Relaxed) { |
| 487 | 0xcc20_2020 | 507 | 0xcc20_2020 |
| 488 | } else { | 508 | } else { |
| 489 | 0xccf3_f3f3 | 509 | 0xccf3_f3f3 |
| ... | @@ -577,7 +597,7 @@ pub fn titlebar(appearance: Theme) -> Option<[[f32; 4]; 2]> { | ... | @@ -577,7 +597,7 @@ pub fn titlebar(appearance: Theme) -> Option<[[f32; 4]; 2]> { |
| 577 | /// The theme over the window's material. Over Windows 7's glass, fields and tool buttons | 597 | /// The theme over the window's material. Over Windows 7's glass, fields and tool buttons |
| 578 | /// take white faces, as Internet Explorer's do there, so their text and icons stay legible, | 598 | /// take white faces, as Internet Explorer's do there, so their text and icons stay legible, |
| 579 | /// and the sidebar stays opaque. | 599 | /// and the sidebar stays opaque. |
| 580 | pub fn over_backdrop(theme: ui::Theme) -> ui::Theme { | 600 | pub fn over_backdrop(theme: ui::Theme, _: Theme) -> ui::Theme { |
| 581 | let sidebar = theme.sidebar; | 601 | let sidebar = theme.sidebar; |
| 582 | let theme = theme.over_backdrop(); | 602 | let theme = theme.over_backdrop(); |
| 583 | if caption() != Caption::Glass || eleven() { | 603 | if caption() != Caption::Glass || eleven() { |
| ... | @@ -795,6 +815,15 @@ impl Clipboard { | ... | @@ -795,6 +815,15 @@ impl Clipboard { |
| 795 | pub fn get_text(&mut self) -> Result<String, arboard::Error> { | 815 | pub fn get_text(&mut self) -> Result<String, arboard::Error> { |
| 796 | self.0.get_text() | 816 | self.0.get_text() |
| 797 | } | 817 | } |
| 818 | |||
| 819 | pub fn get_files(&mut self) -> Vec<std::path::PathBuf> { | ||
| 820 | self.0.get().file_list().unwrap_or_default() | ||
| 821 | } | ||
| 822 | |||
| 823 | /// The clipboard's PNG, or else its device-independent bitmap. | ||
| 824 | pub fn get_picture(&mut self) -> Option<Vec<u8>> { | ||
| 825 | crate::paste::bitmap(self.0.get_image().ok()?) | ||
| 826 | } | ||
| 798 | } | 827 | } |
| 799 | 828 | ||
| 800 | /// The DWORD `value` under `key` in the user's registry hive. | 829 | /// The DWORD `value` under `key` in the user's registry hive. |
| ... | @@ -884,6 +913,32 @@ pub fn settings_dir() -> Option<PathBuf> { | ... | @@ -884,6 +913,32 @@ pub fn settings_dir() -> Option<PathBuf> { |
| 884 | Some(PathBuf::from(std::env::var_os("APPDATA")?).join("Snowbound")) | 913 | Some(PathBuf::from(std::env::var_os("APPDATA")?).join("Snowbound")) |
| 885 | } | 914 | } |
| 886 | 915 | ||
| 916 | /// The user's Documents known folder, wherever it was moved. | ||
| 917 | pub fn documents_dir() -> Option<PathBuf> { | ||
| 918 | use std::os::windows::ffi::OsStringExt; | ||
| 919 | use windows_sys::Win32::{ | ||
| 920 | System::Com::CoTaskMemFree, | ||
| 921 | UI::Shell::{FOLDERID_Documents, KF_FLAG_DEFAULT, SHGetKnownFolderPath}, | ||
| 922 | }; | ||
| 923 | let mut path = std::ptr::null_mut(); | ||
| 924 | let found = unsafe { | ||
| 925 | SHGetKnownFolderPath( | ||
| 926 | &FOLDERID_Documents, | ||
| 927 | KF_FLAG_DEFAULT as u32, | ||
| 928 | std::ptr::null_mut(), | ||
| 929 | &mut path, | ||
| 930 | ) | ||
| 931 | } == 0; | ||
| 932 | let folder = found.then(|| unsafe { | ||
| 933 | let length = (0..).take_while(|&at| *path.add(at) != 0).count(); | ||
| 934 | PathBuf::from(std::ffi::OsString::from_wide(std::slice::from_raw_parts( | ||
| 935 | path, length, | ||
| 936 | ))) | ||
| 937 | }); | ||
| 938 | unsafe { CoTaskMemFree(path as _) }; | ||
| 939 | folder | ||
| 940 | } | ||
| 941 | |||
| 887 | /// None: Windows reaches a share by its UNC path with the share modes and byte-range | 942 | /// None: Windows reaches a share by its UNC path with the share modes and byte-range |
| 888 | /// locks OneNote takes, so a notebook there opens as a folder, as OneNote opens it. | 943 | /// locks OneNote takes, so a notebook there opens as a folder, as OneNote opens it. |
| 889 | /// Snowbound's own client serves Open Notebook from Server…. | 944 | /// Snowbound's own client serves Open Notebook from Server…. |
crates/ui/src/layout.rs+34| ... | @@ -265,6 +265,40 @@ fn flow(nodes: &[Built], index: usize, axis: usize) -> f32 { | ... | @@ -265,6 +265,40 @@ fn flow(nodes: &[Built], index: usize, axis: usize) -> f32 { |
| 265 | } | 265 | } |
| 266 | } | 266 | } |
| 267 | 267 | ||
| 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. | ||
| 270 | pub(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. | ||
| 289 | fn 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 | |||
| 268 | fn fit_labels(nodes: &mut [Built], texts: &mut Texts, frame: u64) { | 302 | fn fit_labels(nodes: &mut [Built], texts: &mut Texts, frame: u64) { |
| 269 | for node in nodes.iter_mut() { | 303 | for node in nodes.iter_mut() { |
| 270 | let Some(label) = node.label.clone() else { | 304 | let Some(label) = node.label.clone() else { |
crates/ui/src/lib.rs+7| ... | @@ -827,6 +827,13 @@ impl Ui { | ... | @@ -827,6 +827,13 @@ impl Ui { |
| 827 | self.hit(point, Flags::CLICKABLE | Flags::CUSTOM) | 827 | self.hit(point, Flags::CLICKABLE | Flags::CUSTOM) |
| 828 | } | 828 | } |
| 829 | 829 | ||
| 830 | /// The least width the box's children fit in, with its padding, as built this frame or | ||
| 831 | /// last: each row's groups folded, and each box yielding all its strictness lets it. | ||
| 832 | pub fn narrowest(&self, id: Id) -> Option<f32> { | ||
| 833 | let index = self.nodes.iter().position(|node| node.id == id)?; | ||
| 834 | Some(layout::narrowest(&self.nodes, index)) | ||
| 835 | } | ||
| 836 | |||
| 830 | /// The box's rectangle from the latest layout. | 837 | /// The box's rectangle from the latest layout. |
| 831 | pub fn rect(&self, id: Id) -> Option<[f32; 4]> { | 838 | pub fn rect(&self, id: Id) -> Option<[f32; 4]> { |
| 832 | self.states.get(&id).map(|state| state.rect) | 839 | self.states.get(&id).map(|state| state.rect) |
crates/ui/src/tests.rs+11| ... | @@ -216,6 +216,17 @@ fn a_row_squeezes_its_boxes_only_once_every_group_is_folded() { | ... | @@ -216,6 +216,17 @@ fn a_row_squeezes_its_boxes_only_once_every_group_is_folded() { |
| 216 | assert_eq!(rects[4], Some([80.0, 0.0, 100.0, 20.0])); | 216 | assert_eq!(rects[4], Some([80.0, 0.0, 100.0, 20.0])); |
| 217 | } | 217 | } |
| 218 | 218 | ||
| 219 | #[test] | ||
| 220 | fn a_row_is_narrowest_with_every_group_folded_and_every_box_squeezed() { | ||
| 221 | let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); | ||
| 222 | folding_row(&mut ui, 300.0, &[0, 1]); | ||
| 223 | let row = Id::ROOT.child("row"); | ||
| 224 | // The space gives all, the loose box 60 of 80, each group 80 of 100. | ||
| 225 | assert_eq!(ui.narrowest(row), Some(60.0)); | ||
| 226 | let rects = folding_row(&mut ui, 60.0, &[0, 1]); | ||
| 227 | assert_eq!(rects[4], Some([40.0, 0.0, 60.0, 20.0])); | ||
| 228 | } | ||
| 229 | |||
| 219 | #[test] | 230 | #[test] |
| 220 | fn a_box_sized_by_its_children_yields_what_they_yield() { | 231 | fn a_box_sized_by_its_children_yields_what_they_yield() { |
| 221 | let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); | 232 | let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); |
crates/ui/src/text.rs+1| ... | @@ -149,6 +149,7 @@ impl Glyphs for Label { | ... | @@ -149,6 +149,7 @@ impl Glyphs for Label { |
| 149 | if let PositionedLayoutItem::GlyphRun(run) = item { | 149 | if let PositionedLayoutItem::GlyphRun(run) = item { |
| 150 | draw::paint_parley_run( | 150 | draw::paint_parley_run( |
| 151 | &run, | 151 | &run, |
| 152 | "", | ||
| 152 | metrics.baseline, | 153 | metrics.baseline, |
| 153 | 0.0, | 154 | 0.0, |
| 154 | [metrics.block_min_coord, metrics.line_height], | 155 | [metrics.block_min_coord, metrics.line_height], |
docs/guide-notebook/Snowbound Guide/.snowbound/tags.json	 created+8| ... | @@ -0,0 +1,8 @@ | ||
| 1 | [ | ||
| 2 | { | ||
| 3 | "name": "Snowflake", | ||
| 4 | "shape": 34, | ||
| 5 | "art": "308029094b76e765ca4025c6e8978ff3f1e5f696601ca46fed3803a81c37792c.svg", | ||
| 6 | "mapped": 134352774263488320 | ||
| 7 | } | ||
| 8 | ] | ||
| \ No newline at end of file | |||
docs/guide-notebook/Snowbound Guide/.snowbound/tags/308029094b76e765ca4025c6e8978ff3f1e5f696601ca46fed3803a81c37792c.svg	 created+14| ... | @@ -0,0 +1,14 @@ | ||
| 1 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"> | ||
| 2 | <defs> | ||
| 3 | <linearGradient id="g0" x1="0" y1="1" x2="0" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e8f6ff"/><stop offset="1" stop-color="#58a8e8"/></linearGradient> | ||
| 4 | </defs> | ||
| 5 | <g fill="none" stroke-linecap="round" stroke="#2a6cb0" stroke-width="2.4"> | ||
| 6 | <path d="M8 1.2V14.8M2.1 4.6L13.9 11.4M2.1 11.4L13.9 4.6"/> | ||
| 7 | <path d="M6.2 2.6L8 4.2L9.8 2.6M6.2 13.4L8 11.8L9.8 13.4M2.4 7L4.6 7.6L4 9.8M13.6 7L11.4 7.6L12 9.8M2.4 9L4.6 8.4L4 6.2M13.6 9L11.4 8.4L12 6.2"/> | ||
| 8 | </g> | ||
| 9 | <g fill="none" stroke-linecap="round" stroke="url(#g0)" stroke-width="1.2"> | ||
| 10 | <path d="M8 1.2V14.8M2.1 4.6L13.9 11.4M2.1 11.4L13.9 4.6"/> | ||
| 11 | <path d="M6.2 2.6L8 4.2L9.8 2.6M6.2 13.4L8 11.8L9.8 13.4M2.4 7L4.6 7.6L4 9.8M13.6 7L11.4 7.6L12 9.8M2.4 9L4.6 8.4L4 6.2M13.6 9L11.4 8.4L12 6.2"/> | ||
| 12 | </g> | ||
| 13 | <circle cx="8" cy="8" r="1.6" fill="#ffffff" stroke="#2a6cb0" stroke-width="0.6"/> | ||
| 14 | </svg> | ||
docs/guide-notebook/Snowbound Guide/Drawing & Recording.one created| Binary files /dev/null and b/docs/guide-notebook/Snowbound Guide/Drawing & Recording.one differ | |||
docs/guide-notebook/Snowbound Guide/Getting Started.one created| Binary files /dev/null and b/docs/guide-notebook/Snowbound Guide/Getting Started.one differ | |||
docs/guide-notebook/Snowbound Guide/Open Notebook.onetoc2 created| Binary files /dev/null and b/docs/guide-notebook/Snowbound Guide/Open Notebook.onetoc2 differ | |||
docs/guide-notebook/Snowbound Guide/Sharing & Sync.one created| Binary files /dev/null and b/docs/guide-notebook/Snowbound Guide/Sharing & Sync.one differ | |||
docs/guide-notebook/Snowbound Guide/Tags & Search.one created| Binary files /dev/null and b/docs/guide-notebook/Snowbound Guide/Tags & Search.one differ | |||
docs/guide-notebook/Snowbound Guide/Writing & Formatting.one created| Binary files /dev/null and b/docs/guide-notebook/Snowbound Guide/Writing & Formatting.one differ | |||
platform/snow-leopard/rt/appkit.c-8| ... | @@ -127,11 +127,6 @@ static CGFloat red(id self, SEL _cmd) { return component(self, 0); } | ... | @@ -127,11 +127,6 @@ static CGFloat red(id self, SEL _cmd) { return component(self, 0); } |
| 127 | static CGFloat green(id self, SEL _cmd) { return component(self, 1); } | 127 | static CGFloat green(id self, SEL _cmd) { return component(self, 1); } |
| 128 | static CGFloat blue(id self, SEL _cmd) { return component(self, 2); } | 128 | static CGFloat blue(id self, SEL _cmd) { return component(self, 2); } |
| 129 | 129 | ||
| 130 | static id black(id self, SEL _cmd) { return SEND(objc_getClass("NSColor"), "blackColor"); } | ||
| 131 | static id secondary_selection(id self, SEL _cmd) { | ||
| 132 | return SEND(objc_getClass("NSColor"), "secondarySelectedControlColor"); | ||
| 133 | } | ||
| 134 | |||
| 135 | #define RECT "{CGRect={CGPoint=dd}{CGSize=dd}}" | 130 | #define RECT "{CGRect={CGPoint=dd}{CGSize=dd}}" |
| 136 | 131 | ||
| 137 | static void add(const char *class_name, int meta, const char *selector, void *imp, const char *types) { | 132 | static void add(const char *class_name, int meta, const char *selector, void *imp, const char *types) { |
| ... | @@ -153,7 +148,6 @@ __attribute__((constructor)) static void polyfill(void) { | ... | @@ -153,7 +148,6 @@ __attribute__((constructor)) static void polyfill(void) { |
| 153 | add("NSWindow", 0, "setTitlebarAppearsTransparent:", ignore, "v@:c"); | 148 | add("NSWindow", 0, "setTitlebarAppearsTransparent:", ignore, "v@:c"); |
| 154 | add("NSView", 0, "setWantsBestResolutionOpenGLSurface:", ignore, "v@:c"); | 149 | add("NSView", 0, "setWantsBestResolutionOpenGLSurface:", ignore, "v@:c"); |
| 155 | add("NSView", 0, "setAccessibilityLabel:", set_accessibility_label, "v@:@"); | 150 | add("NSView", 0, "setAccessibilityLabel:", set_accessibility_label, "v@:@"); |
| 156 | add("NSDatePicker", 0, "setPresentsCalendarOverlay:", ignore, "v@:c"); | ||
| 157 | add("NSEvent", 0, "hasPreciseScrollingDeltas", no, "c@:"); | 151 | add("NSEvent", 0, "hasPreciseScrollingDeltas", no, "c@:"); |
| 158 | add("NSEvent", 0, "scrollingDeltaX", delta_x, "d@:"); | 152 | add("NSEvent", 0, "scrollingDeltaX", delta_x, "d@:"); |
| 159 | add("NSEvent", 0, "scrollingDeltaY", delta_y, "d@:"); | 153 | add("NSEvent", 0, "scrollingDeltaY", delta_y, "d@:"); |
| ... | @@ -163,8 +157,6 @@ __attribute__((constructor)) static void polyfill(void) { | ... | @@ -163,8 +157,6 @@ __attribute__((constructor)) static void polyfill(void) { |
| 163 | add("NSColorSpaceColor", 0, "greenComponent", green, "d@:"); | 157 | add("NSColorSpaceColor", 0, "greenComponent", green, "d@:"); |
| 164 | add("NSColorSpaceColor", 0, "blueComponent", blue, "d@:"); | 158 | add("NSColorSpaceColor", 0, "blueComponent", blue, "d@:"); |
| 165 | add("NSColor", 1, "colorWithSRGBRed:green:blue:alpha:", srgb_color, "@@:dddd"); | 159 | add("NSColor", 1, "colorWithSRGBRed:green:blue:alpha:", srgb_color, "@@:dddd"); |
| 166 | add("NSColor", 1, "textInsertionPointColor", black, "@@:"); | ||
| 167 | add("NSColor", 1, "unemphasizedSelectedTextBackgroundColor", secondary_selection, "@@:"); | ||
| 168 | 160 | ||
| 169 | // One appearance, Aqua, which windows have and nothing changes. | 161 | // One appearance, Aqua, which windows have and nothing changes. |
| 170 | appearance_class = (id)objc_getClass("NSAppearance"); | 162 | appearance_class = (id)objc_getClass("NSAppearance"); |
tools/RELEASE.md+30-7| ... | @@ -18,16 +18,17 @@ development builds, which never update themselves. | ... | @@ -18,16 +18,17 @@ development builds, which never update themselves. |
| 18 | ## The published folder | 18 | ## The published folder |
| 19 | 19 | ||
| 20 | ```text | 20 | ```text |
| 21 | latest.json {"macos-aarch64": "2026-09-29-r10", "macos-10.6": ..., "linux-x86_64": ..., "windows-x86_64": ..., ...} | 21 | latest.json {"macos-aarch64": "2026-09-29-r10", "macos-x86_64": ..., "macos-10.6": ..., "linux-x86_64": ..., ...} |
| 22 | 2026-09-29.r10/ | 22 | 2026-09-29.r10/ |
| 23 | build.json version, commit, and per platform: file, size, sha256, signature | 23 | build.json version, commit, and per platform: file, size, sha256, signature |
| 24 | build.json.sig ed25519 signature of build.json, hex | 24 | build.json.sig ed25519 signature of build.json, hex |
| 25 | Snowbound-2026-09-29-r10-macos-aarch64.zip | 25 | Snowbound-2026-09-29-r10-macos-aarch64.zip |
| 26 | Snowbound-2026-09-29-r10-macos-x86_64.zip | ||
| 26 | Snowbound-2026-09-29-r10-macos-10.6.zip | 27 | Snowbound-2026-09-29-r10-macos-10.6.zip |
| 27 | snowbound-2026-09-29-r10-linux-x86_64 the executable itself | 28 | snowbound-2026-09-29-r10-linux-x86_64 the executable itself |
| 28 | snowbound-2026-09-29-r10-linux-aarch64 | 29 | 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-x86_64.exe |
| 30 | snowbound-2026-09-29-r10-windows-aarch64.exe Windows 11 on Arm | 31 | snowbound-2026-09-29-r10-windows-aarch64.exe |
| 31 | ``` | 32 | ``` |
| 32 | 33 | ||
| 33 | A build folder is written once, under a hidden `.2026-09-29.r10.partial` name renamed into | 34 | A build folder is written once, under a hidden `.2026-09-29.r10.partial` name renamed into |
| ... | @@ -82,7 +83,7 @@ quarantine and open directly. | ... | @@ -82,7 +83,7 @@ quarantine and open directly. |
| 82 | ## Publishing | 83 | ## Publishing |
| 83 | 84 | ||
| 84 | ```sh | 85 | ```sh |
| 85 | python3 tools/release.py # all six platforms | 86 | python3 tools/release.py # all seven platforms |
| 86 | python3 tools/release.py --ad-hoc # the macOS app without Developer ID | 87 | python3 tools/release.py --ad-hoc # the macOS app without Developer ID |
| 87 | python3 tools/release.py --dry-run # the same into a new temporary folder | 88 | python3 tools/release.py --dry-run # the same into a new temporary folder |
| 88 | python3 tools/release.py --platforms macos-aarch64 linux-x86_64 | 89 | python3 tools/release.py --platforms macos-aarch64 linux-x86_64 |
| ... | @@ -99,9 +100,30 @@ publishes as above. Run again for the same commit, it only brings | ... | @@ -99,9 +100,30 @@ publishes as above. Run again for the same commit, it only brings |
| 99 | `latest.json` up to date; a different commit that derives the same version is | 100 | `latest.json` up to date; a different commit that derives the same version is |
| 100 | refused. The 10.6 build needs the SDK and nightly toolchain | 101 | refused. The 10.6 build needs the SDK and nightly toolchain |
| 101 | `platform/snow-leopard/cargo.sh` names; the Linux builds need `zig`, as the | 102 | `platform/snow-leopard/cargo.sh` names; the Linux builds need `zig`, as the |
| 102 | cross linker against glibc 2.31; the Windows builds need llvm-mingw, which | 103 | cross linker against glibc 2.17; the Windows builds need llvm-mingw, which |
| 103 | `platform/windows/toolchain.sh` fetches, and nightly with `rust-src` for | 104 | `platform/windows/toolchain.sh` fetches, and nightly with `rust-src` for |
| 104 | x86_64. All six build from an Apple silicon Mac. | 105 | x86_64. All seven build from an Apple silicon Mac. |
| 106 | |||
| 107 | ## Minimum systems | ||
| 108 | |||
| 109 | Each build runs as far back as its dependencies allow, and newer calls are | ||
| 110 | checked 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 | ||
| 122 | carries it as `LC_BUILD_VERSION` `minos` (`LC_VERSION_MIN_MACOSX` below 10.14), | ||
| 123 | which `otool -l` shows. `objdump -T` of a Linux executable names no `GLIBC_` | ||
| 124 | version above 2.17. The Linux executable links only libc, libm, libpthread and | ||
| 125 | libdl; Wayland, X11, xkbcommon, EGL, Vulkan, fontconfig, GStreamer and Enchant | ||
| 126 | are loaded at run time. | ||
| 105 | 127 | ||
| 106 | ## In the app | 128 | ## In the app |
| 107 | 129 | ||
| ... | @@ -111,7 +133,8 @@ Work Offline is on; Check for Updates… (the app menu on macOS, the command | ... | @@ -111,7 +133,8 @@ Work Offline is on; Check for Updates… (the app menu on macOS, the command |
| 111 | palette elsewhere) checks at once and reports what it found. A check reads | 133 | palette elsewhere) checks at once and reports what it found. A check reads |
| 112 | `latest.json`, then the named build's `build.json` and signature, and | 134 | `latest.json`, then the named build's `build.json` and signature, and |
| 113 | downloads the archive for this platform, verifying size, SHA-256 and | 135 | downloads the archive for this platform, verifying size, SHA-256 and |
| 114 | signature. It stages the update beside the install, so the swap is a rename: | 136 | signature. An Intel build that Rosetta runs takes `macos-aarch64`'s, even at |
| 137 | its own version. It stages the update beside the install, so the swap is a rename: | ||
| 115 | the app unpacked into `.Snowbound.app.update` next to the bundle on macOS, the | 138 | the app unpacked into `.Snowbound.app.update` next to the bundle on macOS, the |
| 116 | executable into `.snowbound.update` next to it on Linux (`.snowbound.exe.update` on | 139 | executable into `.snowbound.update` next to it on Linux (`.snowbound.exe.update` on |
| 117 | Windows). The sync status icon gets a dot, and its popup offers | 140 | Windows). The sync status icon gets a dot, and its popup offers |
tools/canvas/README.md+1-1| ... | @@ -54,7 +54,7 @@ Launched without a file, the app reopens the notebooks open when it last closed, | ... | @@ -54,7 +54,7 @@ Launched without a file, the app reopens the notebooks open when it last closed, |
| 54 | 54 | ||
| 55 | The `ui` crate builds every frame from the application's state. A cache keyed by stable box ids keeps hover, press, focus, scroll and animation values, and the previous frame's layout routes the frame's input before building, so a frame answers input one layout late and paints with its own. Sizes are solved per axis after building: fixed, label-sized, a fraction of an ancestor, or the sum of children, with overflow shared out by each box's strictness. The page is a custom box: `ui` routes it the pointer, wheel, key and input-method events that land on it, in order, and the host hands them to `PageView` and paints the page in a clipped layer of the same frame. Page scrollbars are `ui` widgets over that box; the canvas reports only its scroll bounds. Text fields edit through `draw::edit`, as the page does, so keys, clicks and drags select and move the same way in both. Work the frame asks for (page requests, saving) runs after the frame is painted and requests the frame that shows it. Opening a section or page reads and lays it out on a thread of its own while the current page stays live; the newest replaces the page once the pictures it shows first are drawn, or after 200 ms. | 55 | The `ui` crate builds every frame from the application's state. A cache keyed by stable box ids keeps hover, press, focus, scroll and animation values, and the previous frame's layout routes the frame's input before building, so a frame answers input one layout late and paints with its own. Sizes are solved per axis after building: fixed, label-sized, a fraction of an ancestor, or the sum of children, with overflow shared out by each box's strictness. The page is a custom box: `ui` routes it the pointer, wheel, key and input-method events that land on it, in order, and the host hands them to `PageView` and paints the page in a clipped layer of the same frame. Page scrollbars are `ui` widgets over that box; the canvas reports only its scroll bounds. Text fields edit through `draw::edit`, as the page does, so keys, clicks and drags select and move the same way in both. Work the frame asks for (page requests, saving) runs after the frame is painted and requests the frame that shows it. Opening a section or page reads and lays it out on a thread of its own while the current page stays live; the newest replaces the page once the pictures it shows first are drawn, or after 200 ms. |
| 56 | 56 | ||
| 57 | A covered window receives no redraws, so interaction can be scripted: `SNOWBOUND_REPLAY` names a file of `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `key NAME`, `type TEXT`, `modifiers [shift] [command]`, `wait MS`, `snapshot PNG_PATH`, `accessibility TEXT_PATH` (the window's accessibility tree, a node a line, as the platform shows it), `appearance light|dark`, `resize WIDTH HEIGHT` and `quit` lines in logical pixels, and each step and every 16 ms of a wait draws a frame. With `--screenshot` as well, the window stays hidden and only the replay's snapshots capture it. Snapshots wait for the page's pictures, file icons and background art to finish rasterizing; `SNOWBOUND_FRAMES` frames never wait, so art appears in the first frame drawn after it is ready. Snapshots omit the window's traffic lights, which AppKit draws. Replays edit and save like a user, so point them at a copy of a notebook. Their edits carry the settings' user name, so give fixture runs a `--settings` file holding `{"user_name": "Snowbound Test"}`. | 57 | A covered window receives no redraws, so interaction can be scripted: `SNOWBOUND_REPLAY` names a file of `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, `pinch FACTOR`, `key NAME`, `type TEXT`, `modifiers [shift] [command]`, `wait MS`, `snapshot PNG_PATH`, `accessibility TEXT_PATH` (the window's accessibility tree, a node a line, as the platform shows it), `appearance light|dark`, `resize WIDTH HEIGHT` and `quit` lines in logical pixels, and each step and every 16 ms of a wait draws a frame. With `--screenshot` as well, the window stays hidden and only the replay's snapshots capture it. Snapshots wait for the page's pictures, file icons and background art to finish rasterizing; `SNOWBOUND_FRAMES` frames never wait, so art appears in the first frame drawn after it is ready. Snapshots omit the window's traffic lights, which AppKit draws. Replays edit and save like a user, so point them at a copy of a notebook. Their edits carry the settings' user name, so give fixture runs a `--settings` file holding `{"user_name": "Snowbound Test"}`. |
| 58 | 58 | ||
| 59 | ```sh | 59 | ```sh |
| 60 | cp -RL SOURCE_NOTEBOOK /tmp/notebook-copy && chmod u+w /tmp/notebook-copy/*.one | 60 | cp -RL SOURCE_NOTEBOOK /tmp/notebook-copy && chmod u+w /tmp/notebook-copy/*.one |
tools/canvas/build_macos.py+17-4| ... | @@ -16,6 +16,8 @@ parser = argparse.ArgumentParser(description=__doc__) | ... | @@ -16,6 +16,8 @@ parser = argparse.ArgumentParser(description=__doc__) |
| 16 | parser.add_argument('--release', action='store_true') | 16 | parser.add_argument('--release', action='store_true') |
| 17 | parser.add_argument('--output', type=Path, help='Create a separate bundle at a new .app path') | 17 | parser.add_argument('--output', type=Path, help='Create a separate bundle at a new .app path') |
| 18 | parser.add_argument('--bundle-id', help="Bundle identifier for the separate app; the app's own otherwise") | 18 | parser.add_argument('--bundle-id', help="Bundle identifier for the separate app; the app's own otherwise") |
| 19 | host = 'aarch64' if platform.machine() == 'arm64' else 'x86_64' | ||
| 20 | parser.add_argument('--arch', choices=['aarch64', 'x86_64'], default=host, help="The app's architecture; this Mac's by default") | ||
| 19 | parser.add_argument('--snow-leopard', action='store_true', | 21 | parser.add_argument('--snow-leopard', action='store_true', |
| 20 | help='Build with platform/snow-leopard/cargo.sh into target/snow-leopard') | 22 | help='Build with platform/snow-leopard/cargo.sh into target/snow-leopard') |
| 21 | parser.add_argument('--sign-identity', metavar='SHA1', | 23 | parser.add_argument('--sign-identity', metavar='SHA1', |
| ... | @@ -29,6 +31,9 @@ if args.bundle_id and not args.output: | ... | @@ -29,6 +31,9 @@ if args.bundle_id and not args.output: |
| 29 | parser.error('Use --bundle-id with --output.') | 31 | parser.error('Use --bundle-id with --output.') |
| 30 | if args.sign and args.snow_leopard: | 32 | if args.sign and args.snow_leopard: |
| 31 | parser.error('The 10.6 bundle stays unsigned.') | 33 | parser.error('The 10.6 bundle stays unsigned.') |
| 34 | # The oldest macOS each build runs on: 10.6's own build, wgpu's floor on Intel, and Apple | ||
| 35 | # silicon's first, which rustc also defaults to. | ||
| 36 | minimum = '10.6' if args.snow_leopard else {'x86_64': '10.13', 'aarch64': '11.0'}[args.arch] | ||
| 32 | if args.output and (args.output.suffix != '.app' or args.output.exists()): | 37 | if args.output and (args.output.suffix != '.app' or args.output.exists()): |
| 33 | parser.error('Choose a new output path ending in .app.') | 38 | parser.error('Choose a new output path ending in .app.') |
| 34 | root = Path(__file__).resolve().parents[2] | 39 | root = Path(__file__).resolve().parents[2] |
| ... | @@ -83,9 +88,13 @@ if args.snow_leopard: | ... | @@ -83,9 +88,13 @@ if args.snow_leopard: |
| 83 | target = root / 'target/snow-leopard' | 88 | target = root / 'target/snow-leopard' |
| 84 | built = target / 'x86_64-apple-macosx10.6' | 89 | built = target / 'x86_64-apple-macosx10.6' |
| 85 | else: | 90 | else: |
| 86 | subprocess.run(['cargo', 'build', '-p', 'snowbound'] + profile, cwd=root, check=True) | ||
| 87 | metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--format-version=1', '--no-deps'], cwd=root)) | 91 | metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--format-version=1', '--no-deps'], cwd=root)) |
| 88 | target = built = Path(metadata['target_directory']) | 92 | target = built = Path(metadata['target_directory']) |
| 93 | cross = [] if args.arch == host else ['--target', f'{args.arch}-apple-darwin'] | ||
| 94 | if cross: | ||
| 95 | built = target / cross[1] | ||
| 96 | subprocess.run(['cargo', 'build', '-p', 'snowbound'] + profile + cross, cwd=root, check=True, | ||
| 97 | env={**os.environ, 'MACOSX_DEPLOYMENT_TARGET': minimum} if args.arch == 'x86_64' else None) | ||
| 89 | bundle = args.output.resolve() if args.output else target / 'Snowbound.app' | 98 | bundle = args.output.resolve() if args.output else target / 'Snowbound.app' |
| 90 | if args.output: | 99 | if args.output: |
| 91 | bundle.mkdir(parents=True, exist_ok=False) | 100 | bundle.mkdir(parents=True, exist_ok=False) |
| ... | @@ -107,8 +116,10 @@ if args.snow_leopard: | ... | @@ -107,8 +116,10 @@ if args.snow_leopard: |
| 107 | '--out', iconset / f'icon_{side}x{side}.png'], check=True, capture_output=True) | 116 | '--out', iconset / f'icon_{side}x{side}.png'], check=True, capture_output=True) |
| 108 | subprocess.run(['iconutil', '-c', 'icns', iconset, '-o', resources / 'Snowbound.icns'], check=True) | 117 | subprocess.run(['iconutil', '-c', 'icns', iconset, '-o', resources / 'Snowbound.icns'], check=True) |
| 109 | else: | 118 | else: |
| 110 | icon = 'Snowbound-Tahoe' if int(platform.mac_ver()[0].split('.')[0] or 0) >= 26 else 'Snowbound-Sequoia' | 119 | # macOS 26 draws the Liquid Glass icon from the catalog; it holds no flattened renditions |
| 111 | shutil.copy2(icons / f'{icon}.icns', resources / 'Snowbound.icns') | 120 | # (see the icon folder's README), so earlier versions fall back to the icns. |
| 121 | shutil.copy2(icons / 'Snowbound-Sequoia.icns', resources / 'Snowbound.icns') | ||
| 122 | shutil.copy2(icons / 'Assets.car', resources) | ||
| 112 | # tools/release.py names the build, as 2026-09-29-r10, which About shows. | 123 | # tools/release.py names the build, as 2026-09-29-r10, which About shows. |
| 113 | build = os.environ.get('SNOWBOUND_BUILD') | 124 | build = os.environ.get('SNOWBOUND_BUILD') |
| 114 | versions = {} | 125 | versions = {} |
| ... | @@ -126,6 +137,7 @@ if build: | ... | @@ -126,6 +137,7 @@ if build: |
| 126 | 'NSHighResolutionCapable': True, | 137 | 'NSHighResolutionCapable': True, |
| 127 | 'NSPrincipalClass': 'NSApplication', | 138 | 'NSPrincipalClass': 'NSApplication', |
| 128 | 'CFBundleIconFile': 'Snowbound', | 139 | 'CFBundleIconFile': 'Snowbound', |
| 140 | 'CFBundleIconName': 'Snowbound-Tahoe', | ||
| 129 | 'NSLocalNetworkUsageDescription': 'Snowbound connects to file servers on your network to open and sync shared notebooks.', | 141 | 'NSLocalNetworkUsageDescription': 'Snowbound connects to file servers on your network to open and sync shared notebooks.', |
| 130 | 'NSMicrophoneUsageDescription':'Snowbound records audio into your notes when you choose Record Audio or Record Video.', | 142 | 'NSMicrophoneUsageDescription':'Snowbound records audio into your notes when you choose Record Audio or Record Video.', |
| 131 | 'NSCameraUsageDescription': 'Snowbound records video into your notes when you choose Record Video.', | 143 | 'NSCameraUsageDescription': 'Snowbound records video into your notes when you choose Record Video.', |
| ... | @@ -134,7 +146,8 @@ if build: | ... | @@ -134,7 +146,8 @@ if build: |
| 134 | 'NSUbiquitousContainerName': 'Snowbound', | 146 | 'NSUbiquitousContainerName': 'Snowbound', |
| 135 | 'NSUbiquitousContainerSupportedFolderLevels': 'Any', | 147 | 'NSUbiquitousContainerSupportedFolderLevels': 'Any', |
| 136 | }}, | 148 | }}, |
| 137 | } | versions | ({'LSMinimumSystemVersion': '10.6'} if args.snow_leopard else {}))) | 149 | 'LSMinimumSystemVersion': minimum, |
| 150 | } | versions)) | ||
| 138 | # 10.6 runs the bundle unsigned. | 151 | # 10.6 runs the bundle unsigned. |
| 139 | if not args.snow_leopard: | 152 | if not args.snow_leopard: |
| 140 | identity = args.sign != 'ad-hoc' and (args.sign_identity or developer_id()) | 153 | identity = args.sign != 'ad-hoc' and (args.sign_identity or developer_id()) |
tools/native/picture-objects.ps1 created+37| ... | @@ -0,0 +1,37 @@ | ||
| 1 | param([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. | ||
| 5 | Set-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 = '' | ||
| 14 | try { | ||
| 15 | $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) | ||
| 16 | $sectionId = '' | ||
| 17 | $app.OpenHierarchy('files.one', $notebookId, [ref]$sectionId, 0) | ||
| 18 | $pageId = '' | ||
| 19 | $app.CreateNewPage($sectionId, [ref]$pageId, 0) | ||
| 20 | $body = '<one:Title><one:OE><one:T><![CDATA[Pictures and files]]></one:T></one:OE></one:Title>' | ||
| 21 | $body += '<one:Outline><one:Position x="36" y="86"/><one:Size width="300" height="200"/><one:OEChildren>' | ||
| 22 | $body += '<one:OE><one:T><![CDATA[Picture below]]></one:T></one:OE>' | ||
| 23 | $body += '<one:OE><one:Image><one:Size width="120" height="90"/><one:Data>' + (& $data 'photo.png') + '</one:Data></one:Image></one:OE>' | ||
| 24 | $body += '<one:OE><one:InsertedFile pathSource="' + $file + '" preferredName="notes.txt"/></one:OE>' | ||
| 25 | $body += '<one:OE><one:T><![CDATA[Text after]]></one:T></one:OE>' | ||
| 26 | $body += '</one:OEChildren></one:Outline>' | ||
| 27 | $body += '<one:Image><one:Position x="400" y="86"/><one:Size width="120" height="90"/><one:Data>' + (& $data 'photo.png') + '</one:Data></one:Image>' | ||
| 28 | $body += '<one:InsertedFile pathSource="' + $file + '" preferredName="notes.txt"><one:Position x="560" y="86"/></one:InsertedFile>' | ||
| 29 | $body += '<one:Image><one:Position x="400" y="260"/><one:Size width="320" height="80"/><one:Data>' + (& $data 'ocr.png') + '</one:Data></one:Image>' | ||
| 30 | $xml = '<one:Page xmlns:one="' + $namespace + '" ID="' + $pageId + '">' + $body + '</one:Page>' | ||
| 31 | $app.UpdatePageContent($xml, [DateTime]::MinValue, 1, $true) | ||
| 32 | $app.SyncHierarchy($notebookId) | ||
| 33 | } finally { | ||
| 34 | [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) | ||
| 35 | [GC]::Collect() | ||
| 36 | [GC]::WaitForPendingFinalizers() | ||
| 37 | } | ||
tools/native_runner.py+1-1| ... | @@ -151,7 +151,7 @@ def capture(notebook, output, expected_pages=-1, author=None, screenshots=False, | ... | @@ -151,7 +151,7 @@ def capture(notebook, output, expected_pages=-1, author=None, screenshots=False, |
| 151 | 'author_timeout_seconds': author_timeout, | 151 | 'author_timeout_seconds': author_timeout, |
| 152 | 'inspect': inspect, | 152 | 'inspect': inspect, |
| 153 | 'collect_notebook': collect_notebook, | 153 | 'collect_notebook': collect_notebook, |
| 154 | 'base': json.loads(vm.BASE_MANIFEST.read_text()), | 154 | 'base': json.loads(vm.base_manifest('win7').read_text()), |
| 155 | 'scripts': {name: hashlib.sha256((scripts / name).read_bytes()).hexdigest() | 155 | 'scripts': {name: hashlib.sha256((scripts / name).read_bytes()).hexdigest() |
| 156 | for name in sorted(p.name for p in scripts.iterdir())}, | 156 | for name in sorted(p.name for p in scripts.iterdir())}, |
| 157 | }, indent=2) + '\n') | 157 | }, indent=2) + '\n') |
tools/release.py+5-3| ... | @@ -17,8 +17,8 @@ PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound') | ... | @@ -17,8 +17,8 @@ PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound') |
| 17 | URL = 'https://file.paperclover.net/shr/snowbound/' | 17 | URL = 'https://file.paperclover.net/shr/snowbound/' |
| 18 | KEY = Path.home() / '.config/snowbound/release-key' | 18 | KEY = Path.home() / '.config/snowbound/release-key' |
| 19 | ZONE = ZoneInfo('America/Los_Angeles') | 19 | ZONE = ZoneInfo('America/Los_Angeles') |
| 20 | PLATFORMS = ['macos-aarch64', 'macos-10.6', 'linux-x86_64', 'linux-aarch64', 'windows-x86_64', | 20 | PLATFORMS = ['macos-aarch64', 'macos-x86_64', 'macos-10.6', 'linux-x86_64', 'linux-aarch64', |
| 21 | 'windows-aarch64'] | 21 | 'windows-x86_64', 'windows-aarch64'] |
| 22 | # Windows 7 to 11 on x86_64 (nightly's tier-3 win7 target), and Windows 11 on Arm. | 22 | # Windows 7 to 11 on x86_64 (nightly's tier-3 win7 target), and Windows 11 on Arm. |
| 23 | WINDOWS = {'x86_64': 'x86_64-win7-windows-gnu', 'aarch64': 'aarch64-pc-windows-gnullvm'} | 23 | WINDOWS = {'x86_64': 'x86_64-win7-windows-gnu', 'aarch64': 'aarch64-pc-windows-gnullvm'} |
| 24 | CHECKS = [ | 24 | CHECKS = [ |
| ... | @@ -114,6 +114,8 @@ def build_mac(platform, folder, developer_id, notarize): | ... | @@ -114,6 +114,8 @@ def build_mac(platform, folder, developer_id, notarize): |
| 114 | signing = ['--sign', 'developer-id', '--sign-identity', IDENTITY] | 114 | signing = ['--sign', 'developer-id', '--sign-identity', IDENTITY] |
| 115 | else: | 115 | else: |
| 116 | signing = ['--sign', 'ad-hoc'] | 116 | signing = ['--sign', 'ad-hoc'] |
| 117 | if platform != 'macos-10.6': | ||
| 118 | signing += ['--arch', platform.removeprefix('macos-')] | ||
| 117 | run([sys.executable, ROOT / 'tools/canvas/build_macos.py', '--release', '--output', bundle, *signing]) | 119 | run([sys.executable, ROOT / 'tools/canvas/build_macos.py', '--release', '--output', bundle, *signing]) |
| 118 | archive = folder / 'archive.zip' | 120 | archive = folder / 'archive.zip' |
| 119 | if notarize and platform != 'macos-10.6': | 121 | if notarize and platform != 'macos-10.6': |
| ... | @@ -150,7 +152,7 @@ def main(): | ... | @@ -150,7 +152,7 @@ def main(): |
| 150 | parser.add_argument('--ad-hoc', action='store_true', | 152 | parser.add_argument('--ad-hoc', action='store_true', |
| 151 | help='Sign the macOS app ad hoc instead of with Developer ID, unnotarized') | 153 | help='Sign the macOS app ad hoc instead of with Developer ID, unnotarized') |
| 152 | args = parser.parse_args() | 154 | args = parser.parse_args() |
| 153 | developer_id = not args.ad_hoc and any(platform == 'macos-aarch64' for platform in args.platforms) | 155 | developer_id = not args.ad_hoc and any(platform in ('macos-aarch64', 'macos-x86_64') for platform in args.platforms) |
| 154 | identities = subprocess.run(['security', 'find-identity', '-v', '-p', 'codesigning'], | 156 | identities = subprocess.run(['security', 'find-identity', '-v', '-p', 'codesigning'], |
| 155 | capture_output=True, text=True).stdout | 157 | capture_output=True, text=True).stdout |
| 156 | if developer_id and IDENTITY not in identities: | 158 | if developer_id and IDENTITY not in identities: |
tools/test_equation_select.py created+40| ... | @@ -0,0 +1,40 @@ | ||
| 1 | from pathlib import Path | ||
| 2 | import re | ||
| 3 | import runpy | ||
| 4 | import shutil | ||
| 5 | from tempfile import TemporaryDirectory | ||
| 6 | import unittest | ||
| 7 | |||
| 8 | ROOT = Path(__file__).resolve().parent.parent | ||
| 9 | FIXTURE = ROOT / 'corpus/equation-select' | ||
| 10 | compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] | ||
| 11 | |||
| 12 | |||
| 13 | def 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 | |||
| 20 | class 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 | |||
| 39 | if __name__ == '__main__': | ||
| 40 | unittest.main() | ||
tools/test_release.py+5-2| ... | @@ -50,9 +50,12 @@ class ReleaseTest(unittest.TestCase): | ... | @@ -50,9 +50,12 @@ class ReleaseTest(unittest.TestCase): |
| 50 | [command[1] for command in commands[1:]]) | 50 | [command[1] for command in commands[1:]]) |
| 51 | 51 | ||
| 52 | self.assertEqual(signing('macos-aarch64', True, ['--key-id', 'K']), | 52 | self.assertEqual(signing('macos-aarch64', True, ['--key-id', 'K']), |
| 53 | (['--sign', 'developer-id', '--sign-identity', release['IDENTITY']], | 53 | (['--sign', 'developer-id', '--sign-identity', release['IDENTITY'], '--arch', 'aarch64'], |
| 54 | ['-c', 'notarytool', 'stapler', '-c'])) | 54 | ['-c', 'notarytool', 'stapler', '-c'])) |
| 55 | self.assertEqual(signing('macos-aarch64', False, None), (['--sign', 'ad-hoc'], ['-c'])) | 55 | self.assertEqual(signing('macos-x86_64', True, ['--key-id', 'K']), |
| 56 | (['--sign', 'developer-id', '--sign-identity', release['IDENTITY'], '--arch', 'x86_64'], | ||
| 57 | ['-c', 'notarytool', 'stapler', '-c'])) | ||
| 58 | self.assertEqual(signing('macos-aarch64', False, None), (['--sign', 'ad-hoc', '--arch', 'aarch64'], ['-c'])) | ||
| 56 | self.assertEqual(signing('macos-10.6', True, ['--key-id', 'K']), (['--snow-leopard'], ['-c'])) | 59 | self.assertEqual(signing('macos-10.6', True, ['--key-id', 'K']), (['--snow-leopard'], ['-c'])) |
| 57 | finally: | 60 | finally: |
| 58 | scope['run'] = run | 61 | scope['run'] = run |