authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-29 17:45:49-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-29 18:49:54-07:00
loga861b59985b1920526baa1ea2be635a760965f52
treec4f311ac4712c74a65dec0c1d645717bd679f363
parent3b58d8f8a9667e9578deab17b4bcd96e05515ad8
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: drawing tools and audio recording

- Drawing tools (#21): pens and highlighters from OneNote 2010's gallery, the stroke eraser, lasso select, move and delete, and line, arrow, rectangle and oval shapes. Each stroke is its own ink drawing, stored as OneNote stores it, with highlighters drawn by a multiply blend. - Audio recording (#36): Record Audio inserts a recording and OneNote's started line, links notes typed while recording to their moment, and plays them back from a transport over the page. Recordings are stored as the spec requires, as IMA ADPCM WAV that OneNote 2010 plays, and undo of a page delete restores them. Assisted-by: claude-opus-5.5

190 files changed, 7478 insertions(+), 848 deletions(-)

apps/ios/Snowbound/Notebooks.swift+75-10
......@@ -159,6 +159,8 @@ final class Notebook {
159159 private(set) var problem: String?
160160 /// The Files location being read, held open while the notebook is.
161161 private var accessed: URL?
162 /// Reports other apps' changes to a folder on this device to the library.
163 private var presenter: FolderPresenter?
162164
163165 init(id: UUID = UUID(), name: String, source: Source) {
164166 self.id = id
......@@ -167,6 +169,7 @@ final class Notebook {
167169 }
168170
169171 deinit {
172 presenter?.stop()
170173 if let handle { sb_library_free(handle) }
171174 accessed?.stopAccessingSecurityScopedResource()
172175 }
......@@ -175,39 +178,46 @@ final class Notebook {
175178 /// thread either way.
176179 func open(done: @escaping () -> Void) {
177180 let source = source
178 background({ () -> (OpaquePointer?, URL?, String?) in
181 // The library, the Files location held open, why it failed, and a local folder to watch.
182 background({ () -> (OpaquePointer?, URL?, String?, URL?) in
179183 var error: UnsafeMutablePointer<CChar>?
180184 switch source {
181185 case .server(let server):
182186 let library = sb_library_server(
183187 server.host, server.share, server.user, Keychain.password(server) ?? "", "", server.root,
184188 cacheDirectory.path, &error)
185 return (library, nil, take(error))
189 return (library, nil, take(error), nil)
186190 case .files(let bookmark):
187191 var stale = false
188192 guard let url = try? URL(resolvingBookmarkData: bookmark, bookmarkDataIsStale: &stale) else {
189 return (nil, nil, "The notebook was moved or deleted.")
193 return (nil, nil, "The notebook was moved or deleted.", nil)
190194 }
191195 let accessed = url.startAccessingSecurityScopedResource() ? url : nil
196 let local = onThisDevice(url)
192197 var library: OpaquePointer?
193198 // A coordinated read brings a cloud folder's files down before they are read.
194199 var coordination: NSError?
195200 NSFileCoordinator().coordinate(readingItemAt: url, options: [], error: &coordination) { url in
196 library = sb_library_open(url.path, cacheDirectory.path, &error)
201 library = sb_library_open(url.path, cacheDirectory.path, local, &error)
197202 }
198 return (library, accessed, take(error) ?? coordination?.localizedDescription)
203 return (library, accessed, take(error) ?? coordination?.localizedDescription, local ? url : nil)
199204 case .documents(let path):
200 let library = sb_library_open(
201 documentsDirectory.appendingPathComponent(path).path, cacheDirectory.path, &error)
202 return (library, nil, take(error))
205 let url = documentsDirectory.appendingPathComponent(path)
206 let library = sb_library_open(url.path, cacheDirectory.path, true, &error)
207 return (library, nil, take(error), url)
203208 case .path(let path):
204 return (sb_library_open(path, cacheDirectory.path, &error), nil, take(error))
209 let url = URL(fileURLWithPath: path)
210 let local = onThisDevice(url)
211 return (sb_library_open(path, cacheDirectory.path, local, &error), nil, take(error), local ? url : nil)
205212 }
206 }) { [self] (library, accessed, problem) in
213 }) { [self] (library, accessed, problem, watched) in
207214 self.accessed?.stopAccessingSecurityScopedResource()
208215 self.accessed = accessed
216 presenter?.stop()
217 presenter = nil
209218 if let handle { sb_library_free(handle) }
210219 handle = library
220 if let library, let watched { presenter = FolderPresenter(watching: watched, for: library) }
211221 self.problem = library == nil ? (problem ?? "The notebook can’t be read.") : nil
212222 follow()
213223 reload(done: done)
......@@ -244,6 +254,61 @@ final class Notebook {
244254 }
245255}
246256
257/// Whether `url` is kept on this device, rather than by a file provider that keeps it elsewhere
258/// too, as iCloud Drive does.
259private func onThisDevice(_ url: URL) -> Bool {
260 let values = try? url.resourceValues(forKeys: [.volumeIsLocalKey, .isUbiquitousItemKey])
261 return values?.volumeIsLocal == true && values?.isUbiquitousItem != true
262}
263
264/// Tells a library which files of its folder on this device changed, as file coordination
265/// reports other apps' writes; the library checks just those sections.
266final class FolderPresenter: NSObject, NSFilePresenter {
267 let presentedItemURL: URL?
268 let presentedItemOperationQueue: OperationQueue = {
269 let queue = OperationQueue()
270 queue.maxConcurrentOperationCount = 1
271 return queue
272 }()
273 private let root: String
274 private let library: OpaquePointer
275
276 init(watching url: URL, for library: OpaquePointer) {
277 presentedItemURL = url
278 root = url.standardizedFileURL.resolvingSymlinksInPath().path
279 self.library = library
280 super.init()
281 NSFileCoordinator.addFilePresenter(self)
282 }
283
284 /// Stops reporting, before the library goes.
285 func stop() {
286 NSFileCoordinator.removeFilePresenter(self)
287 presentedItemOperationQueue.waitUntilAllOperationsAreFinished()
288 }
289
290 func presentedItemDidChange() { touched("") }
291 func presentedSubitemDidChange(at url: URL) { touched(url) }
292 func presentedSubitemDidAppear(at url: URL) { touched(url) }
293 func presentedSubitem(at oldURL: URL, didMoveTo newURL: URL) {
294 touched(oldURL)
295 touched(newURL)
296 }
297 func accommodatePresentedSubitemDeletion(at url: URL, completionHandler: @escaping (Error?) -> Void) {
298 touched(url)
299 completionHandler(nil)
300 }
301
302 private func touched(_ url: URL) {
303 let path = url.standardizedFileURL.resolvingSymlinksInPath().path
304 touched(path.hasPrefix(root + "/") ? String(path.dropFirst(root.count + 1)) : "")
305 }
306
307 private func touched(_ path: String) {
308 sb_library_touched(library, path)
309 }
310}
311
247312/// The notebooks the list shows, kept across launches.
248313enum Notebooks {
249314 private struct Entry: Codable {
arc/canvas.md+27
......@@ -106,6 +106,33 @@ on the page itself, as OneNote 2010 places it: the same column at the caret's
106106grid point, outside any outline. It selects, drags on the grid, deletes and
107107moves with Insert Space as a picture does, and opens and saves as above.
108108
109## Recordings
110
111Record Audio works as OneNote 2010's does. The caret's paragraph splits around
112a line saying when recording started, in OneNote's grey `cite` style, and the
113caret goes on below it. Text written while recording links to the moment it
114was written. On Stop, the file goes in above that line, named after the page.
115Hovering a linked note or a recording shows OneNote's blue play button in the
116margin, and a click asks the host to play from that moment. The host records a
11716 kHz WAV and stores it as IMA ADPCM, which OneNote plays.
118
119## Drawing
120
121The Draw tab's tools work as OneNote 2010's do with a mouse (`corpus/ink-tools`).
122Every stroke and every shape is a drawing of its own, added on top of the page
123when the pen lifts: one edit and one undo step, published as one appended
124revision, or in one with the strokes queued behind it during a sync round trip,
125as keystrokes are. Shapes are ink too. Their corners snap to the placement grid, and the
126drawing keeps the shape's kind and anchors beside its strokes, which OneNote
127edits it by. A highlighter's rectangular tip multiplies what lies beneath, so
128text under it stays dark. The stroke eraser takes whole drawings, or only the
129strokes it touches in an older drawing of several. The lasso picks the drawings
130with most of their points inside it. They move by their offset, as OneNote
131moves ink, and Delete removes them. Escape returns to Select & Type, where a
132click on ink picks it. The default pen draws in the open section's accent at
133the light theme's shade, so a stroke stores one real colour and shows it in both
134themes.
135
109136## Content the editor doesn't understand
110137
111138Nothing is lost for being unfamiliar. A paragraph the canvas can't draw
arc/file-format.md+8-5
......@@ -165,11 +165,14 @@ MS-ONESTORE and MS-ONE are good, but they are not the whole truth:
165165 stores beside every author name. Snowbound writes them too.
166166- Some things the spec requires aren't needed by OneNote to open a file.
167167 Snowbound writes them anyway, because other readers exist.
168- Ink, equations and media recordings are absent from MS-ONE entirely. Ink and
169 math were reverse-engineered, each against an independent oracle. For ink,
170 the stroke extents from OneNote's own export. For math, the MathML it exports,
171 matched byte for byte. Recordings and embedded objects are read and kept, but
172 never authored.
168- Ink and equations are absent from MS-ONE entirely, and so is the link from a
169 note to a moment in a recording. Ink and math were reverse-engineered, each
170 against an independent oracle. For ink, the stroke extents from OneNote's own
171 export, and for the Draw tab's pens, highlighters and shapes, the objects
172 OneNote stored while drawing them in the lab (`corpus/ink-tools`). For math, the MathML it exports, matched byte for byte. Recordings
173 and their links follow what OneNote stored while recording in the lab, read
174 back through its XML export (`corpus/recording`). Embedded objects are read
175 and kept, but never authored.
173176
174177Readers stay tolerant (files in the wild are older, odder, or written by other
175178tools), while the writer stays strict.
arc/sync.md+36-18
......@@ -16,7 +16,7 @@ happens when the share is busy, changed or gone.
1616```text
1717UI thread (winit, UIKit) section thread sync thread
1818──────────────────────── ────────────────────────────── ──────────────────────────────
19editor emits Edit (ops) ───â–º apply to the parsed Section poll the stamp (header + length)
19editor emits Edit (ops) ───â–º apply to the parsed Section read the stamp (header + length)
2020 and returns at once write each burst to SQLite unchanged: ask for a seal,
2121page reads â—„──────────────── answered from the Section publish it
2222events â—„──────────────────── Changed, Rejected, published changed: read once, ask
......@@ -33,8 +33,9 @@ events â—„──────────────────── Changed,
3333 fresh thread that builds its own section while the old one keeps answering
3434 reads, so opening a page never waits on the network.
3535- **The sync thread** (`notebook::worker`, stepping `notebook::sync`) does all
36 network I/O. It polls, publishes and rereads. When the queue is idle it
37 reads nothing but the header.
36 network I/O. It reads the stamp when a local edit waits or the notebook's watch
37 reports the file (every two seconds where nothing watches), then publishes or
38 rereads. Idle, it reads nothing.
3839
3940`notebook::session` wraps this up for apps. `Notebook` covers discovery and
4041structure, `Section` covers one section's pages, edits, events and conflict
......@@ -88,7 +89,7 @@ else:
8889```
8990
9091A batch stays open while the sync thread is busy, so keystrokes that arrive
91during one round trip publish together in the next. Polling costs a
92during one round trip publish together in the next. A check costs a
9293kilobyte-sized read. A publication costs one appended revision, not the
9394section.
9495
......@@ -159,7 +160,7 @@ client with the account the system keeps for that mount. It falls back to the
159160mount only when it can't sign in that way.
160161
161162A file's identity is its root object space, not its server file ID, which
162changes when maintenance replaces the file. Polling opens by path each time
163changes when maintenance replaces the file. A check opens by path each time
163164for the same reason.
164165
165166## Offline
......@@ -180,31 +181,48 @@ OneNote keeps every section of an open notebook in sync and on this computer, no
180181the one on screen. Watched in the lab with a notebook of 200 sections on Samba, OneNote
1811822010:
182183
183- reads every section file once when the notebook opens (all 200 within 15 seconds), so a
184 section never shown before opens with the share gone;
184- reads every section file once when it first opens the notebook (all 200 within 15
185 seconds), so a section never shown before opens with the share gone; opening it again
186 with its cache warm, it only lists the folders and reads the files listed otherwise;
185187- arms one CHANGE_NOTIFY on the notebook's folder, with WATCH_TREE and a filter of names,
186188 attributes, size and last write, and otherwise sends nothing: 22 idle minutes put no SMB
187 request on the wire, not even an echo;
189 request on the wire, not even an echo, and the section on screen waits on the same watch;
188190- lists the folder once when the server reports a change, and reads only the section that
189191 changed, some seconds later;
190192- tries a section it could not reach again about every 31 seconds.
191193
192Snowbound's `session::Background` does the same, with one thread per notebook:
194Snowbound does the same. Opening a notebook lists its folders and reads only the files whose
195listed size or last write time changed since it last read them, keeping what it took from
196each, with the file's stamp, in the cache (`discover::Cache`). On a share, a changed section
197whose replica already holds it as it stands, as after its own edits published, costs just a
198stamp read. `session::Background` then keeps the sections in sync, one thread per notebook:
193199
194200```text
195share ─ CHANGE_NOTIFY (tree) ─â–º the sections it names are due in 1 s ─â–º stamp ─â–º moved: rebase
196first pass: one section every 100 ms; a section without a replica gets its offline copy
197failing: again in 31 s otherwise: again in an hour, against a lost notification
201connect: arm the watch, list every folder
202 listed as before: compare the known stamp with the replica, locally
203 listed otherwise: check, one section every 100 ms
204share ─ CHANGE_NOTIFY (tree) ─â–º the file it names is due in 1 s ─â–º stamp ─â–º moved: rebase
205 └â–º a folder it names is listed in 1 s ─â–º the files listed otherwise
206failing: again in 31 s otherwise: again in an hour, against a lost notification
198207```
199208
200209A check costs one stamp read. Only a section whose replica has edits waiting, or whose file
201210moved past the replica's base, has its replica opened for the usual sync steps, and it is
202closed again afterwards; the open section is left to its own session. A connection that
203drops takes its watch with it, so the next one checks every section again. A notebook in a
204folder on this computer has no copies and learns of changes from FSEvents or inotify; one on
205a network volume the system mounted, whose server's changes those never report, keeps copies
206and is checked every 15 seconds. Closing a notebook deletes its copies, except any with
207edits still waiting.
211closed again afterwards; a section without one gets its offline copy. The section open on
212screen is left to its session, whose worker no longer polls: the watch wakes it, and once the
213session lets go of the replica the background checks the file at once. A connection that drops
214takes its watch with it, so the next one lists every folder again, and so does working online
215again.
216
217A notebook in a folder on this computer has no copies and learns of changes from FSEvents or
218inotify. One on a network volume the system mounted keeps copies. A Mac's SMB mount reports
219another client's change to FSEvents only for a folder watched in its own right, and names just
220that folder, so every folder of the notebook is watched and a report lists it. A Linux SMB
221mount reports none of another client's changes to inotify, so there each section's stamp is
222read every 15 seconds. On iOS, a folder on the device (Snowbound's own, or On My iPhone) has no
223copies and learns of other apps' writes through file coordination (`NSFilePresenter`); one a
224file provider keeps elsewhere, as iCloud Drive does, keeps copies and is checked every 15
225seconds. Closing a notebook deletes its copies, except any with edits still waiting.
208226
209227## Notebook structure
210228
corpus/ink-tools/README.md created+55
......@@ -0,0 +1,55 @@
1# The Draw tab's tools
2
3`native-ui/notebook/ink.one` is OneNote 2010's own work in a lab clone, drawn with the
4mouse from the Draw tab (screenshots of the tab, the pen gallery and Color & Thickness
5were taken alongside):
6
7- "Pens": the first favourite pen (black, 35 HIMETRIC wide), a second stroke drawn at once
8 beside it, the red thick pen, the yellow highlighter, and shapes: rectangle, line,
9 arrow and oval with the default shape pen (black, 50 wide).
10- "Gallery": one stroke with each of the 14 favourite pens in the gallery's order.
11- "Edits": four strokes, the second removed by the stroke eraser, the third lassoed and
12 dragged 60 by 20 pixels, the fourth lassoed and deleted, then a rectangle and a line.
13- "Shapes2": arrows at other angles and pen widths, a rectangle moved after drawing, and
14 lines in each of Color & Thickness's nine widths.
15
16What it shows, and `crates/onestore/tests/page_ink.rs` pins:
17
18- Every stroke and every shape is an ink container of its own, on top of the page; OneNote
19 never adds a stroke to an earlier drawing. Erasing or deleting removes the container;
20 moving sets its offset and leaves the strokes as they were.
21- A pen's drawing attributes ignore pressure. A highlighter's tip is a 70 by 400
22 rectangle (pen tip 1) with transparency 127 and raster operation 9 (MaskPen), and its
23 stroke's bias is 2. Strokes are numbered through the page.
24- Pen widths are 25, 35, 50, 70, 100, 150, 200, 350 and 500 HIMETRIC. The favourites are
25 black, red `0x241ced`, blue `0xbb6531`, green `0x367d17` and grey `0x808080` at 35 and
26 50, and yellow, cyan, green and magenta highlighters; black is stored as no colour.
27- A shape's container stores 2 in `0x14001d4e` (1 for a drawn stroke), its kind in
28 `0x0c001d4f` (11 a line or arrow, 12 a closed shape) and its geometry: a line's ends in
29 half inches (`0x1c001dac`), or a closed shape's transform from the unit square in half
30 inches and its anchors in the unit square (`0x1c001daa`: 8 for a rectangle, 4 for an
31 oval). Its strokes carry only their path and pen, whose dimension table spans the whole
32 32-bit range at resolution 1000. A rectangle is drawn clockwise from its top left, an
33 oval in 50 steps clockwise from its right end, and an arrow's head is a second stroke of
34 two barbs at atan(1/2) to the line, 8 points plus twice the pen width long. Shape
35 corners snap to the 18 pt grid.
36
37`native-ui/read` is a fresh OneNote 2010 read of that file.
38
39`drawn/candidate` is the page view's output for
40`drawing_tools_store_each_stroke_and_undo_it_in_one_step` in
41`crates/canvas/tests/ink_tools.rs` (`CANVAS_INK_EXPORT`): a pen stroke in an accent
42colour, a yellow highlighter, and a rectangle, oval, arrow and line dragged out with the
43shape tools, each its own edit. `drawn/cold` is a fresh OneNote 2010 read: six
44`InkDrawing`s, the four shapes with their `ShapeInfo` anchors, drawn as Snowbound drew
45them (`page-000.png`).
46
47`edited/candidate` is `onenote_drawings_take_erasing_moving_and_more_strokes` in
48`page_ink.rs` (`ONESTORE_INK_TOOLS_EDIT_EXPORT`): the native file with the red zigzag on
49"Pens" deleted, the vertical line moved by (36, 18), and the six drawings above added
50below. `edited/cold` shows OneNote reading OneNote's own drawings where they were, the
51moved one offset, and Snowbound's beside them.
52
53`tools/test_ink_tools.py` checks all three without a VM. Regenerate the candidates with
54the export variables set to new absolute directories, then cold-open each with
55`tools/native_runner.py OUTPUT COLD --expected-pages N --collect-notebook --screenshots`.
corpus/ink-tools/drawn/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/ink-tools/drawn/candidate/Open Notebook.onetoc2 differ
corpus/ink-tools/drawn/candidate/ink.one created
Binary files /dev/null and b/corpus/ink-tools/drawn/candidate/ink.one differ
corpus/ink-tools/drawn/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-68F30CFB -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/ink-tools/drawn/cold/machine.json created+1
......@@ -0,0 +1 @@
1{"name": "m6-68f30cfb", "hostname": "ONE-M6-68F30CFB"}
corpus/ink-tools/drawn/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/ink-tools/drawn/cold/notebook/Open Notebook.onetoc2 differ
corpus/ink-tools/drawn/cold/notebook/ink.one created
Binary files /dev/null and b/corpus/ink-tools/drawn/cold/notebook/ink.one differ
corpus/ink-tools/drawn/cold/read/environment.json created+7
......@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-68F30CFB",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/ink-tools/drawn/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="{194A10C2-CAC1-4FA7-8B8B-C54AC6F4AF88}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T01:08:33.000Z" color="#B49EDE"><one:Section name="ink" ID="{F417F32E-49B0-07C3-19B1-FEF33492497E}{1}{B0}" path="C:\one-tests\runs\capture\notebook\ink.one" lastModifiedTime="2026-09-30T01:08:33.000Z" color="#8AA8E4"><one:Page ID="{B7246258-E7DF-06F3-078F-026817A32A8F}{1}{B0}" name="Drawn in Snowbound" dateTime="2026-09-30T01:04:56.000Z" lastModifiedTime="2026-09-30T01:08:33.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/ink-tools/drawn/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": "Drawn in Snowbound - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/ink-tools/drawn/cold/read/page-000.png created
Binary files /dev/null and b/corpus/ink-tools/drawn/cold/read/page-000.png differ
corpus/ink-tools/drawn/cold/read/page-000.xml created+19
......@@ -0,0 +1,19 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{B7246258-E7DF-06F3-078F-026817A32A8F}{1}{B0}" name="Drawn in Snowbound" dateTime="2026-09-30T01:04:56.000Z" lastModifiedTime="2026-09-30T01:08:33.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><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-30T01:04:56.000Z" objectID="{F417F32E-49B0-07C3-19B1-FEF33492497E}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="135.9570770263672" height="13.4277172088623"/><one:OEChildren><one:OE creationTime="2026-09-30T01:04:56.000Z" lastModifiedTime="2026-09-30T01:04:56.000Z" objectID="{F417F32E-49B0-07C3-19B1-FEF33492497E}{24}{B0}" alignment="left"><one:T><![CDATA[Drawn in Snowbound]]></one:T></one:OE></one:OEChildren></one:Outline><one:InkDrawing lastModifiedTime="2022-06-18T04:26:41.000Z" objectID="{8AF0CB4B-77CC-0D78-37A4-D8914A8FD516}{1}{B0}"><one:Position x="60.00944900512695" y="138.0188903808594" z="1"/><one:Size width="160.0157470703125" height="23.98110198974609"/><one:Data>AKACHQOuA0IBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
3Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREn7Tq
4A0UjRiMFCAtlT2ZnaGRpGRQyCACAHgIh4uJBMwgA8BACi8XiQWQDFUYAQAqDASmC/j27+OVQJQEo
5CUBKAIfwuaeF+mIRaPSKUSaUSKRRqIQyAQeGxmNyWSyuSymPx2KwuBwSGRaNSKTSaUSaQR6JAAUU
6RgBAAAAJBAUURgAAAAASABEgx3HwO0nsdkiiPkWOgG81fAoAESCAFrmby4LYAQMVRgBACgARIGia
70z8rfHRA
8</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2022-06-18T04:26:43.000Z" objectID="{8AF0CB4B-77CC-0D78-37A4-D8914A8FD516}{7}{B0}"><one:Position x="252.0" y="140.4000091552734" z="3"/><one:Size width="72.02835845947266" height="54.02833938598632"/><one:ShapeInfo isLine="false"><one:AnchorPoint x="252.0" y="140.3999938964844"/><one:AnchorPoint x="288.0" y="140.3999938964844"/><one:AnchorPoint x="324.0" y="140.3999938964844"/><one:AnchorPoint x="324.0" y="167.3999786376953"/><one:AnchorPoint x="324.0" y="194.3999786376953"/><one:AnchorPoint x="288.0" y="194.3999786376953"/><one:AnchorPoint x="252.0" y="194.3999786376953"/><one:AnchorPoint x="252.0" y="167.3999786376953"/></one:ShapeInfo><one:Data>AFMdBMQBlAEBECoAGh+LQnBFhHWAvtBF2QQDC0gQRJ+06gNFMkYyBQILZGQDFUYAQAoiBYfxn5eI
9BvwEr+AlfwErAIfwqYeFTHtYO1h7WEADFUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2022-06-18T04:26:44.000Z" objectID="{8AF0CB4B-77CC-0D78-37A4-D8914A8FD516}{10}{B0}"><one:Position x="342.0" y="140.4566955566406" z="4"/><one:Size width="90.02835845947266" height="53.9149513244629"/><one:ShapeInfo isLine="false"><one:AnchorPoint x="387.0" y="140.3999938964844"/><one:AnchorPoint x="432.0" y="167.3999786376953"/><one:AnchorPoint x="387.0" y="194.3999786376953"/><one:AnchorPoint x="342.0" y="167.3999786376953"/></one:ShapeInfo><one:Data>AK0BHQT0AZQBARAqABofi0JwRYR1gL7QRdkEAwtIEESftOoDRTJGMgUCC2RkAxVGAEAKfDOH8yx3
10mWpl8vl8rk8ljsbicPhMBgEIh0SjUckkmlUul0umUul0ulUmkkcjUSh0IgEBhMPicbjslk8rl8vl
114Ibw5xeG/WDhYuNj5eTm5mbnZ2dnZuZm5OXj42LhYOBhISKjI6WkpqYmpycnJyamJqSlo6MioSEA
12AxVGAEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2022-06-18T04:26:42.000Z" objectID="{8AF0CB4B-77CC-0D78-37A4-D8914A8FD516}{3}{B0}"><one:Position x="60.00944900512695" y="190.0063018798828" z="2"/><one:Size width="160.0157470703125" height="0.0283412933349609"/><one:Data>APsBHQOwAx4BYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
13Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMUSBRE//8D
14RUZHAUaQA1cJAAAAUH8FCAtlT2ZnaGRpGRQyCACAHgIh4uJBMwgA8BACi8XiQWQDFUYAQApWFYL+
15Pbv407JRKSiUgv7NC/s0MAAABRRGAIAAAAkEBRRGAIAAABIAESDYrR159N2MQ7BkvoW/LnuoCgAR
16IACtUZzLgtgBAxVGAEAKABEg4dbRP+nao0A=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2022-06-18T04:26:45.000Z" objectID="{8AF0CB4B-77CC-0D78-37A4-D8914A8FD516}{14}{B0}"><one:Position x="252.0" y="230.4000091552734" z="5"/><one:Size width="108.0283584594727" height="37.55904388427734"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="252.0" y="230.3999786376953"/><one:AnchorPoint x="360.0" y="266.3999938964844"/></one:ShapeInfo><one:Data>AFodA6QCaAEQKgAaH4tCcEWEdYC+0EXZBAMLSBBEn7TqA0UyRjIFAgtkZAMVRgBAChACD0V0xnAP
17P4CS2AMVRgBAChgDh/J815Nf+OPAh/Gud4zs+AvAAxVGAEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2022-06-18T04:26:46.000Z" objectID="{8AF0CB4B-77CC-0D78-37A4-D8914A8FD516}{17}{B0}"><one:Position x="378.0" y="230.4000091552734" z="6"/><one:Size width="72.02835845947266" height="0.0283412933349609"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="378.0" y="230.3999786376953"/><one:AnchorPoint x="450.0" y="230.3999786376953"/></one:ShapeInfo><one:Data>AEAdA8QBBAEQKgAaH4tCcEWEdYC+0EXZBAMLSBBEn7TqA0UyRjIFAgtkZAMVRgBAChACD2gu+AwO
18fwH8AAMVRgBA
19</one:Data></one:InkDrawing></one:Page>
corpus/ink-tools/drawn/cold/read/payloads.json created+3
......@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/ink-tools/drawn/cold/run.json created+18
......@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/snowbound-ink/gate/drawn-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/ink-tools/drawn/cold/scripts/cold.ps1 created+27
......@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/ink-tools/drawn/cold/scripts/read.ps1 created+144
......@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/ink-tools/drawn/cold/source.json created+14
......@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1596,
5 "sha256": "d5a80cff53089bfedc57be7a900b345fe413b56cb56d5b88097633fb2612ae7c",
6 "mtime_ns": 1790730300920529312
7 },
8 {
9 "path": "ink.one",
10 "bytes": 11656,
11 "sha256": "d1c8e2b2159d4635c017ba4782e53a0f14fabd75889c484c8b10b75107b5e5cf",
12 "mtime_ns": 1790730300920314814
13 }
14]
corpus/ink-tools/drawn/cold/teardown.json created+1
......@@ -0,0 +1 @@
1{"absent": true}
corpus/ink-tools/edited/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/ink-tools/edited/candidate/Open Notebook.onetoc2 differ
corpus/ink-tools/edited/candidate/ink.one created
Binary files /dev/null and b/corpus/ink-tools/edited/candidate/ink.one differ
corpus/ink-tools/edited/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-4F2D85E9 -ExpectedPages 5 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 5 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/ink-tools/edited/cold/machine.json created+1
......@@ -0,0 +1 @@
1{"name": "m6-4f2d85e9", "hostname": "ONE-M6-4F2D85E9"}
corpus/ink-tools/edited/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/ink-tools/edited/cold/notebook/Open Notebook.onetoc2 differ
corpus/ink-tools/edited/cold/notebook/ink.one created
Binary files /dev/null and b/corpus/ink-tools/edited/cold/notebook/ink.one differ
corpus/ink-tools/edited/cold/read/environment.json created+7
......@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-4F2D85E9",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/ink-tools/edited/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="{F96D64FB-64F3-47C2-8710-5BB103E59097}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T01:08:42.000Z" color="#B49EDE"><one:Section name="ink" ID="{A77A1597-6DDA-0C9B-05DD-2CD63B5CFE24}{1}{B0}" path="C:\one-tests\runs\capture\notebook\ink.one" lastModifiedTime="2026-09-30T01:08:42.000Z" color="#8AA8E4"><one:Page ID="{4D0D633D-2B24-0838-0CE3-33E1DB10DA2C}{1}{B0}" name="Beside the drawing" dateTime="2026-09-13T00:25:49.000Z" lastModifiedTime="2026-09-30T01:08:42.000Z" pageLevel="1"/><one:Page ID="{DB8D8C2E-3789-4958-BF3F-202608CD8306}{1}{B0}" name="Pens" dateTime="2026-09-30T00:18:53.000Z" lastModifiedTime="2026-09-30T00:19:49.000Z" pageLevel="1"/><one:Page ID="{AEC739A2-6F42-4515-84C1-D418854C78FF}{1}{B0}" name="Gallery" dateTime="2026-09-30T00:22:50.000Z" lastModifiedTime="2026-09-30T00:23:40.000Z" pageLevel="1"/><one:Page ID="{5282ABA7-9026-4149-9998-6AF690A91A8A}{1}{B0}" name="Edits" dateTime="2026-09-30T00:24:17.000Z" lastModifiedTime="2026-09-30T00:25:14.000Z" pageLevel="1"/><one:Page ID="{165E0C0A-C75B-4477-A2A9-8F9F05942B86}{1}{B0}" name="Shapes2" dateTime="2026-09-30T00:29:10.000Z" lastModifiedTime="2026-09-30T00:32:48.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/ink-tools/edited/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": "Shapes2 - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/ink-tools/edited/cold/read/page-000.png created
Binary files /dev/null and b/corpus/ink-tools/edited/cold/read/page-000.png differ
corpus/ink-tools/edited/cold/read/page-000.xml created+24
......@@ -0,0 +1,24 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{165E0C0A-C75B-4477-A2A9-8F9F05942B86}{1}{B0}" name="Shapes2" dateTime="2026-09-30T00:29:10.000Z" lastModifiedTime="2026-09-30T00:32:48.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.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:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T00:29:11.000Z" lastModifiedTime="2026-09-30T00:29:11.000Z" objectID="{A91E593C-996E-42B0-9B62-886C2AC55CB7}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Shapes2]]></one:T></one:OE></one:Title><one:InkDrawing lastModifiedTime="2026-09-30T00:29:28.000Z" objectID="{A91E593C-996E-42B0-9B62-886C2AC55CB7}{40}{B0}"><one:Position x="270.0" y="50.39999771118164" z="1"/><one:Size width="21.60001373291016" height="72.02834320068359"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="270.0" y="50.39999771118164"/><one:AnchorPoint x="288.0" y="122.400001525879"/></one:ShapeInfo><one:Data>AFodA0TMAQEQKgAaH4tCcEWEdYC+0EXZBAMLSBBE/wFFlgFGlgEFAgtkZAMVRgBAChACD0pqnsAO
3G8kN4AMVRgBAChgDh/H214/1+B3Ah/BeZ4HX+ZfAAxVGAEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:29:25.000Z" objectID="{A91E593C-996E-42B0-9B62-886C2AC55CB7}{31}{B0}"><one:Position x="36.0" y="74.74960327148437" z="0"/><one:Size width="190.5165405273437" height="14.740159034729"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="36.0" y="86.4000015258789"/><one:AnchorPoint x="226.5000152587891" y="81.75000762939453"/></one:ShapeInfo><one:Data>AFkdA4gEMgEQKgAaH4tCcEWEdYC+0EXZBAMLSBBE/wFFlgFGlgEFAgtkZAMVRgBAChACDhPZ82AN
4X0LRAAMVRgBAChcDh/FGd4ks+adAh/AYx4BK5gADFUYAQA==</one:Data></one:InkDrawing><one:InkDrawing isUnclassified="true" lastModifiedTime="2026-09-30T00:32:48.000Z" objectID="{A91E593C-996E-42B0-9B62-886C2AC55CB7}{123}{B0}"><one:Position x="54.0" y="160.4976348876953" z="12"/><one:Size width="60.03779220581055" height="7.540157318115234"/><one:Data>ALYCHQOiARYBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
5Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFI0Yj
6BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKngE4h+1g7FmATebTWAzGAoBA
7ZnNJvACZzKazSawCAwCAwCAwCazKATWZTOZTOaTVMpnNJqmUzmk1TKZppNSH8NXXhq7mk1mUzmUz
8mk1TKZppNUymZNJqmUzTSagAAAAFFEYAQAAACQQFFEYAAAAAEgARIH7bL5LspZlKvDhZGnSznPUK
9P0Ai5AG7fQcwO6ADFUYAQAo/QCMlgf3zx5gRwA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:29:31.000Z" objectID="{A91E593C-996E-42B0-9B62-886C2AC55CB7}{48}{B0}"><one:Position x="81.0" y="162.9000244140625" z="2"/><one:Size width="72.02835845947266" height="54.02833938598632"/><one:ShapeInfo isLine="false"><one:AnchorPoint x="36.0" y="140.4005889892578"/><one:AnchorPoint x="71.99999237060547" y="140.4005889892578"/><one:AnchorPoint x="107.9999847412109" y="140.4005889892578"/><one:AnchorPoint x="107.9999847412109" y="167.4005889892578"/><one:AnchorPoint x="107.9999847412109" y="194.4005889892578"/><one:AnchorPoint x="71.99999237060547" y="194.4005889892578"/><one:AnchorPoint x="36.0" y="194.4005889892578"/><one:AnchorPoint x="36.0" y="167.4005889892578"/></one:ShapeInfo><one:Data>AEwdBMIBkgEBECoAGh+LQnBFhHWAvtBF2QQDBkgQRRlGGQUCC2RkAxVGAEAKIAWH6GroavASv4CV
10/ASsh/Cph4VMe1g7WHtYQAMVRgBA
11</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:29:49.000Z" objectID="{A91E593C-996E-42B0-9B62-886C2AC55CB7}{56}{B0}"><one:Position x="180.0" y="209.2535400390625" z="3"/><one:Size width="234.0283508300781" height="3.174808502197266"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="180.0" y="212.4000091552734"/><one:AnchorPoint x="414.0" y="209.25"/></one:ShapeInfo><one:Data>ADsdA/IEDAEQKgAaH4tCcEWEdYC+0EXZBAMGSBBFI0YjBQILZGQDFUYAQAoQAg8xnOQ0DnUVzWAD
12FUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:29:54.000Z" objectID="{A91E593C-996E-42B0-9B62-886C2AC55CB7}{64}{B0}"><one:Position x="180.0" y="212.4000091552734" z="4"/><one:Size width="234.0283508300781" height="4.365348815917969"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="180.0" y="212.4000091552734"/><one:AnchorPoint x="414.0" y="216.75"/></one:ShapeInfo><one:Data>ADsdA/QEEAEQKgAaH4tCcEWEdYC+0EXZBAMGSBBFMkYyBQILZGQDFUYAQAoQAg8xnOQ0DnUV3eAD
13FUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:29:59.000Z" objectID="{A91E593C-996E-42B0-9B62-886C2AC55CB7}{72}{B0}"><one:Position x="180.0" y="224.2488098144531" z="5"/><one:Size width="234.0283508300781" height="6.179534912109375"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="180.0" y="230.4000091552734"/><one:AnchorPoint x="414.0" y="224.2500152587891"/></one:ShapeInfo><one:Data>ADsdA/YEFgEQKgAaH4tCcEWEdYC+0EXZBAMGSBBFRkZGBQILZGQDFUYAQAoQAg8xnOQ0Dn8B7nAD
14FUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:30:10.000Z" objectID="{A91E593C-996E-42B0-9B62-886C2AC55CB7}{88}{B0}"><one:Position x="180.0" y="230.4000091552734" z="7"/><one:Size width="234.0283508300781" height="8.87243843078613"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="180.0" y="230.4000091552734"/><one:AnchorPoint x="414.0" y="239.25"/></one:ShapeInfo><one:Data>AD0dA/wEIgEQKgAaH4tCcEWEdYC+0EXZBAMISBBFlgFGlgEFAgtkZAMVRgBAChACDzGc5DQPP4CD
154AMVRgBA
16</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:30:05.000Z" objectID="{A91E593C-996E-42B0-9B62-886C2AC55CB7}{80}{B0}"><one:Position x="180.0" y="230.4000091552734" z="6"/><one:Size width="234.0283508300781" height="1.38896369934082"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="180.0" y="230.4000091552734"/><one:AnchorPoint x="414.0" y="231.75"/></one:ShapeInfo><one:Data>ADsdA/gEDAEQKgAaH4tCcEWEdYC+0EXZBAMGSBBFZEZkBQILZGQDFUYAQAoQAg8xnOQ0Dn8B/wAD
17FUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:30:20.000Z" objectID="{A91E593C-996E-42B0-9B62-886C2AC55CB7}{104}{B0}"><one:Position x="180.0" y="248.4000091552734" z="9"/><one:Size width="234.0283508300781" height="5.867712020874023"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="180.0" y="248.4000091552734"/><one:AnchorPoint x="414.0" y="254.25"/></one:ShapeInfo><one:Data>AD0dA4oFKgEQKgAaH4tCcEWEdYC+0EXZBAMISBBF3gJG3gIFAgtkZAMVRgBAChACDzGc5DQPRHaM
18JAMVRgBA
19</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:30:15.000Z" objectID="{A91E593C-996E-42B0-9B62-886C2AC55CB7}{96}{B0}"><one:Position x="180.0" y="246.7559051513672" z="8"/><one:Size width="234.0283508300781" height="1.672445297241211"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="180.0" y="248.4000091552734"/><one:AnchorPoint x="414.0" y="246.7500152587891"/></one:ShapeInfo><one:Data>AD0dA4AFFAEQKgAaH4tCcEWEdYC+0EXZBAMISBBFyAFGyAEFAgtkZAMVRgBAChACDzGc5DQPRHaI
20BAMVRgBA
21</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:30:25.000Z" objectID="{A91E593C-996E-42B0-9B62-886C2AC55CB7}{112}{B0}"><one:Position x="180.0" y="254.2393798828125" z="10"/><one:Size width="234.0283508300781" height="12.1889705657959"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="180.0" y="266.3999938964844"/><one:AnchorPoint x="414.0" y="254.2393798828125"/></one:ShapeInfo><one:Data>AD0dA5YFRgEQKgAaH4tCcEWEdYC+0EXZBAMISBBF9ANG9AMFAgtkZAMVRgBAChACDzGc5DQPSWyM
22JAMVRgBA
23</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:30:31.000Z" objectID="{A91E593C-996E-42B0-9B62-886C2AC55CB7}{120}{B0}"><one:Position x="180.0" y="266.3999938964844" z="11"/><one:Size width="234.0283508300781" height="72.02834320068359"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="180.0" y="266.3999938964844"/><one:AnchorPoint x="414.0" y="338.3999938964844"/></one:ShapeInfo><one:Data>AD4dBJYF5gEBECoAGh+LQnBFhHWAvtBF2QQDCEgQRfQDRvQDBQILZGQDFUYAQAoQAg8xnOQ0D0ls
24uogDFUYAQA==</one:Data></one:InkDrawing></one:Page>
corpus/ink-tools/edited/cold/read/page-001.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": "Beside the drawing - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/ink-tools/edited/cold/read/page-001.png created
Binary files /dev/null and b/corpus/ink-tools/edited/cold/read/page-001.png differ
corpus/ink-tools/edited/cold/read/page-001.xml created+7
......@@ -0,0 +1,7 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{4D0D633D-2B24-0838-0CE3-33E1DB10DA2C}{1}{B0}" name="Beside the drawing" dateTime="2026-09-13T00:25:49.000Z" lastModifiedTime="2026-09-30T01:08:42.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><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-13T00:25:49.000Z" objectID="{A77A1597-6DDA-0C9B-05DD-2CD63B5CFE24}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="152.4244079589843" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-13T00:25:49.000Z" lastModifiedTime="2026-09-13T00:25:49.000Z" objectID="{A77A1597-6DDA-0C9B-05DD-2CD63B5CFE24}{24}{B0}" alignment="left"><one:T><![CDATA[Beside the drawing]]></one:T></one:OE></one:OEChildren></one:Outline><one:InkDrawing lastModifiedTime="2026-09-13T00:25:49.000Z" objectID="{51506EB3-217E-0F51-2E4F-2B1ADBD0DD24}{0}{B0}"><one:Position x="299.9905395507812" y="119.9905471801758" z="1"/><one:Size width="60.03781127929687" height="60.03779220581055"/><one:Data>AM8CHQSiAaIBAWAqABofi0JwRYR1gL7QRdkE+oeVMvLsF0G4wBa/8yv0et67E4vlXrNInJuhJVq3
3URK/7HHMzCKxQYkOuxMs7nqhWM9UiuaXxU+PBvi60uGbIsRmbacNslhJmh1OQDQGbBACEQZIEEUj
4RiMJSBBE/wFFI0YjBQgLZU9mZ2hkaRkUMggAgB4CIeLiQTMIAPAQAovF4kFkAxVGAEAKXAWH8gln
5jCj7wnvCe8IAh/B8h4Pke8I7wnvCQAUURgBAAAAJBAUURgAAAAASABEg7Ks5v90DzE+YKNcBjx6b
6FwoAESCA5B1tFkPdAQMVRgBACj9AIm9GCC70WAqACQEKTAIPUq7GcA5CJYzgBRRGAIAAAAkEBRRG
7AAAAABIAESBSpMe3m/jlRpcE5ouTMwiuCgARIIDkHW0WQ90BAxVGAEAKP0Aib0YILvRYCoA=</one:Data></one:InkDrawing></one:Page>
corpus/ink-tools/edited/cold/read/page-002.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": "Edits - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/ink-tools/edited/cold/read/page-002.png created
Binary files /dev/null and b/corpus/ink-tools/edited/cold/read/page-002.png differ
corpus/ink-tools/edited/cold/read/page-002.xml created+15
......@@ -0,0 +1,15 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{5282ABA7-9026-4149-9998-6AF690A91A8A}{1}{B0}" name="Edits" dateTime="2026-09-30T00:24:17.000Z" lastModifiedTime="2026-09-30T00:25:14.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.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:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T00:24:18.000Z" lastModifiedTime="2026-09-30T00:24:18.000Z" objectID="{616ED56F-25A4-4C1F-B04A-01347A13D7CC}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Edits]]></one:T></one:OE></one:Title><one:InkDrawing lastModifiedTime="2026-09-30T00:24:32.000Z" objectID="{616ED56F-25A4-4C1F-B04A-01347A13D7CC}{56}{B0}"><one:Position x="252.0" y="50.39999771118164" z="2"/><one:Size width="72.02835845947266" height="54.02834701538086"/><one:ShapeInfo isLine="false"><one:AnchorPoint x="251.9998931884766" y="50.4000587463379"/><one:AnchorPoint x="287.9998474121094" y="50.4000587463379"/><one:AnchorPoint x="323.9997863769531" y="50.4000587463379"/><one:AnchorPoint x="323.9997863769531" y="77.40003967285156"/><one:AnchorPoint x="323.9997863769531" y="104.400016784668"/><one:AnchorPoint x="287.9998474121094" y="104.400016784668"/><one:AnchorPoint x="251.9998931884766" y="104.400016784668"/><one:AnchorPoint x="251.9998931884766" y="77.40003967285156"/></one:ShapeInfo><one:Data>AEwdBMQBlAEBECoAGh+LQnBFhHWAvtBF2QQDBkgQRTJGMgUCC2RkAxVGAEAKIAWH8Z+XiAb8BK/g
3JX8BKwCH7GLsY+1g7WHtYQMVRgBA
4</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:24:22.000Z" objectID="{616ED56F-25A4-4C1F-B04A-01347A13D7CC}{32}{B0}"><one:Position x="39.00472640991211" y="59.24409484863281" z="0"/><one:Size width="112.5354309082031" height="0.0283455848693848"/><one:Data>AKgCHQOuAgIBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
5Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFI0Yj
6BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKkAFBh+k+5y+YwGazGApnAZjA
7UzE1QCAzOZTMmcygMxmU1gEBgEBgEBgE1mSZzSazKZzSaplM5pNUymaaTWZTNNJqgv484/jzkAAA
8AAAAAAAABRRGAEAAAAkEBRRGAAAAABIAESBoVDskhZV+SLo2P5B9KRgDCgARIAD8ywlyUN0BAxVG
9AEAKABEgV/iIP6ru0D8=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:24:35.000Z" objectID="{616ED56F-25A4-4C1F-B04A-01347A13D7CC}{64}{B0}"><one:Position x="252.0" y="140.4000091552734" z="3"/><one:Size width="72.02835845947266" height="36.02833938598632"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="252.0" y="140.4000091552734"/><one:AnchorPoint x="324.0" y="176.4000091552734"/></one:ShapeInfo><one:Data>ADsdA8QBZAEQKgAaH4tCcEWEdYC+0EXZBAMGSBBFMkYyBQILZGQDFUYAQAoQAg9FdLKYDk1lhPAD
10FUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:24:26.000Z" objectID="{616ED56F-25A4-4C1F-B04A-01347A13D7CC}{42}{B0}"><one:Position x="84.00472259521484" y="149.2488250732422" z="1"/><one:Size width="112.5354309082031" height="0.0283584594726562"/><one:Data>AKgCHQOuAgIBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
11Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFI0Yj
12BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKkAFBh+k+5y+YwGazGApnAZjA
13UzE1QCAzOZTMmcygMxmU1gEBgEBgEBgE1mSZzSazKZzSaplM5pNUymaaTWZTNNJqgv6Pk/o+UAAA
14AAAAAAAABRRGAMAAAAkEBRRGAAAAABIAESCTBvW9+tcASYS183z19nlfCgARIKCijAxyUN0BAxVG
15AEAKABEgV/iIP9HObUA=</one:Data></one:InkDrawing></one:Page>
corpus/ink-tools/edited/cold/read/page-003.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": "Gallery - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/ink-tools/edited/cold/read/page-003.png created
Binary files /dev/null and b/corpus/ink-tools/edited/cold/read/page-003.png differ
corpus/ink-tools/edited/cold/read/page-003.xml created+75
......@@ -0,0 +1,75 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{AEC739A2-6F42-4515-84C1-D418854C78FF}{1}{B0}" name="Gallery" dateTime="2026-09-30T00:22:50.000Z" lastModifiedTime="2026-09-30T00:23:40.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.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:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T00:22:51.000Z" lastModifiedTime="2026-09-30T00:22:51.000Z" objectID="{58BD258F-39BE-4208-BF86-37CF491CE3C1}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Gallery]]></one:T></one:OE></one:Title><one:InkDrawing lastModifiedTime="2026-09-30T00:22:55.000Z" objectID="{58BD258F-39BE-4208-BF86-37CF491CE3C1}{32}{B0}"><one:Position x="39.00472640991211" y="81.75118255615234" z="0"/><one:Size width="150.0094451904297" height="0.0283498764038086"/><one:Data>AJoCHQOSAwIBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
3Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFI0Yj
4BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKggEyh+k+5R202e1zOazOzwGa
5zGbwCazMmsAgKZpnMk1QCAwCAzOaTWZTOaTWZTNNJrMpmmUzgv5Vs/lW0AAAAAAAAAUURgBAAAAJ
6BAUURgAAAAASABEg5wN6/ciK60u6WsVDR2cJgAoAESAwfQjWcVDdAQMVRgBACgARIFf4iD+PehBA
7
8</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:22:58.000Z" objectID="{58BD258F-39BE-4208-BF86-37CF491CE3C1}{38}{B0}"><one:Position x="39.00472640991211" y="95.2440948486328" z="1"/><one:Size width="150.0094451904297" height="0.0283498764038086"/><one:Data>AJ8CHQOSAwIBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
9Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBRE7bmQ
10AUUjRiMFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5
11rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/mST+ZJQAAAAAAAABRRG
12AIAAAAkEBRRGAAAAABIAESBGQZxts4QQTbV43z4KBKVpCgARIHD5K9hxUN0BAxVGAEAKABEgV/iI
13P1V3KEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:02.000Z" objectID="{58BD258F-39BE-4208-BF86-37CF491CE3C1}{44}{B0}"><one:Position x="39.00472640991211" y="108.7370071411133" z="2"/><one:Size width="150.0094451904297" height="0.0283412933349609"/><one:Data>AJ8CHQOSAwIBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
14Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREscrt
15BUUjRiMFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5
16rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/nNz+c3QAAAAAAAABRRG
17AMAAAAkEBRRGAAAAABIAESAY7KQuf+viTJV1jzfsq3ctCgARIPA2NdpxUN0BAxVGAEAKABEgV/iI
18Pxt0QEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:05.000Z" objectID="{58BD258F-39BE-4208-BF86-37CF491CE3C1}{50}{B0}"><one:Position x="39.00472640991211" y="122.2582702636719" z="3"/><one:Size width="150.0094451904297" height="0.0283412933349609"/><one:Data>AJ8CHQOSAwIBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
19Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREl/rZ
20AUUjRiMFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5
21rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/oJb+glwAAAAAAAABRRG
22AQAAAAkEBRRGAAAAABIAESAm+zoEY2rUTJ5/d6gaBVuMCgARIICbPtxxUN0BAxVGAEAKABEgV/iI
23P8h9WEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:09.000Z" objectID="{58BD258F-39BE-4208-BF86-37CF491CE3C1}{56}{B0}"><one:Position x="39.00472640991211" y="135.7511749267578" z="4"/><one:Size width="150.0094451904297" height="0.0283412933349609"/><one:Data>AJ8CHQOSAwIBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
24Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREgIGC
25BEUjRiMFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5
26rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/pE7+kTwAAAAAAAABRRG
27AUAAAAkEBRRGAAAAABIAESCnZ/4+38wKT4pCwgl9y8fMCgARIABoRd5xUN0BAxVGAEAKABEgV/iI
28P496cEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:12.000Z" objectID="{58BD258F-39BE-4208-BF86-37CF491CE3C1}{62}{B0}"><one:Position x="39.00472640991211" y="149.2440948486328" z="5"/><one:Size width="150.0094451904297" height="0.0283412933349609"/><one:Data>AKgCHQOWAx4BYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
29Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMUSBRE//8D
30RUZHAUaQA1cJAAAAUH8FCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH
316T7lHbTZ7XM5rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/qAb+oBw
32AAAAAAAABRRGAYAAAAkEBRRGAIAAABIAESDuqWuD5PkcQbZoQPucPr1dCgARIJBbTOBxUN0BAxVG
33AEAKP0Ain6X8O+fYD+A=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:15.000Z" objectID="{58BD258F-39BE-4208-BF86-37CF491CE3C1}{68}{B0}"><one:Position x="39.00472640991211" y="162.7370147705078" z="6"/><one:Size width="150.0094451904297" height="0.0283412933349609"/><one:Data>AKkCHQOWAx4BYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
34Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMVSBREgP7/
35B0VGRwFGkANXCQAAAFB/BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKggEy
36h+k+5R202e1zOazOzwGazGbwCazMmsAgKZpnMk1QCAwCAzOaTWZTOaTWZTNNJrMpmmUzgv6u+/q7
378AAAAAAAAAUURgHAAAAJBAUURgCAAAASABEgULb3v276vU+yZJBxbMv26goAESAgT1PicVDdAQMV
38RgBACgARIOknhz+inYtA
39</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:19.000Z" objectID="{58BD258F-39BE-4208-BF86-37CF491CE3C1}{74}{B0}"><one:Position x="39.00472640991211" y="176.2582550048828" z="7"/><one:Size width="150.0094451904297" height="0.0283584594726562"/><one:Data>AJoCHQOUAwQBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
40Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFMkYy
41BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKggEyh+k+5R202e1zOazOzwGa
42zGbwCazMmsAgKZpnMk1QCAwCAzOaTWZTOaTWZTNNJrMpmmUzgv694/r3kAAAAAAAAAUURgIAAAAJ
43BAUURgAAAAASABEg598ld/kfskqe+7EWJ5S2+QoAESCgbmHkcVDdAQMVRgBACj9AIqe1/EC0qBOA
44
45</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:22.000Z" objectID="{58BD258F-39BE-4208-BF86-37CF491CE3C1}{80}{B0}"><one:Position x="39.00472640991211" y="189.7511749267578" z="8"/><one:Size width="150.0094451904297" height="0.0283584594726562"/><one:Data>AJ8CHQOUAwQBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
46Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBRE7bmQ
47AUUyRjIFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5
48rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/szD+zMQAAAAAAAABRRG
49AkAAAAkEBRRGAAAAABIAESA7WYhg62teTJWERTyI4+4BCgARIAAidOZxUN0BAxVGAEAKP0Aip7X8
50QJrYFQA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:26.000Z" objectID="{58BD258F-39BE-4208-BF86-37CF491CE3C1}{86}{B0}"><one:Position x="39.00472640991211" y="203.2440948486328" z="9"/><one:Size width="150.0094451904297" height="0.0283412933349609"/><one:Data>AJ8CHQOUAwQBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
51Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREscrt
52BUUyRjIFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5
53rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/tuj+26QAAAAAAAABRRG
54AoAAAAkEBRRGAAAAABIAESCzAtv9BGjgRKJG+TDcSpijCgARIICyhOhxUN0BAxVGAEAKP0Aip7X8
55QIEIFoA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:29.000Z" objectID="{58BD258F-39BE-4208-BF86-37CF491CE3C1}{92}{B0}"><one:Position x="39.00472640991211" y="216.7370147705078" z="10"/><one:Size width="150.0094451904297" height="0.0283412933349609"/><one:Data>AJ8CHQOUAwQBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
56Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREl/rZ
57AUUyRjIFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5
58rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/uqD+6oQAAAAAAAABRRG
59AsAAAAkEBRRGAAAAABIAESAU7/fpyXd0SJkgxjH5MyTjCgARIOBll+pxUN0BAxVGAEAKP0Aip7X8
60QGc4GAA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:33.000Z" objectID="{58BD258F-39BE-4208-BF86-37CF491CE3C1}{98}{B0}"><one:Position x="39.00472640991211" y="230.2582550048828" z="11"/><one:Size width="150.0094451904297" height="0.0283584594726562"/><one:Data>AJ8CHQOUAwQBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
61Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREgIGC
62BEUyRjIFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5
63rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/vlr++WwAAAAAAAABRRG
64AwAAAAkEBRRGAAAAABIAESAMVgQMFYRlS7E8QjeztIbhCgARIFCxrOxxUN0BAxVGAEAKP0Aip7X8
65QLSoGYA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:36.000Z" objectID="{58BD258F-39BE-4208-BF86-37CF491CE3C1}{104}{B0}"><one:Position x="39.00472640991211" y="243.7511749267578" z="12"/><one:Size width="150.0094451904297" height="0.0283584594726562"/><one:Data>AKoCHQOWAx4BYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
66Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMUSBREgP4D
67RUZHAUaQA1cJAAAAUH8FCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqEATKH
686T7lHbTZ7XM5rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/wAEJf4A
69CEwAAAAAAAAFFEYDQAAACQQFFEYAgAAAEgARIJkREIJaMrZPpp8CdhQXBbYKABEgwBq97nFQ3QED
70FUYAQAoAESDpJ4c/26DTQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:40.000Z" objectID="{58BD258F-39BE-4208-BF86-37CF491CE3C1}{110}{B0}"><one:Position x="39.00472640991211" y="257.2441101074219" z="13"/><one:Size width="150.0094451904297" height="0.0283412933349609"/><one:Data>AKsCHQOWAx4BYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
71Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMVSBRE/4H8
72B0VGRwFGkANXCQAAAFB/BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKhAEy
73h+k+5R202e1zOazOzwGazGbwCazMmsAgKZpnMk1QCAwCAzOaTWZTOaTWZTNNJrMpmmUzgv8AC5X+
74ABcsAAAAAAAABRRGA4AAAAkEBRRGAIAAABIAESCoZ3Z0oKXFSKIRRxukb8jmCgARIEA6y/BxUN0B
75AxVGAEAKABEg6SeHPz6f30A=</one:Data></one:InkDrawing></one:Page>
corpus/ink-tools/edited/cold/read/page-004.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": "Pens - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/ink-tools/edited/cold/read/page-004.png created
Binary files /dev/null and b/corpus/ink-tools/edited/cold/read/page-004.png differ
corpus/ink-tools/edited/cold/read/page-004.xml created+46
......@@ -0,0 +1,46 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{DB8D8C2E-3789-4958-BF3F-202608CD8306}{1}{B0}" name="Pens" dateTime="2026-09-30T00:18:53.000Z" lastModifiedTime="2026-09-30T00:19:49.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.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:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T00:18:54.000Z" lastModifiedTime="2026-09-30T00:18:54.000Z" objectID="{350AB5BE-E3CC-4E57-832C-F13E71EC14AC}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Pens]]></one:T></one:OE></one:Title><one:InkDrawing lastModifiedTime="2026-09-30T00:19:39.000Z" objectID="{350AB5BE-E3CC-4E57-832C-F13E71EC14AC}{63}{B0}"><one:Position x="252.0" y="86.4000015258789" z="4"/><one:Size width="72.02835845947266" height="36.02833938598632"/><one:ShapeInfo isLine="false"><one:AnchorPoint x="251.999755859375" y="86.39993286132812"/><one:AnchorPoint x="287.999755859375" y="86.39993286132812"/><one:AnchorPoint x="323.9997863769531" y="86.39993286132812"/><one:AnchorPoint x="323.9997863769531" y="104.3999328613281"/><one:AnchorPoint x="323.9997863769531" y="122.3999404907227"/><one:AnchorPoint x="287.999755859375" y="122.3999404907227"/><one:AnchorPoint x="251.999755859375" y="122.3999404907227"/><one:AnchorPoint x="251.999755859375" y="104.3999328613281"/></one:ShapeInfo><one:Data>AE0dA8QBZAEQKgAaH4tCcEWEdYC+0EXZBAMGSBBFMkYyBQILZGQDFUYAQAoiBYfxn5eIBvwEr+Al
3fwErAIfwMneBk/oauhr6GsADFUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:19:42.000Z" objectID="{350AB5BE-E3CC-4E57-832C-F13E71EC14AC}{71}{B0}"><one:Position x="342.0" y="86.4000015258789" z="5"/><one:Size width="72.02835845947266" height="36.02833938598632"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="342.0" y="86.4000015258789"/><one:AnchorPoint x="414.0" y="122.400001525879"/></one:ShapeInfo><one:Data>ADsdA8QBZAEQKgAaH4tCcEWEdYC+0EXZBAMGSBBFMkYyBQILZGQDFUYAQAoQAg9eQuQ0Di+hDeAD
4FUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:18:59.000Z" objectID="{350AB5BE-E3CC-4E57-832C-F13E71EC14AC}{32}{B0}"><one:Position x="39.00472640991211" y="87.76062774658203" z="0"/><one:Size width="112.5354309082031" height="0.0283498764038086"/><one:Data>ALUCHQOuAgIBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
5Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFI0Yj
6BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKnQFQh+k+580mZAZjAUzEBmMy
7mqATWZTOZTMJrNJnMprNJnAUAgMAgMAgMzmkBgE1mUzmkBmaZTOaTWZTNNJqmUzmk1TKZzSaplM5
8pNSC/lxT+XFQAAAAAAAAAAAAAAUURgBAAAAJBAUURgAAAAASABEg43IZXxePZEqSIvXVtQWmUwo/
9QCMjAkbdBxA7oAMVRgBACgARIFf4iD+GKRtA
10</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T01:05:20.000Z" objectID="{350AB5BE-E3CC-4E57-832C-F13E71EC14AC}{37}{B0}"><one:Position x="194.9952697753906" y="90.73700714111328" z="1"/><one:Size width="0.0283412933349609" height="60.03779220581055"/><one:Data>AK4CHQMCogEBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
11Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFI0Yj
12BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKlgFKgv6q2/qrcAAAAAAAAAAA
13AIfwFFeAeuZJrNJqmc0gMzAgMxmU1mkzgMAgMAgKAQGAQGZzSAzOaQGZzSazKZplM5pNUymZNJqm
14UzJpNUymaaTUmUzABRRGAIAAAAkEBRRGAAAAABIAESCdJ4KnPMDWT4AyoS4EsfmDCj9AIkfCUi0H
15EDugAxVGAEAKABEgmuaMQBt0AEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:19:45.000Z" objectID="{350AB5BE-E3CC-4E57-832C-F13E71EC14AC}{78}{B0}"><one:Position x="252.0" y="158.4000091552734" z="6"/><one:Size width="72.02835845947266" height="36.02833938598632"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="252.0" y="158.4000091552734"/><one:AnchorPoint x="324.0" y="194.4000091552734"/></one:ShapeInfo><one:Data>AFUdA8QBZAEQKgAaH4tCcEWEdYC+0EXZBAMGSBBFMkYyBQILZGQDFUYAQAoQAg9FdLKYDldRrKAD
16FUYAQAoYA4fyMAeRDfjRQIfxDYeH0/g4QAMVRgBA
17</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:19:49.000Z" objectID="{350AB5BE-E3CC-4E57-832C-F13E71EC14AC}{86}{B0}"><one:Position x="342.0" y="158.4283447265625" z="7"/><one:Size width="72.02835845947266" height="35.97165679931641"/><one:ShapeInfo isLine="false"><one:AnchorPoint x="378.0002746582031" y="158.4002532958984"/><one:AnchorPoint x="414.0002746582031" y="176.4002532958984"/><one:AnchorPoint x="378.0002746582031" y="194.4002532958984"/><one:AnchorPoint x="342.0002746582031" y="176.4002532958984"/></one:ShapeInfo><one:Data>AKABHQPEAWQBECoAGh+LQnBFhHWAvtBF2QQDBkgQRTJGMgUCC2RkAxVGAEAKdTOH8wTHmCtk8nkc
18fkcbjcTh8Lg8DgUGhUOiUajUij0ik0mk0mk0ij0ijUaiUOhUGgUDg8Lh8Tjcbkcfkcnk4IX4fTPh
196tjjnppvtvweBweDweDwODvtvppnjjRRTUUXWXYLAYLBYLBYDBXWXUUTRRADFUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:19:26.000Z" objectID="{350AB5BE-E3CC-4E57-832C-F13E71EC14AC}{49}{B0}"><one:Position x="39.00472640991211" y="171.7511749267578" z="2"/><one:Size width="150.0094451904297" height="0.0283584594726562"/><one:Data>ANECHQOWAx4BYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
20Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMUSBRE//8D
21RUZHAUaQA1cJAAAAUH8FCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqrAVaH
226T7nL5jAU1mMwmM3mkzmcwmMBmcymaAzGZTVAIDM5lM00mqazRNZpM5lNUAgM0mc1mUAgMAgMzmk
23Bmcymc0msymc0mqZTNNJrMpmmk1mUzTSazKZplMwgv646/rjsAAAAAAAAAAAAAAFFEYBAAAACQQF
24FEYAgAAAEgARIIxv3sLIAtJLsNVUUmVe0nIKABEgwIKVWXFQ3QEDFUYAQAoAESDpJ4c/26CTQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:19:30.000Z" objectID="{350AB5BE-E3CC-4E57-832C-F13E71EC14AC}{54}{B0}"><one:Position x="188.9858245849609" y="239.2440948486328" z="3"/><one:Size width="30.0472469329834" height="22.53543090820312"/><one:Data>AMkCHQJSPgFgKgAaH4tCcEWEdYC+0EXZBPqHlTLy7BdBuMAWv/Mr9HreuxOL5V6zSJyboSVat1ES
25v+xxzMwisUGJDrsTLO56oVjPVIrml8VPjwb4utLhmyLEZm2nDbJYSZodTkA0BmwQAwZIFEUjRiMF
26CAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqyATeH8RSniIrmcyJrNJrAIDNJ
27nAYBAYBAUzmkBmc0gMzmk1mUzmk1mUzTSazKZppNZlM0ymc0mqZTNNJqh/GDd4wOYDNJrAIDNJnA
28ZnNIDAIDAJrMkzmk1mUzmk1mUzTSaplM00mpMpmmk1JlM00mpMpmBRRGAUAAAAkEBRRGAAAAABIA
29ESAy8nFzf6jGRYlYDFxmSkMdCj9AI42C380HEDugAxVGAEAKABEgHo+nQKo71EA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T01:05:21.000Z" objectID="{6729AFE0-D1B1-0CF4-3BA6-E9CDC9337A20}{4}{B0}"><one:Position x="60.00944900512695" y="338.031494140625" z="9"/><one:Size width="160.0157470703125" height="23.98109436035156"/><one:Data>AKACHQOuA0IBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
30Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREn7Tq
31A0UjRiMFCAtlT2ZnaGRpGRQyCACAHgIh4uJBMwgA8BACi8XiQWQDFUYAQAqDASmC/j27+OVQJQEo
32CUBKAIfycqeTwmIRaPSKUSaUSKRRqIQyAQeGxmNyWSyuSymPx2KwuBwSGRaNSKTSaUSaQR6JAAUU
33RgHAAAAJBAUURgAAAAASABEgqcqAxLBes0uG2h8ws7R+rwoAESCAHvbDd1DdAQMVRgBACgARIGia
340z8IBBZB
35</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T01:05:21.000Z" objectID="{6729AFE0-D1B1-0CF4-3BA6-E9CDC9337A20}{7}{B0}"><one:Position x="252.0" y="340.41259765625" z="10"/><one:Size width="72.02835845947266" height="54.02832412719727"/><one:ShapeInfo isLine="false"><one:AnchorPoint x="252.0" y="340.3999633789062"/><one:AnchorPoint x="288.0" y="340.3999633789062"/><one:AnchorPoint x="324.0" y="340.3999633789062"/><one:AnchorPoint x="324.0" y="367.3999633789062"/><one:AnchorPoint x="324.0" y="394.3999633789062"/><one:AnchorPoint x="288.0" y="394.3999633789062"/><one:AnchorPoint x="252.0" y="394.3999633789062"/><one:AnchorPoint x="252.0" y="367.3999633789062"/></one:ShapeInfo><one:Data>AFMdBMQBlAEBECoAGh+LQnBFhHWAvtBF2QQDC0gQRJ+06gNFMkYyBQILZGQDFUYAQAoiBYfxn5eI
36BvwEr+AlfwErAIfyYoeTFHtYO1h7WEADFUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T01:05:21.000Z" objectID="{6729AFE0-D1B1-0CF4-3BA6-E9CDC9337A20}{9}{B0}"><one:Position x="342.0" y="340.4692993164062" z="11"/><one:Size width="90.02835845947266" height="53.91495895385742"/><one:ShapeInfo isLine="false"><one:AnchorPoint x="387.0" y="340.3999633789062"/><one:AnchorPoint x="432.0" y="367.3999633789062"/><one:AnchorPoint x="387.0" y="394.3999633789062"/><one:AnchorPoint x="342.0" y="367.3999633789062"/></one:ShapeInfo><one:Data>AK0BHQT0AZQBARAqABofi0JwRYR1gL7QRdkEAwtIEESftOoDRTJGMgUCC2RkAxVGAEAKfDOH8yx3
37mWpl8vl8rk8ljsbicPhMBgEIh0SjUckkmlUul0umUul0ulUmkkcjUSh0IgEBhMPicbjslk8rl8vl
384IbyoBeUxWDhYuNj5eTm5mbnZ2dnZuZm5OXj42LhYOBhISKjI6WkpqYmpycnJyamJqSlo6MioSEA
39AxVGAEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T01:05:20.000Z" objectID="{6729AFE0-D1B1-0CF4-3BA6-E9CDC9337A20}{2}{B0}"><one:Position x="60.00944900512695" y="390.0188903808594" z="8"/><one:Size width="160.0157470703125" height="0.0283241271972656"/><one:Data>APEBHQOwAx4BYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
40Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMUSBRE//8D
41RUZHAUaQA1cJAAAAUH8FCAtlT2ZnaGRpGRQyCACAHgIh4uJBMwgA8BACi8XiQWQDFUYAQApMAg4h
42FeUQD2t+1vwFFEYBgAAACQQFFEYAgAAAEgARIKX0Fhytz+FHincjDAF4OcwKABEgAIhdw3dQ3QED
43FUYAQAoAESDh1tE/ctIqQQ==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T01:05:21.000Z" objectID="{6729AFE0-D1B1-0CF4-3BA6-E9CDC9337A20}{12}{B0}"><one:Position x="252.0" y="430.41259765625" z="12"/><one:Size width="108.0283584594727" height="37.55902862548828"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="252.0" y="430.3999633789062"/><one:AnchorPoint x="360.0" y="466.3999633789062"/></one:ShapeInfo><one:Data>AFodA6QCaAEQKgAaH4tCcEWEdYC+0EXZBAMLSBBEn7TqA0UyRjIFAgtkZAMVRgBAChACD0V0xnAQ
44O1BARgMVRgBAChgDh/J815Nf+OPAh/Nnd5q0+AvAAxVGAEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T01:05:21.000Z" objectID="{6729AFE0-D1B1-0CF4-3BA6-E9CDC9337A20}{14}{B0}"><one:Position x="378.0" y="430.41259765625" z="13"/><one:Size width="72.02835845947266" height="0.0283241271972656"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="378.0" y="430.3999633789062"/><one:AnchorPoint x="450.0" y="430.3999633789062"/></one:ShapeInfo><one:Data>AEAdA8QBBAEQKgAaH4tCcEWEdYC+0EXZBAMLSBBEn7TqA0UyRjIFAgtkZAMVRgBAChACD2gu+AwP
45dqDtQAMVRgBA
46</one:Data></one:InkDrawing></one:Page>
corpus/ink-tools/edited/cold/read/payloads.json created+3
......@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/ink-tools/edited/cold/run.json created+18
......@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/snowbound-ink/gate/edited-candidate",
3 "expected_pages": 5,
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/ink-tools/edited/cold/scripts/cold.ps1 created+27
......@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/ink-tools/edited/cold/scripts/read.ps1 created+144
......@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/ink-tools/edited/cold/source.json created+14
......@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1596,
5 "sha256": "92301b5ba63ac2b7ae8721a2846a3827d75cc944ed2277569f690fbd4d0c687b",
6 "mtime_ns": 1790730321004978035
7 },
8 {
9 "path": "ink.one",
10 "bytes": 86952,
11 "sha256": "3c6809d655b86b6b2afdaa8fe49d80da211f562dac987eb52cd7545601ec1d1a",
12 "mtime_ns": 1790730321004761537
13 }
14]
corpus/ink-tools/edited/cold/teardown.json created+1
......@@ -0,0 +1 @@
1{"absent": true}
corpus/ink-tools/native-ui/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-A8B36F2B -ExpectedPages 5 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 5 pages.\r\n", "stderr": "", "error": null}
3{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\read -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
corpus/ink-tools/native-ui/machine.json created+1
......@@ -0,0 +1 @@
1{"name": "m6-a8b36f2b", "hostname": "ONE-M6-A8B36F2B"}
corpus/ink-tools/native-ui/read/environment.json created+7
......@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-A8B36F2B",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/ink-tools/native-ui/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="{9CAD55E2-13A6-462A-847F-FFEDB6D54293}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T01:13:37.000Z" color="#EE9597"><one:Section name="ink" ID="{9C8EFC66-2CF4-0F8E-25EA-9196AA36B971}{1}{B0}" path="C:\one-tests\runs\capture\notebook\ink.one" lastModifiedTime="2026-09-30T01:13:37.000Z" color="#8AA8E4"><one:Page ID="{76F98ACC-6A0A-0B2D-2CD4-8EA14A7A9D79}{1}{B0}" name="Beside the drawing" dateTime="2026-09-13T00:25:49.000Z" lastModifiedTime="2026-09-30T01:13:37.000Z" pageLevel="1"/><one:Page ID="{E07965DF-76A7-4A4D-9F08-9D6699A7C453}{1}{B0}" name="Pens" dateTime="2026-09-30T00:18:53.000Z" lastModifiedTime="2026-09-30T00:19:49.000Z" pageLevel="1"/><one:Page ID="{9533D053-2E6C-4600-A4F6-695814263FAA}{1}{B0}" name="Gallery" dateTime="2026-09-30T00:22:50.000Z" lastModifiedTime="2026-09-30T00:23:40.000Z" pageLevel="1"/><one:Page ID="{69764256-D108-425C-B9AF-D7B601C35DDF}{1}{B0}" name="Edits" dateTime="2026-09-30T00:24:17.000Z" lastModifiedTime="2026-09-30T00:25:14.000Z" pageLevel="1"/><one:Page ID="{2DAAE5FB-8675-4762-829E-32DF94FE6CD3}{1}{B0}" name="Shapes2" dateTime="2026-09-30T00:29:10.000Z" lastModifiedTime="2026-09-30T00:32:48.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/ink-tools/native-ui/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": "Shapes2 - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/ink-tools/native-ui/read/page-000.png created
Binary files /dev/null and b/corpus/ink-tools/native-ui/read/page-000.png differ
corpus/ink-tools/native-ui/read/page-000.xml created+24
......@@ -0,0 +1,24 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{2DAAE5FB-8675-4762-829E-32DF94FE6CD3}{1}{B0}" name="Shapes2" dateTime="2026-09-30T00:29:10.000Z" lastModifiedTime="2026-09-30T00:32:48.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.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:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T00:29:11.000Z" lastModifiedTime="2026-09-30T00:29:11.000Z" objectID="{92EAB0CD-D840-41A5-BB55-352CBBAF1BE2}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Shapes2]]></one:T></one:OE></one:Title><one:InkDrawing lastModifiedTime="2026-09-30T00:29:28.000Z" objectID="{92EAB0CD-D840-41A5-BB55-352CBBAF1BE2}{40}{B0}"><one:Position x="270.0" y="50.39999771118164" z="1"/><one:Size width="21.60001373291016" height="72.02834320068359"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="270.0" y="50.39999771118164"/><one:AnchorPoint x="288.0" y="122.400001525879"/></one:ShapeInfo><one:Data>AFodA0TMAQEQKgAaH4tCcEWEdYC+0EXZBAMLSBBE/wFFlgFGlgEFAgtkZAMVRgBAChACD0pqnsAO
3G8kN4AMVRgBAChgDh/H214/1+B3Ah/BeZ4HX+ZfAAxVGAEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:29:25.000Z" objectID="{92EAB0CD-D840-41A5-BB55-352CBBAF1BE2}{31}{B0}"><one:Position x="36.0" y="74.74960327148437" z="0"/><one:Size width="190.5165405273437" height="14.740159034729"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="36.0" y="86.4000015258789"/><one:AnchorPoint x="226.5000152587891" y="81.75000762939453"/></one:ShapeInfo><one:Data>AFkdA4gEMgEQKgAaH4tCcEWEdYC+0EXZBAMLSBBE/wFFlgFGlgEFAgtkZAMVRgBAChACDhPZ82AN
4X0LRAAMVRgBAChcDh/FGd4ks+adAh/AYx4BK5gADFUYAQA==</one:Data></one:InkDrawing><one:InkDrawing isUnclassified="true" lastModifiedTime="2026-09-30T00:32:48.000Z" objectID="{92EAB0CD-D840-41A5-BB55-352CBBAF1BE2}{123}{B0}"><one:Position x="54.0" y="160.4976348876953" z="12"/><one:Size width="60.03779220581055" height="7.540157318115234"/><one:Data>ALYCHQOiARYBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
5Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFI0Yj
6BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKngE4h+1g7FmATebTWAzGAoBA
7ZnNJvACZzKazSawCAwCAwCAwCazKATWZTOZTOaTVMpnNJqmUzmk1TKZppNSH8NXXhq7mk1mUzmUz
8mk1TKZppNUymZNJqmUzTSagAAAAFFEYAQAAACQQFFEYAAAAAEgARIH7bL5LspZlKvDhZGnSznPUK
9P0Ai5AG7fQcwO6ADFUYAQAo/QCMlgf3zx5gRwA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:29:31.000Z" objectID="{92EAB0CD-D840-41A5-BB55-352CBBAF1BE2}{48}{B0}"><one:Position x="81.0" y="162.9000244140625" z="2"/><one:Size width="72.02835845947266" height="54.02833938598632"/><one:ShapeInfo isLine="false"><one:AnchorPoint x="36.0" y="140.4005889892578"/><one:AnchorPoint x="71.99999237060547" y="140.4005889892578"/><one:AnchorPoint x="107.9999847412109" y="140.4005889892578"/><one:AnchorPoint x="107.9999847412109" y="167.4005889892578"/><one:AnchorPoint x="107.9999847412109" y="194.4005889892578"/><one:AnchorPoint x="71.99999237060547" y="194.4005889892578"/><one:AnchorPoint x="36.0" y="194.4005889892578"/><one:AnchorPoint x="36.0" y="167.4005889892578"/></one:ShapeInfo><one:Data>AEwdBMIBkgEBECoAGh+LQnBFhHWAvtBF2QQDBkgQRRlGGQUCC2RkAxVGAEAKIAWH6GroavASv4CV
10/ASsh/Cph4VMe1g7WHtYQAMVRgBA
11</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:29:49.000Z" objectID="{92EAB0CD-D840-41A5-BB55-352CBBAF1BE2}{56}{B0}"><one:Position x="180.0" y="209.2535400390625" z="3"/><one:Size width="234.0283508300781" height="3.174808502197266"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="180.0" y="212.4000091552734"/><one:AnchorPoint x="414.0" y="209.25"/></one:ShapeInfo><one:Data>ADsdA/IEDAEQKgAaH4tCcEWEdYC+0EXZBAMGSBBFI0YjBQILZGQDFUYAQAoQAg8xnOQ0DnUVzWAD
12FUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:29:54.000Z" objectID="{92EAB0CD-D840-41A5-BB55-352CBBAF1BE2}{64}{B0}"><one:Position x="180.0" y="212.4000091552734" z="4"/><one:Size width="234.0283508300781" height="4.365348815917969"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="180.0" y="212.4000091552734"/><one:AnchorPoint x="414.0" y="216.75"/></one:ShapeInfo><one:Data>ADsdA/QEEAEQKgAaH4tCcEWEdYC+0EXZBAMGSBBFMkYyBQILZGQDFUYAQAoQAg8xnOQ0DnUV3eAD
13FUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:29:59.000Z" objectID="{92EAB0CD-D840-41A5-BB55-352CBBAF1BE2}{72}{B0}"><one:Position x="180.0" y="224.2488098144531" z="5"/><one:Size width="234.0283508300781" height="6.179534912109375"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="180.0" y="230.4000091552734"/><one:AnchorPoint x="414.0" y="224.2500152587891"/></one:ShapeInfo><one:Data>ADsdA/YEFgEQKgAaH4tCcEWEdYC+0EXZBAMGSBBFRkZGBQILZGQDFUYAQAoQAg8xnOQ0Dn8B7nAD
14FUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:30:10.000Z" objectID="{92EAB0CD-D840-41A5-BB55-352CBBAF1BE2}{88}{B0}"><one:Position x="180.0" y="230.4000091552734" z="7"/><one:Size width="234.0283508300781" height="8.87243843078613"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="180.0" y="230.4000091552734"/><one:AnchorPoint x="414.0" y="239.25"/></one:ShapeInfo><one:Data>AD0dA/wEIgEQKgAaH4tCcEWEdYC+0EXZBAMISBBFlgFGlgEFAgtkZAMVRgBAChACDzGc5DQPP4CD
154AMVRgBA
16</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:30:05.000Z" objectID="{92EAB0CD-D840-41A5-BB55-352CBBAF1BE2}{80}{B0}"><one:Position x="180.0" y="230.4000091552734" z="6"/><one:Size width="234.0283508300781" height="1.38896369934082"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="180.0" y="230.4000091552734"/><one:AnchorPoint x="414.0" y="231.75"/></one:ShapeInfo><one:Data>ADsdA/gEDAEQKgAaH4tCcEWEdYC+0EXZBAMGSBBFZEZkBQILZGQDFUYAQAoQAg8xnOQ0Dn8B/wAD
17FUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:30:20.000Z" objectID="{92EAB0CD-D840-41A5-BB55-352CBBAF1BE2}{104}{B0}"><one:Position x="180.0" y="248.4000091552734" z="9"/><one:Size width="234.0283508300781" height="5.867712020874023"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="180.0" y="248.4000091552734"/><one:AnchorPoint x="414.0" y="254.25"/></one:ShapeInfo><one:Data>AD0dA4oFKgEQKgAaH4tCcEWEdYC+0EXZBAMISBBF3gJG3gIFAgtkZAMVRgBAChACDzGc5DQPRHaM
18JAMVRgBA
19</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:30:15.000Z" objectID="{92EAB0CD-D840-41A5-BB55-352CBBAF1BE2}{96}{B0}"><one:Position x="180.0" y="246.7559051513672" z="8"/><one:Size width="234.0283508300781" height="1.672445297241211"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="180.0" y="248.4000091552734"/><one:AnchorPoint x="414.0" y="246.7500152587891"/></one:ShapeInfo><one:Data>AD0dA4AFFAEQKgAaH4tCcEWEdYC+0EXZBAMISBBFyAFGyAEFAgtkZAMVRgBAChACDzGc5DQPRHaI
20BAMVRgBA
21</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:30:25.000Z" objectID="{92EAB0CD-D840-41A5-BB55-352CBBAF1BE2}{112}{B0}"><one:Position x="180.0" y="254.2393798828125" z="10"/><one:Size width="234.0283508300781" height="12.1889705657959"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="180.0" y="266.3999938964844"/><one:AnchorPoint x="414.0" y="254.2393798828125"/></one:ShapeInfo><one:Data>AD0dA5YFRgEQKgAaH4tCcEWEdYC+0EXZBAMISBBF9ANG9AMFAgtkZAMVRgBAChACDzGc5DQPSWyM
22JAMVRgBA
23</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:30:31.000Z" objectID="{92EAB0CD-D840-41A5-BB55-352CBBAF1BE2}{120}{B0}"><one:Position x="180.0" y="266.3999938964844" z="11"/><one:Size width="234.0283508300781" height="72.02834320068359"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="180.0" y="266.3999938964844"/><one:AnchorPoint x="414.0" y="338.3999938964844"/></one:ShapeInfo><one:Data>AD4dBJYF5gEBECoAGh+LQnBFhHWAvtBF2QQDCEgQRfQDRvQDBQILZGQDFUYAQAoQAg8xnOQ0D0ls
24uogDFUYAQA==</one:Data></one:InkDrawing></one:Page>
corpus/ink-tools/native-ui/read/page-001.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": "Edits - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/ink-tools/native-ui/read/page-001.png created
Binary files /dev/null and b/corpus/ink-tools/native-ui/read/page-001.png differ
corpus/ink-tools/native-ui/read/page-001.xml created+15
......@@ -0,0 +1,15 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{69764256-D108-425C-B9AF-D7B601C35DDF}{1}{B0}" name="Edits" dateTime="2026-09-30T00:24:17.000Z" lastModifiedTime="2026-09-30T00:25:14.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.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:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T00:24:18.000Z" lastModifiedTime="2026-09-30T00:24:18.000Z" objectID="{5A9A3C9E-648A-4F0A-907D-BC74EB799099}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Edits]]></one:T></one:OE></one:Title><one:InkDrawing lastModifiedTime="2026-09-30T00:24:32.000Z" objectID="{5A9A3C9E-648A-4F0A-907D-BC74EB799099}{56}{B0}"><one:Position x="252.0" y="50.39999771118164" z="2"/><one:Size width="72.02835845947266" height="54.02834701538086"/><one:ShapeInfo isLine="false"><one:AnchorPoint x="251.9998931884766" y="50.4000587463379"/><one:AnchorPoint x="287.9998474121094" y="50.4000587463379"/><one:AnchorPoint x="323.9997863769531" y="50.4000587463379"/><one:AnchorPoint x="323.9997863769531" y="77.40003967285156"/><one:AnchorPoint x="323.9997863769531" y="104.400016784668"/><one:AnchorPoint x="287.9998474121094" y="104.400016784668"/><one:AnchorPoint x="251.9998931884766" y="104.400016784668"/><one:AnchorPoint x="251.9998931884766" y="77.40003967285156"/></one:ShapeInfo><one:Data>AEwdBMQBlAEBECoAGh+LQnBFhHWAvtBF2QQDBkgQRTJGMgUCC2RkAxVGAEAKIAWH8Z+XiAb8BK/g
3JX8BKwCH7GLsY+1g7WHtYQMVRgBA
4</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:24:22.000Z" objectID="{5A9A3C9E-648A-4F0A-907D-BC74EB799099}{32}{B0}"><one:Position x="39.00472640991211" y="59.24409484863281" z="0"/><one:Size width="112.5354309082031" height="0.0283455848693848"/><one:Data>AKgCHQOuAgIBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
5Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFI0Yj
6BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKkAFBh+k+5y+YwGazGApnAZjA
7UzE1QCAzOZTMmcygMxmU1gEBgEBgEBgE1mSZzSazKZzSaplM5pNUymaaTWZTNNJqgv484/jzkAAA
8AAAAAAAABRRGAEAAAAkEBRRGAAAAABIAESBoVDskhZV+SLo2P5B9KRgDCgARIAD8ywlyUN0BAxVG
9AEAKABEgV/iIP6ru0D8=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:24:35.000Z" objectID="{5A9A3C9E-648A-4F0A-907D-BC74EB799099}{64}{B0}"><one:Position x="252.0" y="140.4000091552734" z="3"/><one:Size width="72.02835845947266" height="36.02833938598632"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="252.0" y="140.4000091552734"/><one:AnchorPoint x="324.0" y="176.4000091552734"/></one:ShapeInfo><one:Data>ADsdA8QBZAEQKgAaH4tCcEWEdYC+0EXZBAMGSBBFMkYyBQILZGQDFUYAQAoQAg9FdLKYDk1lhPAD
10FUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:24:26.000Z" objectID="{5A9A3C9E-648A-4F0A-907D-BC74EB799099}{42}{B0}"><one:Position x="84.00472259521484" y="149.2488250732422" z="1"/><one:Size width="112.5354309082031" height="0.0283584594726562"/><one:Data>AKgCHQOuAgIBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
11Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFI0Yj
12BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKkAFBh+k+5y+YwGazGApnAZjA
13UzE1QCAzOZTMmcygMxmU1gEBgEBgEBgE1mSZzSazKZzSaplM5pNUymaaTWZTNNJqgv6Pk/o+UAAA
14AAAAAAAABRRGAMAAAAkEBRRGAAAAABIAESCTBvW9+tcASYS183z19nlfCgARIKCijAxyUN0BAxVG
15AEAKABEgV/iIP9HObUA=</one:Data></one:InkDrawing></one:Page>
corpus/ink-tools/native-ui/read/page-002.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": "Beside the drawing - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/ink-tools/native-ui/read/page-002.png created
Binary files /dev/null and b/corpus/ink-tools/native-ui/read/page-002.png differ
corpus/ink-tools/native-ui/read/page-002.xml created+7
......@@ -0,0 +1,7 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{76F98ACC-6A0A-0B2D-2CD4-8EA14A7A9D79}{1}{B0}" name="Beside the drawing" dateTime="2026-09-13T00:25:49.000Z" lastModifiedTime="2026-09-30T01:13:37.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><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-13T00:25:49.000Z" objectID="{9C8EFC66-2CF4-0F8E-25EA-9196AA36B971}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="152.4244079589843" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-13T00:25:49.000Z" lastModifiedTime="2026-09-13T00:25:49.000Z" objectID="{9C8EFC66-2CF4-0F8E-25EA-9196AA36B971}{24}{B0}" alignment="left"><one:T><![CDATA[Beside the drawing]]></one:T></one:OE></one:OEChildren></one:Outline><one:InkDrawing lastModifiedTime="2026-09-13T00:25:49.000Z" objectID="{6AA48742-6050-0C44-0E78-965A4ABA9A71}{0}{B0}"><one:Position x="299.9905395507812" y="119.9905471801758" z="1"/><one:Size width="60.03781127929687" height="60.03779220581055"/><one:Data>AM8CHQSiAaIBAWAqABofi0JwRYR1gL7QRdkE+oeVMvLsF0G4wBa/8yv0et67E4vlXrNInJuhJVq3
3URK/7HHMzCKxQYkOuxMs7nqhWM9UiuaXxU+PBvi60uGbIsRmbacNslhJmh1OQDQGbBACEQZIEEUj
4RiMJSBBE/wFFI0YjBQgLZU9mZ2hkaRkUMggAgB4CIeLiQTMIAPAQAovF4kFkAxVGAEAKXAWH8gln
5jCj7wnvCe8IAh/B8h4Pke8I7wnvCQAUURgBAAAAJBAUURgAAAAASABEg7Ks5v90DzE+YKNcBjx6b
6FwoAESCA5B1tFkPdAQMVRgBACj9AIm9GCC70WAqACQEKTAIPUq7GcA5CJYzgBRRGAIAAAAkEBRRG
7AAAAABIAESBSpMe3m/jlRpcE5ouTMwiuCgARIIDkHW0WQ90BAxVGAEAKP0Aib0YILvRYCoA=</one:Data></one:InkDrawing></one:Page>
corpus/ink-tools/native-ui/read/page-003.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": "Gallery - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/ink-tools/native-ui/read/page-003.png created
Binary files /dev/null and b/corpus/ink-tools/native-ui/read/page-003.png differ
corpus/ink-tools/native-ui/read/page-003.xml created+75
......@@ -0,0 +1,75 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{9533D053-2E6C-4600-A4F6-695814263FAA}{1}{B0}" name="Gallery" dateTime="2026-09-30T00:22:50.000Z" lastModifiedTime="2026-09-30T00:23:40.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.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:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T00:22:51.000Z" lastModifiedTime="2026-09-30T00:22:51.000Z" objectID="{6349CC7E-7890-411D-9FB1-8A8FD876A494}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Gallery]]></one:T></one:OE></one:Title><one:InkDrawing lastModifiedTime="2026-09-30T00:22:55.000Z" objectID="{6349CC7E-7890-411D-9FB1-8A8FD876A494}{32}{B0}"><one:Position x="39.00472640991211" y="81.75118255615234" z="0"/><one:Size width="150.0094451904297" height="0.0283498764038086"/><one:Data>AJoCHQOSAwIBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
3Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFI0Yj
4BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKggEyh+k+5R202e1zOazOzwGa
5zGbwCazMmsAgKZpnMk1QCAwCAzOaTWZTOaTWZTNNJrMpmmUzgv5Vs/lW0AAAAAAAAAUURgBAAAAJ
6BAUURgAAAAASABEg5wN6/ciK60u6WsVDR2cJgAoAESAwfQjWcVDdAQMVRgBACgARIFf4iD+PehBA
7
8</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:22:58.000Z" objectID="{6349CC7E-7890-411D-9FB1-8A8FD876A494}{38}{B0}"><one:Position x="39.00472640991211" y="95.2440948486328" z="1"/><one:Size width="150.0094451904297" height="0.0283498764038086"/><one:Data>AJ8CHQOSAwIBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
9Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBRE7bmQ
10AUUjRiMFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5
11rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/mST+ZJQAAAAAAAABRRG
12AIAAAAkEBRRGAAAAABIAESBGQZxts4QQTbV43z4KBKVpCgARIHD5K9hxUN0BAxVGAEAKABEgV/iI
13P1V3KEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:02.000Z" objectID="{6349CC7E-7890-411D-9FB1-8A8FD876A494}{44}{B0}"><one:Position x="39.00472640991211" y="108.7370071411133" z="2"/><one:Size width="150.0094451904297" height="0.0283412933349609"/><one:Data>AJ8CHQOSAwIBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
14Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREscrt
15BUUjRiMFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5
16rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/nNz+c3QAAAAAAAABRRG
17AMAAAAkEBRRGAAAAABIAESAY7KQuf+viTJV1jzfsq3ctCgARIPA2NdpxUN0BAxVGAEAKABEgV/iI
18Pxt0QEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:05.000Z" objectID="{6349CC7E-7890-411D-9FB1-8A8FD876A494}{50}{B0}"><one:Position x="39.00472640991211" y="122.2582702636719" z="3"/><one:Size width="150.0094451904297" height="0.0283412933349609"/><one:Data>AJ8CHQOSAwIBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
19Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREl/rZ
20AUUjRiMFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5
21rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/oJb+glwAAAAAAAABRRG
22AQAAAAkEBRRGAAAAABIAESAm+zoEY2rUTJ5/d6gaBVuMCgARIICbPtxxUN0BAxVGAEAKABEgV/iI
23P8h9WEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:09.000Z" objectID="{6349CC7E-7890-411D-9FB1-8A8FD876A494}{56}{B0}"><one:Position x="39.00472640991211" y="135.7511749267578" z="4"/><one:Size width="150.0094451904297" height="0.0283412933349609"/><one:Data>AJ8CHQOSAwIBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
24Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREgIGC
25BEUjRiMFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5
26rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/pE7+kTwAAAAAAAABRRG
27AUAAAAkEBRRGAAAAABIAESCnZ/4+38wKT4pCwgl9y8fMCgARIABoRd5xUN0BAxVGAEAKABEgV/iI
28P496cEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:12.000Z" objectID="{6349CC7E-7890-411D-9FB1-8A8FD876A494}{62}{B0}"><one:Position x="39.00472640991211" y="149.2440948486328" z="5"/><one:Size width="150.0094451904297" height="0.0283412933349609"/><one:Data>AKgCHQOWAx4BYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
29Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMUSBRE//8D
30RUZHAUaQA1cJAAAAUH8FCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH
316T7lHbTZ7XM5rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/qAb+oBw
32AAAAAAAABRRGAYAAAAkEBRRGAIAAABIAESDuqWuD5PkcQbZoQPucPr1dCgARIJBbTOBxUN0BAxVG
33AEAKP0Ain6X8O+fYD+A=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:15.000Z" objectID="{6349CC7E-7890-411D-9FB1-8A8FD876A494}{68}{B0}"><one:Position x="39.00472640991211" y="162.7370147705078" z="6"/><one:Size width="150.0094451904297" height="0.0283412933349609"/><one:Data>AKkCHQOWAx4BYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
34Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMVSBREgP7/
35B0VGRwFGkANXCQAAAFB/BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKggEy
36h+k+5R202e1zOazOzwGazGbwCazMmsAgKZpnMk1QCAwCAzOaTWZTOaTWZTNNJrMpmmUzgv6u+/q7
378AAAAAAAAAUURgHAAAAJBAUURgCAAAASABEgULb3v276vU+yZJBxbMv26goAESAgT1PicVDdAQMV
38RgBACgARIOknhz+inYtA
39</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:19.000Z" objectID="{6349CC7E-7890-411D-9FB1-8A8FD876A494}{74}{B0}"><one:Position x="39.00472640991211" y="176.2582550048828" z="7"/><one:Size width="150.0094451904297" height="0.0283584594726562"/><one:Data>AJoCHQOUAwQBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
40Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFMkYy
41BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKggEyh+k+5R202e1zOazOzwGa
42zGbwCazMmsAgKZpnMk1QCAwCAzOaTWZTOaTWZTNNJrMpmmUzgv694/r3kAAAAAAAAAUURgIAAAAJ
43BAUURgAAAAASABEg598ld/kfskqe+7EWJ5S2+QoAESCgbmHkcVDdAQMVRgBACj9AIqe1/EC0qBOA
44
45</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:22.000Z" objectID="{6349CC7E-7890-411D-9FB1-8A8FD876A494}{80}{B0}"><one:Position x="39.00472640991211" y="189.7511749267578" z="8"/><one:Size width="150.0094451904297" height="0.0283584594726562"/><one:Data>AJ8CHQOUAwQBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
46Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBRE7bmQ
47AUUyRjIFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5
48rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/szD+zMQAAAAAAAABRRG
49AkAAAAkEBRRGAAAAABIAESA7WYhg62teTJWERTyI4+4BCgARIAAidOZxUN0BAxVGAEAKP0Aip7X8
50QJrYFQA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:26.000Z" objectID="{6349CC7E-7890-411D-9FB1-8A8FD876A494}{86}{B0}"><one:Position x="39.00472640991211" y="203.2440948486328" z="9"/><one:Size width="150.0094451904297" height="0.0283412933349609"/><one:Data>AJ8CHQOUAwQBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
51Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREscrt
52BUUyRjIFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5
53rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/tuj+26QAAAAAAAABRRG
54AoAAAAkEBRRGAAAAABIAESCzAtv9BGjgRKJG+TDcSpijCgARIICyhOhxUN0BAxVGAEAKP0Aip7X8
55QIEIFoA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:29.000Z" objectID="{6349CC7E-7890-411D-9FB1-8A8FD876A494}{92}{B0}"><one:Position x="39.00472640991211" y="216.7370147705078" z="10"/><one:Size width="150.0094451904297" height="0.0283412933349609"/><one:Data>AJ8CHQOUAwQBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
56Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREl/rZ
57AUUyRjIFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5
58rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/uqD+6oQAAAAAAAABRRG
59AsAAAAkEBRRGAAAAABIAESAU7/fpyXd0SJkgxjH5MyTjCgARIOBll+pxUN0BAxVGAEAKP0Aip7X8
60QGc4GAA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:33.000Z" objectID="{6349CC7E-7890-411D-9FB1-8A8FD876A494}{98}{B0}"><one:Position x="39.00472640991211" y="230.2582550048828" z="11"/><one:Size width="150.0094451904297" height="0.0283584594726562"/><one:Data>AJ8CHQOUAwQBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
61Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBREgIGC
62BEUyRjIFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqCATKH6T7lHbTZ7XM5
63rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/vlr++WwAAAAAAAABRRG
64AwAAAAkEBRRGAAAAABIAESAMVgQMFYRlS7E8QjeztIbhCgARIFCxrOxxUN0BAxVGAEAKP0Aip7X8
65QLSoGYA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:36.000Z" objectID="{6349CC7E-7890-411D-9FB1-8A8FD876A494}{104}{B0}"><one:Position x="39.00472640991211" y="243.7511749267578" z="12"/><one:Size width="150.0094451904297" height="0.0283584594726562"/><one:Data>AKoCHQOWAx4BYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
66Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMUSBREgP4D
67RUZHAUaQA1cJAAAAUH8FCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqEATKH
686T7lHbTZ7XM5rM7PAZrMZvAJrMyawCApmmcyTVAIDAIDM5pNZlM5pNZlM00msymaZTOC/wAEJf4A
69CEwAAAAAAAAFFEYDQAAACQQFFEYAgAAAEgARIJkREIJaMrZPpp8CdhQXBbYKABEgwBq97nFQ3QED
70FUYAQAoAESDpJ4c/26DTQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:23:40.000Z" objectID="{6349CC7E-7890-411D-9FB1-8A8FD876A494}{110}{B0}"><one:Position x="39.00472640991211" y="257.2441101074219" z="13"/><one:Size width="150.0094451904297" height="0.0283412933349609"/><one:Data>AKsCHQOWAx4BYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
71Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMVSBRE/4H8
72B0VGRwFGkANXCQAAAFB/BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKhAEy
73h+k+5R202e1zOazOzwGazGbwCazMmsAgKZpnMk1QCAwCAzOaTWZTOaTWZTNNJrMpmmUzgv8AC5X+
74ABcsAAAAAAAABRRGA4AAAAkEBRRGAIAAABIAESCoZ3Z0oKXFSKIRRxukb8jmCgARIEA6y/BxUN0B
75AxVGAEAKABEg6SeHPz6f30A=</one:Data></one:InkDrawing></one:Page>
corpus/ink-tools/native-ui/read/page-004.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": "Pens - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/ink-tools/native-ui/read/page-004.png created
Binary files /dev/null and b/corpus/ink-tools/native-ui/read/page-004.png differ
corpus/ink-tools/native-ui/read/page-004.xml created+39
......@@ -0,0 +1,39 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{E07965DF-76A7-4A4D-9F08-9D6699A7C453}{1}{B0}" name="Pens" dateTime="2026-09-30T00:18:53.000Z" lastModifiedTime="2026-09-30T00:19:49.000Z" pageLevel="1"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.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:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T00:18:54.000Z" lastModifiedTime="2026-09-30T00:18:54.000Z" objectID="{0EFE5C4F-A2E2-4D42-A31B-4C7EE08653F9}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Pens]]></one:T></one:OE></one:Title><one:InkDrawing lastModifiedTime="2026-09-30T00:19:01.000Z" objectID="{0EFE5C4F-A2E2-4D42-A31B-4C7EE08653F9}{37}{B0}"><one:Position x="158.9952697753906" y="72.73700714111328" z="1"/><one:Size width="0.0283412933349609" height="60.03779220581055"/><one:Data>AK4CHQMCogEBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
3Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFI0Yj
4BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKlgFKgv6q2/qrcAAAAAAAAAAA
5AIfwFFeAeuZJrNJqmc0gMzAgMxmU1mkzgMAgMAgKAQGAQGZzSAzOaQGZzSazKZplM5pNUymZNJqm
6UzJpNUymaaTUmUzABRRGAIAAAAkEBRRGAAAAABIAESCdJ4KnPMDWT4AyoS4EsfmDCj9AIkfCUi0H
7EDugAxVGAEAKABEgmuaMQBt0AEA=</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:19:39.000Z" objectID="{0EFE5C4F-A2E2-4D42-A31B-4C7EE08653F9}{63}{B0}"><one:Position x="252.0" y="86.4000015258789" z="5"/><one:Size width="72.02835845947266" height="36.02833938598632"/><one:ShapeInfo isLine="false"><one:AnchorPoint x="251.999755859375" y="86.39993286132812"/><one:AnchorPoint x="287.999755859375" y="86.39993286132812"/><one:AnchorPoint x="323.9997863769531" y="86.39993286132812"/><one:AnchorPoint x="323.9997863769531" y="104.3999328613281"/><one:AnchorPoint x="323.9997863769531" y="122.3999404907227"/><one:AnchorPoint x="287.999755859375" y="122.3999404907227"/><one:AnchorPoint x="251.999755859375" y="122.3999404907227"/><one:AnchorPoint x="251.999755859375" y="104.3999328613281"/></one:ShapeInfo><one:Data>AE0dA8QBZAEQKgAaH4tCcEWEdYC+0EXZBAMGSBBFMkYyBQILZGQDFUYAQAoiBYfxn5eIBvwEr+Al
8fwErAIfwMneBk/oauhr6GsADFUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:19:42.000Z" objectID="{0EFE5C4F-A2E2-4D42-A31B-4C7EE08653F9}{71}{B0}"><one:Position x="342.0" y="86.4000015258789" z="6"/><one:Size width="72.02835845947266" height="36.02833938598632"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="342.0" y="86.4000015258789"/><one:AnchorPoint x="414.0" y="122.400001525879"/></one:ShapeInfo><one:Data>ADsdA8QBZAEQKgAaH4tCcEWEdYC+0EXZBAMGSBBFMkYyBQILZGQDFUYAQAoQAg9eQuQ0Di+hDeAD
9FUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:18:59.000Z" objectID="{0EFE5C4F-A2E2-4D42-A31B-4C7EE08653F9}{32}{B0}"><one:Position x="39.00472640991211" y="87.76062774658203" z="0"/><one:Size width="112.5354309082031" height="0.0283498764038086"/><one:Data>ALUCHQOuAgIBYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
10Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRFI0Yj
11BQgLZU9mZ2hkaRkUMggAwAwCAADIQTMIALAJAgAAyEFkAxVGAEAKnQFQh+k+580mZAZjAUzEBmMy
12mqATWZTOZTMJrNJnMprNJnAUAgMAgMAgMzmkBgE1mUzmkBmaZTOaTWZTNNJqmUzmk1TKZzSaplM5
13pNSC/lxT+XFQAAAAAAAAAAAAAAUURgBAAAAJBAUURgAAAAASABEg43IZXxePZEqSIvXVtQWmUwo/
14QCMjAkbdBxA7oAMVRgBACgARIFf4iD+GKRtA
15</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:19:23.000Z" objectID="{0EFE5C4F-A2E2-4D42-A31B-4C7EE08653F9}{43}{B0}"><one:Position x="39.00472640991211" y="89.26298522949218" z="2"/><one:Size width="90.02834320068359" height="22.50708961486816"/><one:Data>AOgEHQP0AUABYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
16Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMLSBRE7bmQ
17AUUyRjIFCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQArLA9UBh+k+6QuATKaw
18CAzSawCazKAQGAQGZzSAzOaTWZTOZTOaTVMpmmk1mUzTSazKZppNZlM00moJkmkzgMAmU1gE1mUA
19gKATWZQCazKZzSazKZzSaplM00msymaZTOaTVMpnNJqmUzBNIDNJnAYBMJjAZnNIDAIDAJrMoBNZ
20lM5lM5pNZlM00mqZTOaTVMpnNJqmUzmk1TKZgmkBmk1gEBmkzgMzmkBQCAzOaQGZzSazKZzSazKZ
21ppNUymc0mqZTOZTNNJrMpmmk1ACH8GpXg21mk1mkBgE1mUAmU1gEBgEBmkzTKazSZzKazSZzJNZo
22mcyJrNEzmSazQmcyTOZCZJpM4DAJlNYBNZkgEBgE1mUAmsymc0msymc0mqZTNNJqTKZplM00mpMp
23mmk1Jlh2aTWaQGATWZQCZTWAQGAQGaTNMprNJnMprNJnMk1miZzIms0TOZJrNCZzJM5kJkmkzgMA
24mU1gE1mSAQGATWZQCazKZzSazKZzSaplM00mpMpmmUzTSakymaaTUmQFFEYAwAAACQQFFEYAAAAA
25EgARICQJs8oEpxxMtZFlXTzuPzUKABEgQNoRVnFQ3QEDFUYAQAo/QCKntfxG4PgDoA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:19:45.000Z" objectID="{0EFE5C4F-A2E2-4D42-A31B-4C7EE08653F9}{78}{B0}"><one:Position x="252.0" y="158.4000091552734" z="7"/><one:Size width="72.02835845947266" height="36.02833938598632"/><one:ShapeInfo isLine="true"><one:AnchorPoint x="252.0" y="158.4000091552734"/><one:AnchorPoint x="324.0" y="194.4000091552734"/></one:ShapeInfo><one:Data>AFUdA8QBZAEQKgAaH4tCcEWEdYC+0EXZBAMGSBBFMkYyBQILZGQDFUYAQAoQAg9FdLKYDldRrKAD
26FUYAQAoYA4fyMAeRDfjRQIfxDYeH0/g4QAMVRgBA
27</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:19:49.000Z" objectID="{0EFE5C4F-A2E2-4D42-A31B-4C7EE08653F9}{86}{B0}"><one:Position x="342.0" y="158.4283447265625" z="8"/><one:Size width="72.02835845947266" height="35.97165679931641"/><one:ShapeInfo isLine="false"><one:AnchorPoint x="378.0002746582031" y="158.4002532958984"/><one:AnchorPoint x="414.0002746582031" y="176.4002532958984"/><one:AnchorPoint x="378.0002746582031" y="194.4002532958984"/><one:AnchorPoint x="342.0002746582031" y="176.4002532958984"/></one:ShapeInfo><one:Data>AKABHQPEAWQBECoAGh+LQnBFhHWAvtBF2QQDBkgQRTJGMgUCC2RkAxVGAEAKdTOH8wTHmCtk8nkc
28fkcbjcTh8Lg8DgUGhUOiUajUij0ik0mk0mk0ij0ijUaiUOhUGgUDg8Lh8Tjcbkcfkcnk4IX4fTPh
296tjjnppvtvweBweDweDwODvtvppnjjRRTUUXWXYLAYLBYLBYDBXWXUUTRRADFUYAQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:19:26.000Z" objectID="{0EFE5C4F-A2E2-4D42-A31B-4C7EE08653F9}{49}{B0}"><one:Position x="39.00472640991211" y="171.7511749267578" z="3"/><one:Size width="150.0094451904297" height="0.0283584594726562"/><one:Data>ANECHQOWAx4BYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR
30Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMUSBRE//8D
31RUZHAUaQA1cJAAAAUH8FCAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqrAVaH
326T7nL5jAU1mMwmM3mkzmcwmMBmcymaAzGZTVAIDM5lM00mqazRNZpM5lNUAgM0mc1mUAgMAgMzmk
33Bmcymc0msymc0mqZTNNJrMpmmk1mUzTSazKZplMwgv646/rjsAAAAAAAAAAAAAAFFEYBAAAACQQF
34FEYAgAAAEgARIIxv3sLIAtJLsNVUUmVe0nIKABEgwIKVWXFQ3QEDFUYAQAoAESDpJ4c/26CTQA==</one:Data></one:InkDrawing><one:InkDrawing lastModifiedTime="2026-09-30T00:19:30.000Z" objectID="{0EFE5C4F-A2E2-4D42-A31B-4C7EE08653F9}{54}{B0}"><one:Position x="188.9858245849609" y="239.2440948486328" z="4"/><one:Size width="30.0472469329834" height="22.53543090820312"/><one:Data>AMkCHQJSPgFgKgAaH4tCcEWEdYC+0EXZBPqHlTLy7BdBuMAWv/Mr9HreuxOL5V6zSJyboSVat1ES
35v+xxzMwisUGJDrsTLO56oVjPVIrml8VPjwb4utLhmyLEZm2nDbJYSZodTkA0BmwQAwZIFEUjRiMF
36CAtlT2ZnaGRpGRQyCADADAIAAMhBMwgAsAkCAADIQWQDFUYAQAqyATeH8RSniIrmcyJrNJrAIDNJ
37nAYBAYBAUzmkBmc0gMzmk1mUzmk1mUzTSazKZppNZlM0ymc0mqZTNNJqh/GDd4wOYDNJrAIDNJnA
38ZnNIDAIDAJrMkzmk1mUzmk1mUzTSaplM00mpMpmmk1JlM00mpMpmBRRGAUAAAAkEBRRGAAAAABIA
39ESAy8nFzf6jGRYlYDFxmSkMdCj9AI42C380HEDugAxVGAEAKABEgHo+nQKo71EA=</one:Data></one:InkDrawing></one:Page>
corpus/ink-tools/native-ui/read/payloads.json created+3
......@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/ink-tools/native-ui/run.json created+18
......@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/snowbound-ink/gate/native-src",
3 "expected_pages": 5,
4 "author": null,
5 "author_timeout_seconds": 600,
6 "inspect": false,
7 "collect_notebook": false,
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/ink-tools/native-ui/scripts/cold.ps1 created+27
......@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/ink-tools/native-ui/scripts/read.ps1 created+144
......@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/ink-tools/native-ui/source.json created+14
......@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 3748,
5 "sha256": "c8d045166bedd6a34bc320687164a6028da67a22e740f56866a7850148241997",
6 "mtime_ns": 1790730645582703153
7 },
8 {
9 "path": "ink.one",
10 "bytes": 75888,
11 "sha256": "6971b45218ee86de5ceece252ffa7fae5d6bff58182604e46f2d9e48818f3cde",
12 "mtime_ns": 1790730645582324906
13 }
14]
corpus/ink-tools/native-ui/teardown.json created+1
......@@ -0,0 +1 @@
1{"absent": true}
corpus/media-edit/README.md+2-1
......@@ -8,7 +8,8 @@
88annotated a paragraph with the moment 500 ms. The recording attachment
99(`page::Attachment::recording`) and the annotation
1010(`PageParagraph::media`) are read from the page and kept through the edit;
11a model that adds an annotation or drops the recording refuses to write.
11a model that drops the recording refuses to write. `corpus/recording` covers
12writing recordings and links.
1213
1314`cold/` is a fresh OneNote 2010 read: `MediaFile`, `MediaReference` and
1415`MediaIndex` name the same recording and moment as before the edit, next to
corpus/recording/README.md created+65
......@@ -0,0 +1,65 @@
1# Recording audio
2
3## What OneNote 2010 does
4
5Observed in a lab clone on 2026-09-29, with a QEMU HD Audio device on a null
6audio backend (it records silence; the clone has no camera, so Record Video
7was not observed):
8
9- **Record Audio** (Insert, Recording) with the caret at the end of "Before
10 recording": after that paragraph OneNote adds the recording's file, an empty
11 paragraph, "Audio recording started: 5:18 PM Tuesday, September 29, 2026" in
12 the `cite` quick style (Calibri 9, #595959) and an empty paragraph. The
13 contextual Audio & Video tab shows Recording: Record Audio, Record Video,
14 Play, Pause, Stop, Rewind 10 Minutes, Rewind 10 Seconds, the position
15 ("00:08/00:08"), Fast Forward 10 Seconds, Fast Forward 10 Minutes, See
16 Playback and Audio & Video Settings (`native/read/onenote-recording-tab.png`).
17- **The file** is named after the page ("Recorded.wma", shown without its
18 extension) and is Windows Media Audio 9 Voice, 8 kHz mono: 24,827 bytes for
19 34 seconds. It is stored in the section's own file data store beside a
20 32-pixel icon, never in `_onefiles`. The attachment carries
21 AudioRecordingGuid (`0x1c001c97`), IRecordMedia 1 (`0x14001d24`) and
22 AudioRecordingDuration in milliseconds (`0x14001cfd`, 33,903); the page lists
23 the recording in AudioRecordingGuids (`0x1c001ca3`, COM's `MediaPlaylist`).
24- **Linked notes.** Every paragraph whose text changes while recording is
25 linked to that moment: the recording's GUID (`0x1c001c98`) and milliseconds
26 since the start (`0x14001c99`, COM's `MediaIndex`) on its text object. A
27 paragraph edited during recording is linked too ("Before recording edited",
28 20,131 ms); empty paragraphs are not. The line saying when and the file are
29 linked at 0 and 151 ms.
30- **Playback.** Hovering a linked paragraph, or selecting the recording, shows
31 a blue play button in the margin (`native/read/onenote-linked-notes.png`); a
32 click plays from the linked moment.
33- **Deleting** the recording removes AudioRecordingGuids from the page; the
34 notes keep their links (`native/deleted`).
35- **Attach File** of a .wav makes it a recording as well: identity,
36 IRecordMedia 1 and its length (1,000 ms), listed on the page
37 (`native/attached`). MS-ONE requires this of .wma, .mp3, .wav, .wmv, .avi and
38 .mpg files.
39
40## Rows
41
42`native/` is OneNote's recording (`notebook/`), its COM export
43(`read/page-recorded.xml`), and the section after OneNote deleted the
44recording (`deleted/`) or attached a .wav (`attached/`).
45
46`candidate/` is `a_recording_stores_its_file_line_and_linked_notes_for_onenote`
47in `crates/snowbound/src/recording.rs`: the editor records at the end of
48"Before recording", two notes are typed while it records, and Stop puts in
49two seconds of a tone as Snowbound stores recordings, 16 kHz IMA ADPCM WAV.
50`cold/` is a fresh OneNote 2010 read: the playlist, the file with its bytes,
51the line and both notes linked to their moments. Opened in a lab clone, the
52note's play button played the recording from there (`played-from-note.png`);
53`native/read/onenote-plays-ima-adpcm.png` is OneNote playing a 20-second one.
54Regenerate with `SNOWBOUND_RECORDING_EXPORT`, then `tools/native_runner.py DIR
55COLD --expected-pages 1 --collect-notebook --screenshots`.
56
57`edit/` is `onenote_s_recording_round_trips_through_deleting_restoring_and_linking`
58in `crates/onestore/tests/page_media.rs`: OneNote's recording deleted, put back
59as undo does, and a paragraph linked at 25 s, each as its own edit. `edit/cold`
60is a fresh OneNote 2010 read: the same playlist, links, WMA bytes and the new
61link. Regenerate with `ONESTORE_RECORDING_EDIT_EXPORT`, then
62`tools/native_runner.py DIR COLD --expected-pages 2 --collect-notebook
63--screenshots`.
64
65`tools/test_recording.py` checks the rows without a VM.
corpus/recording/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/recording/candidate/Open Notebook.onetoc2 differ
corpus/recording/candidate/Recorded.one created
Binary files /dev/null and b/corpus/recording/candidate/Recorded.one differ
corpus/recording/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-AB2185D7 -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/recording/cold/machine.json created+1
......@@ -0,0 +1 @@
1{"name": "m6-ab2185d7", "hostname": "ONE-M6-AB2185D7"}
corpus/recording/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/recording/cold/notebook/Open Notebook.onetoc2 differ
corpus/recording/cold/notebook/Recorded.one created
Binary files /dev/null and b/corpus/recording/cold/notebook/Recorded.one differ
corpus/recording/cold/read/36b0ffb210410ba1e1afffb66c6817f76261c422c4a083ab7f721cb94b186087.attachment created
Binary files /dev/null and b/corpus/recording/cold/read/36b0ffb210410ba1e1afffb66c6817f76261c422c4a083ab7f721cb94b186087.attachment differ
corpus/recording/cold/read/environment.json created+7
......@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-AB2185D7",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/recording/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="{AB730F2F-036C-4D72-A711-326F7C10168F}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T01:22:24.000Z" color="#B49EDE"><one:Section name="Recorded" ID="{EED087BE-07B2-05B4-3848-B86669CDFDC1}{1}{B0}" path="C:\one-tests\runs\capture\notebook\Recorded.one" lastModifiedTime="2026-09-30T01:22:24.000Z" color="#8AA8E4"><one:Page ID="{8456898C-BAE3-030C-1BB1-61E30651A3D9}{1}{B0}" name="Before recording" dateTime="2026-09-30T01:19:00.000Z" lastModifiedTime="2026-09-30T01:22:24.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/recording/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": "Before recording - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/recording/cold/read/page-000.png created
Binary files /dev/null and b/corpus/recording/cold/read/page-000.png differ
corpus/recording/cold/read/page-000.xml created+2
......@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{8456898C-BAE3-030C-1BB1-61E30651A3D9}{1}{B0}" name="Before recording" dateTime="2026-09-30T01:19:00.000Z" lastModifiedTime="2026-09-30T01:22:24.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="cite" fontColor="#595959" highlightColor="automatic" font="Calibri" fontSize="9.0" spaceBefore="0.0" spaceAfter="0.0"/><one:MediaPlaylist><one:MediaReference mediaID="{15A71FFE-6FF6-4E3E-95A8-88B32D892606}"/></one:MediaPlaylist><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Snowbound" authorInitials="S" lastModifiedBy="Snowbound" lastModifiedByInitials="S" lastModifiedTime="2026-09-30T01:19:00.000Z" objectID="{EED087BE-07B2-05B4-3848-B86669CDFDC1}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="272.0706176757812" height="127.6971435546875"/><one:OEChildren><one:OE creationTime="2026-09-30T01:19:00.000Z" lastModifiedTime="2026-09-30T01:19:00.000Z" objectID="{EED087BE-07B2-05B4-3848-B86669CDFDC1}{24}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:T><![CDATA[Before recording]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:24.000Z" lastModifiedTime="2025-08-18T14:13:24.000Z" objectID="{9ABDFC70-D187-0E34-1234-8F9E8F02FD0F}{11}{B0}" alignment="left"><one:MediaIndex timeIndex="0"><one:MediaReference mediaID="{15A71FFE-6FF6-4E3E-95A8-88B32D892606}"/></one:MediaIndex><one:MediaFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\5154cbf2-8ca1-45d6-a9ab-df456b942fcb.wav" preferredName="Recorded.wav"><one:MediaReference mediaID="{15A71FFE-6FF6-4E3E-95A8-88B32D892606}"/></one:MediaFile></one:OE><one:OE creationTime="2025-08-18T14:13:21.000Z" lastModifiedTime="2025-08-18T14:13:21.000Z" objectID="{9ABDFC70-D187-0E34-1234-8F9E8F02FD0F}{1}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:T><![CDATA[]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:21.000Z" lastModifiedTime="2025-08-18T14:13:21.000Z" objectID="{9ABDFC70-D187-0E34-1234-8F9E8F02FD0F}{3}{B0}" alignment="left" quickStyleIndex="0"><one:MediaIndex timeIndex="0"><one:MediaReference mediaID="{15A71FFE-6FF6-4E3E-95A8-88B32D892606}"/></one:MediaIndex><one:T><![CDATA[Audio recording started: 5:18 PM Tuesday, September 29, 2026]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:21.000Z" lastModifiedTime="2025-08-18T14:13:22.000Z" objectID="{9ABDFC70-D187-0E34-1234-8F9E8F02FD0F}{5}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:MediaIndex timeIndex="50"><one:MediaReference mediaID="{15A71FFE-6FF6-4E3E-95A8-88B32D892606}"/></one:MediaIndex><one:T><![CDATA[First linked note]]></one:T></one:OE><one:OE creationTime="2025-08-18T14:13:23.000Z" lastModifiedTime="2025-08-18T14:13:23.000Z" objectID="{9ABDFC70-D187-0E34-1234-8F9E8F02FD0F}{8}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:MediaIndex timeIndex="91"><one:MediaReference mediaID="{15A71FFE-6FF6-4E3E-95A8-88B32D892606}"/></one:MediaIndex><one:T><![CDATA[Second linked note]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/recording/cold/read/payloads.json created+10
......@@ -0,0 +1,10 @@
1[
2 {
3 "sha256": "36b0ffb210410ba1e1afffb66c6817f76261c422c4a083ab7f721cb94b186087",
4 "name": "Recorded.wav",
5 "object": "{9ABDFC70-D187-0E34-1234-8F9E8F02FD0F}{11}{B0}",
6 "kind": "MediaFile",
7 "page": "{8456898C-BAE3-030C-1BB1-61E30651A3D9}{1}{B0}",
8 "bytes": 16444
9 }
10]
\ No newline at end of file
corpus/recording/cold/run.json created+18
......@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/snowbound-recording/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/recording/cold/scripts/cold.ps1 created+27
......@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/recording/cold/scripts/read.ps1 created+144
......@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/recording/cold/source.json created+14
......@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1604,
5 "sha256": "367002b3fa8652d57b33524b212bdd1c9e2db92c05a3dcd0b1b81c42a9f5b571",
6 "mtime_ns": 1790731147609636401
7 },
8 {
9 "path": "Recorded.one",
10 "bytes": 26848,
11 "sha256": "34339ca682fb0c5686ff03e8e181664d3d776f42ba6cb6c7e43a651bf7dbbca2",
12 "mtime_ns": 1790731147609438776
13 }
14]
corpus/recording/cold/teardown.json created+1
......@@ -0,0 +1 @@
1{"absent": true}
corpus/recording/edit/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/recording/edit/candidate/Open Notebook.onetoc2 differ
corpus/recording/edit/candidate/files.one created
Binary files /dev/null and b/corpus/recording/edit/candidate/files.one differ
corpus/recording/edit/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-C1DF0A20 -ExpectedPages 2 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 2 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/recording/edit/cold/machine.json created+1
......@@ -0,0 +1 @@
1{"name": "m6-c1df0a20", "hostname": "ONE-M6-C1DF0A20"}
corpus/recording/edit/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/recording/edit/cold/notebook/Open Notebook.onetoc2 differ
corpus/recording/edit/cold/notebook/files.one created
Binary files /dev/null and b/corpus/recording/edit/cold/notebook/files.one differ
corpus/recording/edit/cold/read/b20a14e044e33eac39b4665e7d79813b8a3672dbbd27ed9d2e4ac866c13ec4b6.attachment created+1
......@@ -0,0 +1 @@
1Rust wrote this attachment.
corpus/recording/edit/cold/read/c41bd6bd61277bdc4bf3557b5572be5c08451ba5b4665aba662ac1280d907ca3.attachment created
Binary files /dev/null and b/corpus/recording/edit/cold/read/c41bd6bd61277bdc4bf3557b5572be5c08451ba5b4665aba662ac1280d907ca3.attachment differ
corpus/recording/edit/cold/read/environment.json created+7
......@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-C1DF0A20",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/recording/edit/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="{70C86429-74AC-49DA-89F8-36EEEBE00B05}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-30T01:03:32.000Z" color="#B49EDE"><one:Section name="files" ID="{2F599349-FBBE-05E3-0652-268C3758DCC9}{1}{B0}" path="C:\one-tests\runs\capture\notebook\files.one" lastModifiedTime="2026-09-30T01:03:32.000Z" color="#8AA8E4"><one:Page ID="{D383DAE3-517B-02EB-0821-2990092E3DE9}{1}{B0}" name="Before the file" dateTime="2026-09-12T20:54:55.000Z" lastModifiedTime="2026-09-30T01:03:32.000Z" pageLevel="1"/><one:Page ID="{0D60127D-BED1-425E-8EA7-E0DE4C091A0E}{1}{B0}" name="Recorded" dateTime="2026-09-30T00:18:10.000Z" lastModifiedTime="2026-09-30T00:18:50.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/recording/edit/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": "Recorded - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/recording/edit/cold/read/page-000.png created
Binary files /dev/null and b/corpus/recording/edit/cold/read/page-000.png differ
corpus/recording/edit/cold/read/page-000.xml created+2
......@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{0D60127D-BED1-425E-8EA7-E0DE4C091A0E}{1}{B0}" name="Recorded" dateTime="2026-09-30T00:18:10.000Z" lastModifiedTime="2026-09-30T00:18:50.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="1" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="2" name="cite" fontColor="#595959" highlightColor="automatic" font="Calibri" fontSize="9.0" spaceBefore="0.0" spaceAfter="0.0"/><one:MediaPlaylist><one:MediaReference mediaID="{0D7D7123-FDC2-4EA7-8A00-908F4F32D5E5}"/></one:MediaPlaylist><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T00:18:12.000Z" lastModifiedTime="2026-09-30T00:18:12.000Z" objectID="{831B7C66-D79E-4364-BC87-04D01F7A5187}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Recorded]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T01:01:01.000Z" objectID="{831B7C66-D79E-4364-BC87-04D01F7A5187}{28}{B0}"><one:Position x="36.0" y="86.4000015258789" z="0"/><one:Size width="272.0706176757812" height="154.5525970458984"/><one:OEChildren><one:OE creationTime="2026-09-30T00:18:40.000Z" lastModifiedTime="2026-09-30T00:18:40.000Z" objectID="{831B7C66-D79E-4364-BC87-04D01F7A5187}{29}{B0}" alignment="left" quickStyleIndex="1"><one:MediaIndex timeIndex="20131"><one:MediaReference mediaID="{0D7D7123-FDC2-4EA7-8A00-908F4F32D5E5}"/></one:MediaIndex><one:T><![CDATA[Before recording edited]]></one:T></one:OE><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-09-30T01:01:01.000Z" lastModifiedTime="2026-09-30T01:01:01.000Z" objectID="{831B7C66-D79E-4364-BC87-04D01F7A5187}{46}{B0}" alignment="left"><one:MediaIndex timeIndex="151"><one:MediaReference mediaID="{0D7D7123-FDC2-4EA7-8A00-908F4F32D5E5}"/></one:MediaIndex><one:MediaFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\7350cade-df91-45d9-8aed-577a41ffef0b.wma" pathSource="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\Audio Cache\Recorded.wma" preferredName="Recorded.wma"><one:MediaReference mediaID="{0D7D7123-FDC2-4EA7-8A00-908F4F32D5E5}"/></one:MediaFile></one:OE><one:OE creationTime="2026-09-30T00:18:20.000Z" lastModifiedTime="2026-09-30T00:18:20.000Z" objectID="{831B7C66-D79E-4364-BC87-04D01F7A5187}{34}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[]]></one:T></one:OE><one:OE creationTime="2026-09-30T00:18:20.000Z" lastModifiedTime="2026-09-30T00:18:20.000Z" objectID="{831B7C66-D79E-4364-BC87-04D01F7A5187}{36}{B0}" alignment="left" quickStyleIndex="2"><one:MediaIndex timeIndex="0"><one:MediaReference mediaID="{0D7D7123-FDC2-4EA7-8A00-908F4F32D5E5}"/></one:MediaIndex><one:T><![CDATA[Audio recording started: 5:18 PM Tuesday, September 29, 2026]]></one:T></one:OE><one:OE creationTime="2026-09-30T00:18:20.000Z" lastModifiedTime="2026-09-30T00:18:20.000Z" objectID="{831B7C66-D79E-4364-BC87-04D01F7A5187}{39}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[]]></one:T></one:OE><one:OE creationTime="2026-09-30T00:18:35.000Z" lastModifiedTime="2026-09-30T00:18:35.000Z" objectID="{831B7C66-D79E-4364-BC87-04D01F7A5187}{41}{B0}" alignment="left" quickStyleIndex="1"><one:MediaIndex timeIndex="15123"><one:MediaReference mediaID="{0D7D7123-FDC2-4EA7-8A00-908F4F32D5E5}"/></one:MediaIndex><one:T><![CDATA[First linked note]]></one:T></one:OE><one:OE creationTime="2026-09-30T00:18:38.000Z" lastModifiedTime="2026-09-30T00:18:38.000Z" objectID="{831B7C66-D79E-4364-BC87-04D01F7A5187}{47}{B0}" alignment="left" quickStyleIndex="1"><one:MediaIndex timeIndex="18134"><one:MediaReference mediaID="{0D7D7123-FDC2-4EA7-8A00-908F4F32D5E5}"/></one:MediaIndex><one:T><![CDATA[Second linked note]]></one:T></one:OE><one:OE author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" creationTime="2026-09-30T01:01:01.000Z" lastModifiedTime="2026-09-30T01:01:01.000Z" objectID="{9427AC27-0484-0E62-2B01-1A318C39298F}{1}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:MediaIndex timeIndex="25000"><one:MediaReference mediaID="{0D7D7123-FDC2-4EA7-8A00-908F4F32D5E5}"/></one:MediaIndex><one:T><![CDATA[Linked by Snowbound]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/recording/edit/cold/read/page-001.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": "Before the file - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/recording/edit/cold/read/page-001.png created
Binary files /dev/null and b/corpus/recording/edit/cold/read/page-001.png differ
corpus/recording/edit/cold/read/page-001.xml created+2
......@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{D383DAE3-517B-02EB-0821-2990092E3DE9}{1}{B0}" name="Before the file" dateTime="2026-09-12T20:54:55.000Z" lastModifiedTime="2026-09-30T01:03:32.000Z" pageLevel="1" lang="en-US"><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-12T20:54:55.000Z" objectID="{2F599349-FBBE-05E3-0652-268C3758DCC9}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="130.4619598388672" height="89.85542297363281"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-12T20:54:55.000Z" lastModifiedTime="2026-09-12T20:54:55.000Z" objectID="{2F599349-FBBE-05E3-0652-268C3758DCC9}{24}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:T><![CDATA[Before the file]]></one:T></one:OE><one:OE author="Attachment author" lastModifiedBy="Attachment author" creationTime="2026-09-12T20:54:55.000Z" lastModifiedTime="2026-09-12T20:54:55.000Z" objectID="{DC10E2AC-49AB-0DF4-26AE-2E8215DF4D70}{0}{B0}" alignment="left"><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\6b5d60fe-566b-4a4b-aef1-fd37d628779c.txt" preferredName="notes 🦀.txt"/></one:OE><one:OE author="Attachment author" lastModifiedBy="Attachment author" creationTime="2026-09-12T20:54:55.000Z" lastModifiedTime="2026-09-12T20:54:55.000Z" objectID="{6F57B715-DDC6-02FB-3925-4B11BC745096}{0}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:T><![CDATA[After the file]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/recording/edit/cold/read/payloads.json created+18
......@@ -0,0 +1,18 @@
1[
2 {
3 "sha256": "c41bd6bd61277bdc4bf3557b5572be5c08451ba5b4665aba662ac1280d907ca3",
4 "name": "Recorded.wma",
5 "object": "{831B7C66-D79E-4364-BC87-04D01F7A5187}{46}{B0}",
6 "kind": "MediaFile",
7 "page": "{0D60127D-BED1-425E-8EA7-E0DE4C091A0E}{1}{B0}",
8 "bytes": 24827
9 },
10 {
11 "sha256": "b20a14e044e33eac39b4665e7d79813b8a3672dbbd27ed9d2e4ac866c13ec4b6",
12 "name": "notes 🦀.txt",
13 "object": "{DC10E2AC-49AB-0DF4-26AE-2E8215DF4D70}{0}{B0}",
14 "kind": "InsertedFile",
15 "page": "{D383DAE3-517B-02EB-0821-2990092E3DE9}{1}{B0}",
16 "bytes": 28
17 }
18]
\ No newline at end of file
corpus/recording/edit/cold/run.json created+18
......@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/snowbound-recording/edit-candidate",
3 "expected_pages": 2,
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/recording/edit/cold/scripts/cold.ps1 created+27
......@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/recording/edit/cold/scripts/read.ps1 created+144
......@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/recording/edit/cold/source.json created+14
......@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 3764,
5 "sha256": "b47a0549b7401a416419d94f174f159ed257a8248ca2a60b9b7859f19637212d",
6 "mtime_ns": 1790730061936055248
7 },
8 {
9 "path": "files.one",
10 "bytes": 82304,
11 "sha256": "fcf457cbb8c5bba3ee565908b52a2abb8b1da20ce9f34dffb42d83f558104b10",
12 "mtime_ns": 1790730061935940415
13 }
14]
corpus/recording/edit/cold/teardown.json created+1
......@@ -0,0 +1 @@
1{"absent": true}
corpus/recording/native/attached/files.one created
Binary files /dev/null and b/corpus/recording/native/attached/files.one differ
corpus/recording/native/deleted/files.one created
Binary files /dev/null and b/corpus/recording/native/deleted/files.one differ
corpus/recording/native/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/recording/native/notebook/Open Notebook.onetoc2 differ
corpus/recording/native/notebook/files.one created
Binary files /dev/null and b/corpus/recording/native/notebook/files.one differ
corpus/recording/native/read/onenote-linked-notes.png created
Binary files /dev/null and b/corpus/recording/native/read/onenote-linked-notes.png differ
corpus/recording/native/read/onenote-plays-ima-adpcm.png created
Binary files /dev/null and b/corpus/recording/native/read/onenote-plays-ima-adpcm.png differ
corpus/recording/native/read/onenote-recording-tab.png created
Binary files /dev/null and b/corpus/recording/native/read/onenote-recording-tab.png differ
corpus/recording/native/read/page-recorded.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="{D2B5565B-CE76-4195-A48D-FD09668AD090}{1}{B0}" name="Recorded" dateTime="2026-09-30T00:18:10.000Z" lastModifiedTime="2026-09-30T00:18:50.000Z" pageLevel="1" isCurrentlyViewed="true" lang="en-US"><one:QuickStyleDef index="0" name="PageTitle" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="17.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="1" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="2" name="cite" fontColor="#595959" highlightColor="automatic" font="Calibri" fontSize="9.0" spaceBefore="0.0" spaceAfter="0.0"/><one:MediaPlaylist><one:MediaReference mediaID="{0D7D7123-FDC2-4EA7-8A00-908F4F32D5E5}"/></one:MediaPlaylist><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-30T00:18:12.000Z" lastModifiedTime="2026-09-30T00:18:12.000Z" objectID="{5CCE3840-A739-40AF-96AD-190735F99B19}{15}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Recorded]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-30T00:18:40.000Z" objectID="{5CCE3840-A739-40AF-96AD-190735F99B19}{28}{B0}"><one:Position x="36.0" y="86.4000015258789" z="0"/><one:Size width="272.0706176757812" height="141.1248626708984"/><one:OEChildren><one:OE creationTime="2026-09-30T00:18:40.000Z" lastModifiedTime="2026-09-30T00:18:40.000Z" objectID="{5CCE3840-A739-40AF-96AD-190735F99B19}{29}{B0}" alignment="left" quickStyleIndex="1"><one:MediaIndex timeIndex="20131"><one:MediaReference mediaID="{0D7D7123-FDC2-4EA7-8A00-908F4F32D5E5}"/></one:MediaIndex><one:T><![CDATA[Before recording edited]]></one:T></one:OE><one:OE creationTime="2026-09-30T00:18:20.000Z" lastModifiedTime="2026-09-30T00:18:20.000Z" objectID="{5CCE3840-A739-40AF-96AD-190735F99B19}{46}{B0}" alignment="left"><one:MediaIndex timeIndex="151"><one:MediaReference mediaID="{0D7D7123-FDC2-4EA7-8A00-908F4F32D5E5}"/></one:MediaIndex><one:MediaFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\16475b97-e54f-4f30-a381-c4fb8f2e074c.wma" pathSource="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\Audio Cache\Recorded.wma" preferredName="Recorded.wma"><one:MediaReference mediaID="{0D7D7123-FDC2-4EA7-8A00-908F4F32D5E5}"/></one:MediaFile></one:OE><one:OE creationTime="2026-09-30T00:18:20.000Z" lastModifiedTime="2026-09-30T00:18:20.000Z" objectID="{5CCE3840-A739-40AF-96AD-190735F99B19}{34}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[]]></one:T></one:OE><one:OE creationTime="2026-09-30T00:18:20.000Z" lastModifiedTime="2026-09-30T00:18:20.000Z" objectID="{5CCE3840-A739-40AF-96AD-190735F99B19}{36}{B0}" alignment="left" quickStyleIndex="2"><one:MediaIndex timeIndex="0"><one:MediaReference mediaID="{0D7D7123-FDC2-4EA7-8A00-908F4F32D5E5}"/></one:MediaIndex><one:T><![CDATA[Audio recording started: 5:18 PM Tuesday, September 29, 2026]]></one:T></one:OE><one:OE creationTime="2026-09-30T00:18:20.000Z" lastModifiedTime="2026-09-30T00:18:20.000Z" objectID="{5CCE3840-A739-40AF-96AD-190735F99B19}{39}{B0}" alignment="left" quickStyleIndex="1"><one:T><![CDATA[]]></one:T></one:OE><one:OE creationTime="2026-09-30T00:18:35.000Z" lastModifiedTime="2026-09-30T00:18:35.000Z" objectID="{5CCE3840-A739-40AF-96AD-190735F99B19}{41}{B0}" alignment="left" quickStyleIndex="1"><one:MediaIndex timeIndex="15123"><one:MediaReference mediaID="{0D7D7123-FDC2-4EA7-8A00-908F4F32D5E5}"/></one:MediaIndex><one:T><![CDATA[First linked note]]></one:T></one:OE><one:OE creationTime="2026-09-30T00:18:38.000Z" lastModifiedTime="2026-09-30T00:18:38.000Z" objectID="{5CCE3840-A739-40AF-96AD-190735F99B19}{47}{B0}" alignment="left" quickStyleIndex="1"><one:MediaIndex timeIndex="18134"><one:MediaReference mediaID="{0D7D7123-FDC2-4EA7-8A00-908F4F32D5E5}"/></one:MediaIndex><one:T><![CDATA[Second linked note]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
3
corpus/recording/played-from-note.png created
Binary files /dev/null and b/corpus/recording/played-from-note.png differ
crates/canvas/src/editor.rs+47-3
......@@ -28,14 +28,17 @@ mod equation;
2828#[cfg(test)]
2929mod evidence;
3030mod format;
31mod ink;
3132mod link;
3233mod ops;
3334pub(crate) mod page;
35mod recording;
3436mod space;
3537mod table;
3638pub use format::{
3739 Alignment, BULLET_LIBRARY, FormatState, Formatting, ListStyle, NUMBER_LIBRARY, NoteTag, Toggle,
3840};
41pub use ink::{FAVORITES, Pen};
3942pub use link::{Link, shown_urls};
4043pub use page::ReadOnlyObject;
4144
......@@ -120,6 +123,9 @@ pub struct CanvasEditor {
120123 /// The outline, caret and undo depth a run of typing left, which typing there joins as
121124 /// OneNote 2010 does until the caret moves or another kind of edit comes between.
122125 typing: Option<(ExGuid, TextPosition, usize)>,
126 recording: Option<recording::Live>,
127 /// Drawings being dragged and how far, shown there until dropped.
128 ink_drag: Option<(Vec<ExGuid>, [f32; 2])>,
123129}
124130
125131/// Imported page state the editable content does not carry.
......@@ -813,6 +819,17 @@ enum History {
813819 index: usize,
814820 file: Option<Box<page::Content>>,
815821 },
822 /// Restores the drawing on the page at `index` in paint order, or removes the one there
823 /// when `None`.
824 Ink {
825 index: usize,
826 ink: Option<Box<onestore::page::Ink>>,
827 },
828 /// Gives a drawing back the strokes it had.
829 Strokes {
830 ink: ExGuid,
831 strokes: Vec<onestore::page::InkStroke>,
832 },
816833 Remove {
817834 outline: onestore::ExGuid,
818835 focus: RestoreFocus,
......@@ -870,6 +887,8 @@ impl CanvasEditor {
870887 stored: None,
871888 whole: None,
872889 typing: None,
890 recording: None,
891 ink_drag: None,
873892 })
874893 }
875894
......@@ -1058,9 +1077,11 @@ impl CanvasEditor {
10581077 let reaches_one = |history: &History| match history {
10591078 History::Date(_) => date_changed,
10601079 History::Paper { .. } => false,
1061 History::Image { .. } | History::Picture { .. } | History::File { .. } => {
1062 objects_changed
1063 }
1080 History::Image { .. }
1081 | History::Picture { .. }
1082 | History::File { .. }
1083 | History::Ink { .. }
1084 | History::Strokes { .. } => objects_changed,
10641085 History::Draft { outlines, .. } => changed.contains(&outlines[0].id),
10651086 History::Text { outline, change } => {
10661087 changed.contains(outline)
......@@ -1446,6 +1467,8 @@ impl CanvasEditor {
14461467 stored: None,
14471468 whole: None,
14481469 typing: None,
1470 recording: None,
1471 ink_drag: None,
14491472 })
14501473 }
14511474
......@@ -3683,6 +3706,14 @@ impl CanvasEditor {
36833706 .get(*index)
36843707 .and_then(page::Content::file)
36853708 .is_some(),
3709 History::Ink {
3710 index,
3711 ink: Some(_),
3712 } => *index <= self.objects.len(),
3713 History::Ink { index, ink: None } => {
3714 matches!(self.objects.get(*index), Some(page::Content::Ink(_)))
3715 }
3716 History::Strokes { ink, .. } => self.has_ink(*ink),
36863717 History::Remove { outline, focus } => {
36873718 self.outlines.iter().any(|item| item.id == *outline)
36883719 && match focus {
......@@ -3917,6 +3948,17 @@ impl CanvasEditor {
39173948 None => Some(Box::new(self.remove_file(index))),
39183949 },
39193950 },
3951 History::Ink { index, ink } => History::Ink {
3952 index,
3953 ink: match ink {
3954 Some(ink) => {
3955 self.add_ink(index, *ink);
3956 None
3957 }
3958 None => Some(Box::new(self.remove_ink(index))),
3959 },
3960 },
3961 History::Strokes { ink, strokes } => self.set_strokes(ink, strokes),
39203962 History::Remove { outline, focus } => {
39213963 let index = self
39223964 .outlines
......@@ -4065,6 +4107,7 @@ impl CanvasEditor {
40654107 .document
40664108 .replace(range.clone(), replacement)?;
40674109 self.own_lists(&mut edit)?;
4110 self.link_notes(&mut edit)?;
40684111 let inverse = self.apply(
40694112 engine,
40704113 edit,
......@@ -4163,6 +4206,7 @@ impl CanvasEditor {
41634206 selection: Selection,
41644207 ) -> Result<(), EditorError> {
41654208 self.own_lists(&mut edit)?;
4209 self.link_notes(&mut edit)?;
41664210 let inverse = self.apply(engine, edit, selection, true, None)?;
41674211 self.record_change(inverse);
41684212 Ok(())
crates/canvas/src/editor/ink.rs created+450
......@@ -0,0 +1,450 @@
1//! Drawing on the page as OneNote 2010's Draw tab does (`corpus/ink-tools`): each stroke and
2//! shape is a drawing of its own, the stroke eraser takes whole strokes, and the lasso picks
3//! drawings to move or delete.
4
5use super::{CanvasEditor, EditError, EditorError, History, Placement, page};
6use onestore::{
7 ExGuid,
8 op::PageOp,
9 page::{Ink, InkStroke, PageObject, ink::snap, text::new_id},
10};
11
12/// A pen or highlighter, as OneNote's pen gallery and Color & Thickness choose one.
13#[derive(Clone, Copy, Debug, PartialEq)]
14pub struct Pen {
15 /// Tip width in HIMETRIC (hundredths of a millimetre); a highlighter's tip is as tall as `HIGHLIGHTER_HEIGHT`.
16 pub width: f32,
17 /// COLORREF; `None` draws in the paper's ink, as OneNote's black pens store it.
18 pub color: Option<u32>,
19 pub highlighter: bool,
20}
21
22/// A highlighter tip's height in HIMETRIC.
23const HIGHLIGHTER_HEIGHT: f32 = 400.0;
24
25impl Pen {
26 pub const fn new(width: f32, color: Option<u32>) -> Self {
27 Self {
28 width,
29 color,
30 highlighter: false,
31 }
32 }
33
34 pub const fn highlighter(color: u32) -> Self {
35 Self {
36 width: 70.0,
37 color: Some(color),
38 highlighter: true,
39 }
40 }
41
42 /// A stroke of this pen through `points`, snapped to the HIMETRIC grid stored.
43 pub fn stroke(&self, points: &[[f32; 2]]) -> Result<InkStroke, EditError> {
44 let points_of = |himetric: f32| snap(himetric * 72.0 / 2540.0);
45 Ok(InkStroke {
46 id: new_id()?,
47 points: points.iter().map(|point| point.map(snap)).collect(),
48 width: points_of(self.width),
49 height: points_of(if self.highlighter {
50 HIGHLIGHTER_HEIGHT
51 } else {
52 self.width
53 }),
54 color: self.color,
55 transparency: self.highlighter.then_some(127),
56 pen_tip: self.highlighter.then_some(1),
57 raster_operation: self.highlighter.then_some(9),
58 })
59 }
60}
61
62/// OneNote 2010's favourite pens, in its gallery's order.
63pub const FAVORITES: [Pen; 14] = {
64 const RED: u32 = 0x241ced;
65 const BLUE: u32 = 0xbb6531;
66 const GREEN: u32 = 0x367d17;
67 const GREY: u32 = 0x808080;
68 [
69 Pen::new(35.0, None),
70 Pen::new(35.0, Some(RED)),
71 Pen::new(35.0, Some(BLUE)),
72 Pen::new(35.0, Some(GREEN)),
73 Pen::new(35.0, Some(GREY)),
74 Pen::highlighter(0x00ffff),
75 Pen::highlighter(0xffff00),
76 Pen::new(50.0, None),
77 Pen::new(50.0, Some(RED)),
78 Pen::new(50.0, Some(BLUE)),
79 Pen::new(50.0, Some(GREEN)),
80 Pen::new(50.0, Some(GREY)),
81 Pen::highlighter(0x00ff00),
82 Pen::highlighter(0xff00ff),
83 ]
84};
85
86impl CanvasEditor {
87 /// Puts `ink`, a stroke or shape just drawn, on top of the page as one undo step.
88 pub fn draw(&mut self, ink: Ink) -> Result<(), EditorError> {
89 if ink.strokes.is_empty() {
90 return Err(EditError::InvalidRange.into());
91 }
92 self.finish_composition();
93 let index = self.objects.len();
94 self.add_ink(index, ink);
95 self.undo.push(History::Ink { index, ink: None });
96 self.redo.clear();
97 Ok(())
98 }
99
100 /// Erases what the eraser touches moving from `from` to `to`, `reach` page points either
101 /// side of its path: a whole drawing where it is a shape, a single stroke or a group, as
102 /// OneNote erases a shape at a touch, and otherwise the strokes touched. With `join` the
103 /// erasing joins the last undo step, which the same sweep of the eraser made. Returns
104 /// whether anything was erased.
105 pub fn erase(
106 &mut self,
107 from: [f32; 2],
108 to: [f32; 2],
109 reach: f32,
110 join: bool,
111 ) -> Result<bool, EditorError> {
112 let mut entries = Vec::new();
113 let mut index = self.objects.len();
114 while index > 0 {
115 index -= 1;
116 let page::Content::Ink(ink) = &self.objects[index] else {
117 continue;
118 };
119 let offset = page::ink_offset(ink);
120 let touched = |stroke: &InkStroke| {
121 let reach = reach + stroke.width.max(stroke.height) / 2.0;
122 let mut points = stroke
123 .points
124 .iter()
125 .map(|p| [p[0] + offset[0], p[1] + offset[1]]);
126 let Some(mut previous) = points.next() else {
127 return false;
128 };
129 if segments_distance([previous, previous], [from, to]) <= reach {
130 return true;
131 }
132 points.any(|point| {
133 let near = segments_distance([previous, point], [from, to]) <= reach;
134 previous = point;
135 near
136 })
137 };
138 let (kept, erased): (Vec<InkStroke>, Vec<InkStroke>) = ink
139 .strokes
140 .iter()
141 .cloned()
142 .partition(|stroke| !touched(stroke));
143 let grouped = ink
144 .groups
145 .iter()
146 .any(|group| group_touched(group, &touched));
147 if erased.is_empty() && !grouped {
148 continue;
149 }
150 if ink.shape.is_some() || !ink.groups.is_empty() || kept.is_empty() {
151 let removed = self.remove_ink(index);
152 entries.push(History::Ink {
153 index,
154 ink: Some(Box::new(removed)),
155 });
156 } else {
157 let id = ink.id;
158 entries.push(self.set_strokes(id, kept));
159 }
160 }
161 if entries.is_empty() {
162 return Ok(false);
163 }
164 self.finish_composition();
165 if join
166 && let Some(History::Group {
167 entries: earlier, ..
168 }) = self.undo.last_mut()
169 {
170 earlier.extend(entries);
171 } else {
172 self.undo.push(History::Group {
173 entries,
174 page: false,
175 });
176 }
177 self.redo.clear();
178 Ok(true)
179 }
180
181 /// The page's drawings, on top first, with most of their points inside `lasso`, a
182 /// closed path in page points.
183 pub fn ink_within(&self, lasso: &[[f32; 2]]) -> Vec<ExGuid> {
184 self.objects
185 .iter()
186 .rev()
187 .filter_map(|object| match object {
188 page::Content::Ink(ink) => {
189 let offset = page::ink_offset(ink);
190 let points = points(ink);
191 let inside = points
192 .iter()
193 .filter(|p| contains(lasso, [p[0] + offset[0], p[1] + offset[1]]))
194 .count();
195 (!points.is_empty() && inside * 2 > points.len()).then_some(ink.id)
196 }
197 _ => None,
198 })
199 .collect()
200 }
201
202 /// The drawing on top whose strokes pass within `reach` of `point`.
203 pub fn ink_at(&self, point: [f32; 2], reach: f32) -> Option<ExGuid> {
204 self.objects.iter().rev().find_map(|object| match object {
205 page::Content::Ink(ink) => {
206 let offset = page::ink_offset(ink);
207 let near = |stroke: &InkStroke| {
208 let reach = reach + stroke.width.max(stroke.height) / 2.0;
209 let mut points = stroke
210 .points
211 .iter()
212 .map(|p| [p[0] + offset[0], p[1] + offset[1]]);
213 let first = points.next();
214 first.is_some_and(|first| {
215 let mut previous = first;
216 segments_distance([first, first], [point, point]) <= reach
217 || points.any(|p| {
218 let near =
219 segments_distance([previous, p], [point, point]) <= reach;
220 previous = p;
221 near
222 })
223 })
224 };
225 (ink.strokes.iter().any(near) || ink.groups.iter().any(|g| group_touched(g, &near)))
226 .then_some(ink.id)
227 }
228 _ => None,
229 })
230 }
231
232 /// The painted extent of drawings `ids`, as `[x0, y0, x1, y1]` page points, while dragged
233 /// where they show.
234 pub fn ink_extent(&self, ids: &[ExGuid]) -> Option<[f32; 4]> {
235 self.objects
236 .iter()
237 .filter_map(|object| match object {
238 page::Content::Ink(ink) if ids.contains(&ink.id) => {
239 let [dx, dy] = self.ink_drag_offset(ink.id);
240 page::ink_bounds(ink)
241 .map(|[x0, y0, x1, y1]| [x0 + dx, y0 + dy, x1 + dx, y1 + dy])
242 }
243 _ => None,
244 })
245 .reduce(|a, b| {
246 [
247 a[0].min(b[0]),
248 a[1].min(b[1]),
249 a[2].max(b[2]),
250 a[3].max(b[3]),
251 ]
252 })
253 }
254
255 /// Shows drawings dragged `delta` from where they lie, without storing it, until the
256 /// drag ends with `move_ink` or `None`.
257 pub fn drag_ink(&mut self, drag: Option<(Vec<ExGuid>, [f32; 2])>) {
258 self.ink_drag = drag;
259 }
260
261 /// How far a drawing shows from where it lies, while dragged.
262 pub(crate) fn ink_drag_offset(&self, id: ExGuid) -> [f32; 2] {
263 match &self.ink_drag {
264 Some((ids, delta)) if ids.contains(&id) => *delta,
265 _ => [0.0; 2],
266 }
267 }
268
269 /// Moves drawings `ids` by `delta` as one undo step, as OneNote moves ink: by the
270 /// drawing's offset, its strokes as they were.
271 pub fn move_ink(&mut self, ids: &[ExGuid], delta: [f32; 2]) -> Result<(), EditorError> {
272 self.ink_drag = None;
273 if delta == [0.0; 2] {
274 return Ok(());
275 }
276 let mut entries = Vec::new();
277 for &id in ids {
278 let Some(layout) = self.ink_layout_mut(id) else {
279 continue;
280 };
281 let stored = [layout.x, layout.y];
282 // Undoing restores a position: a drawing never moved lies at no offset.
283 let position = stored.map(|v| Some(v.unwrap_or(0.0)));
284 [layout.x, layout.y] = [0, 1].map(|axis| Some(position[axis].unwrap() + delta[axis]));
285 self.record(self.placement_ops(&Placement {
286 id,
287 position: stored,
288 }));
289 entries.push(History::Position {
290 object: id,
291 position,
292 });
293 }
294 self.push_ink_step(entries);
295 Ok(())
296 }
297
298 /// Deletes drawings `ids` as one undo step.
299 pub fn delete_ink(&mut self, ids: &[ExGuid]) -> Result<(), EditorError> {
300 let mut entries = Vec::new();
301 let mut index = self.objects.len();
302 while index > 0 {
303 index -= 1;
304 if matches!(&self.objects[index], page::Content::Ink(ink) if ids.contains(&ink.id)) {
305 let ink = self.remove_ink(index);
306 entries.push(History::Ink {
307 index,
308 ink: Some(Box::new(ink)),
309 });
310 }
311 }
312 self.push_ink_step(entries);
313 Ok(())
314 }
315
316 fn push_ink_step(&mut self, entries: Vec<History>) {
317 if entries.is_empty() {
318 return;
319 }
320 self.finish_composition();
321 self.undo.push(History::Group {
322 entries,
323 page: false,
324 });
325 self.redo.clear();
326 }
327
328 fn ink_layout_mut(&mut self, id: ExGuid) -> Option<&mut onestore::document::Layout> {
329 self.objects.iter_mut().find_map(|object| match object {
330 page::Content::Ink(ink) if ink.id == id => Some(&mut ink.layout),
331 _ => None,
332 })
333 }
334
335 /// Puts a drawing on the page at `index` in paint order, recording its op.
336 pub(super) fn add_ink(&mut self, index: usize, ink: Ink) {
337 let object = PageObject::Ink(ink.clone());
338 self.objects.insert(index, page::Content::Ink(ink));
339 let before = self.successor(object.id());
340 self.record(Ok(vec![PageOp::Add { object, before }]));
341 }
342
343 /// Takes the drawing at `index` in paint order off the page, recording its op.
344 pub(super) fn remove_ink(&mut self, index: usize) -> Ink {
345 let page::Content::Ink(ink) = self.objects.remove(index) else {
346 unreachable!("the index holds a drawing")
347 };
348 self.record(Ok(vec![PageOp::Delete { object: ink.id }]));
349 ink
350 }
351
352 /// Gives drawing `id` `strokes`, recording the op that erases the strokes it loses and
353 /// adds those it gains; the entry restoring what it had.
354 pub(super) fn set_strokes(&mut self, id: ExGuid, strokes: Vec<InkStroke>) -> History {
355 let ink = self
356 .objects
357 .iter_mut()
358 .find_map(|object| match object {
359 page::Content::Ink(ink) if ink.id == id => Some(ink),
360 _ => None,
361 })
362 .expect("the drawing is on the page");
363 let known = |stroke: &InkStroke, list: &[InkStroke]| list.iter().any(|s| s.id == stroke.id);
364 let remove = ink
365 .strokes
366 .iter()
367 .filter(|stroke| !known(stroke, &strokes))
368 .map(|stroke| stroke.id)
369 .collect();
370 let (mut kept, add): (Vec<InkStroke>, Vec<InkStroke>) = strokes
371 .into_iter()
372 .partition(|stroke| known(stroke, &ink.strokes));
373 // Strokes a drawing gains follow those it keeps, as the stored list orders them.
374 kept.extend(add.iter().cloned());
375 let previous = std::mem::replace(&mut ink.strokes, kept);
376 self.record(Ok(vec![PageOp::Strokes {
377 ink: id,
378 add,
379 remove,
380 }]));
381 History::Strokes {
382 ink: id,
383 strokes: previous,
384 }
385 }
386
387 pub(super) fn has_ink(&self, id: ExGuid) -> bool {
388 self.objects
389 .iter()
390 .any(|object| matches!(object, page::Content::Ink(ink) if ink.id == id))
391 }
392}
393
394fn group_touched(ink: &Ink, touched: &impl Fn(&InkStroke) -> bool) -> bool {
395 ink.strokes.iter().any(touched) || ink.groups.iter().any(|group| group_touched(group, touched))
396}
397
398fn points(ink: &Ink) -> Vec<[f32; 2]> {
399 ink.strokes
400 .iter()
401 .flat_map(|stroke| stroke.points.iter().copied())
402 .chain(ink.groups.iter().flat_map(points))
403 .collect()
404}
405
406/// Whether `point` lies inside the closed path `polygon` (even-odd).
407fn contains(polygon: &[[f32; 2]], point: [f32; 2]) -> bool {
408 let mut inside = false;
409 let mut previous = match polygon.last() {
410 Some(last) => *last,
411 None => return false,
412 };
413 for &current in polygon {
414 if (current[1] > point[1]) != (previous[1] > point[1])
415 && point[0]
416 < (previous[0] - current[0]) * (point[1] - current[1]) / (previous[1] - current[1])
417 + current[0]
418 {
419 inside = !inside;
420 }
421 previous = current;
422 }
423 inside
424}
425
426/// The least distance between segments `a` and `b`.
427fn segments_distance(a: [[f32; 2]; 2], b: [[f32; 2]; 2]) -> f32 {
428 let cross = |o: [f32; 2], p: [f32; 2], q: [f32; 2]| {
429 (p[0] - o[0]) * (q[1] - o[1]) - (p[1] - o[1]) * (q[0] - o[0])
430 };
431 let [d1, d2] = [cross(b[0], b[1], a[0]), cross(b[0], b[1], a[1])];
432 let [d3, d4] = [cross(a[0], a[1], b[0]), cross(a[0], a[1], b[1])];
433 if d1 * d2 < 0.0 && d3 * d4 < 0.0 {
434 return 0.0;
435 }
436 let to_segment = |p: [f32; 2], [s, e]: [[f32; 2]; 2]| {
437 let [dx, dy] = [e[0] - s[0], e[1] - s[1]];
438 let length = dx * dx + dy * dy;
439 let t = if length > 0.0 {
440 (((p[0] - s[0]) * dx + (p[1] - s[1]) * dy) / length).clamp(0.0, 1.0)
441 } else {
442 0.0
443 };
444 (p[0] - s[0] - t * dx).hypot(p[1] - s[1] - t * dy)
445 };
446 to_segment(a[0], b)
447 .min(to_segment(a[1], b))
448 .min(to_segment(b[0], a))
449 .min(to_segment(b[1], a))
450}
crates/canvas/src/editor/recording.rs created+284
......@@ -0,0 +1,284 @@
1//! Record Audio as OneNote 2010 records (`corpus/recording`): starting splits the caret's
2//! paragraph around a line saying when, in the page's `cite` style and linked to the start;
3//! text written while recording links to the moment it was written; the finished file takes
4//! its place after the caret's paragraph.
5
6use super::*;
7use onestore::page::{Attachment, MediaIndex};
8use std::time::Instant;
9
10/// A recording in progress.
11pub(super) struct Live {
12 id: [u8; 16],
13 started: Instant,
14 /// The blank paragraph before the line saying when, which the file goes before.
15 place: ExGuid,
16}
17
18/// OneNote 2010's `cite` quick style: Calibri 9 in dark grey.
19fn cite() -> Definition {
20 Definition {
21 kind: Kind::Style {
22 name: Some("cite".into()),
23 },
24 format: Format {
25 bold: Some(false),
26 italic: Some(false),
27 underline: Some(false),
28 strike: Some(false),
29 superscript: Some(false),
30 subscript: Some(false),
31 font: Some("Calibri".into()),
32 font_size: Some(9.0),
33 color: Some(0x595959),
34 highlight: Some(0xff00_0000),
35 space_before: Some(0.0),
36 space_after: Some(0.0),
37 line_spacing: Some(0.0),
38 ..Format::default()
39 },
40 }
41}
42
43impl CanvasEditor {
44 /// The recording that text written now links to.
45 pub fn recording(&self) -> Option<[u8; 16]> {
46 self.recording.as_ref().map(|live| live.id)
47 }
48
49 /// Starts a recording at the caret, returning its identity: the caret's paragraph splits
50 /// there around a blank line, `label` (saying when, in the `cite` style, linked to the
51 /// start) and the blank paragraph the caret starts. An empty first half is the blank
52 /// line; a caret in the title records at the body's start.
53 pub fn start_recording(
54 &mut self,
55 engine: &mut TextEngine,
56 label: &str,
57 ) -> Result<[u8; 16], EditorError> {
58 if self.recording.is_some() {
59 return Err(EditError::InvalidRange.into());
60 }
61 let id = onestore::page::text::new_guid()?;
62 self.leave_title(engine)?;
63 let [anchor, focus] = self.selection().positions;
64 let (start, end) = (anchor.min(focus), anchor.max(focus));
65 let mut format = self.typing_format(start)?;
66 link::unlinked(&mut format);
67 let language = format.language;
68 let head_empty = start.offset == 0;
69 let parts = if head_empty { 3 } else { 4 };
70 let mut edit = self.active_outline().document.replace(
71 start..end,
72 vec![Paragraph::new(String::new(), format); parts],
73 )?;
74 let style = match self.definitions.iter().find(|(_, definition)| {
75 matches!(&definition.kind, Kind::Style { name: Some(name) } if name == "cite")
76 }) {
77 Some((style, _)) => *style,
78 None => {
79 let style = onestore::page::text::new_id()?;
80 self.definitions.insert(style, cite());
81 style
82 }
83 };
84 if head_empty {
85 // The caret's text, now the last part, keeps its tags and link, as Attach File's does.
86 let [head, .., tail] = &mut edit.replacement[..parts] else {
87 unreachable!("a split holds both halves")
88 };
89 tail.tags = std::mem::take(&mut head.tags);
90 tail.text_mut().unwrap().tags = std::mem::take(&mut head.text_mut().unwrap().tags);
91 tail.media = std::mem::take(&mut head.media);
92 }
93 let place = edit.replacement[parts - 3].id;
94 let line = &mut edit.replacement[parts - 2];
95 line.style = Some(style);
96 line.lists.clear();
97 line.media = MediaIndex {
98 recordings: vec![id],
99 time_ms: Some(0),
100 };
101 // The line's text reads back in its style's look.
102 let look = Format {
103 language,
104 ..self.definitions[&style].format.clone()
105 };
106 line.text_mut().unwrap().text = Paragraph::new(label.to_owned(), look);
107 let caret = TextPosition {
108 paragraph: start.paragraph + parts - 1,
109 offset: 0,
110 };
111 self.commit(
112 engine,
113 edit,
114 Selection {
115 positions: [caret; 2],
116 affinities: [Affinity::Upstream; 2],
117 },
118 )?;
119 self.recording = Some(Live {
120 id,
121 started: Instant::now(),
122 place,
123 });
124 Ok(id)
125 }
126
127 /// Stops recording: `file`, the recording made, goes where it started, linked to its
128 /// start, or at the caret when that place is gone.
129 pub fn finish_recording(
130 &mut self,
131 engine: &mut TextEngine,
132 mut file: Attachment,
133 ) -> Result<(), EditorError> {
134 let Some(live) = self.recording.take() else {
135 return Err(EditError::InvalidRange.into());
136 };
137 if file
138 .recording
139 .is_none_or(|recording| recording.id != live.id)
140 {
141 return Err(EditError::InvalidStructure.into());
142 }
143 let place = self.outlines.iter().find_map(|outline| {
144 descendants(outline.document.nodes(), None).find_map(|(container, index, node)| {
145 (node.id == live.place).then(|| (outline.id, container, index, node.clone()))
146 })
147 });
148 let Some((outline, container, index, place)) = place else {
149 return self.insert_attachment(engine, file);
150 };
151 file.layout = Default::default();
152 let node = PageParagraph {
153 id: onestore::page::text::new_id()?,
154 content: ParagraphContent::Attachment(file),
155 style: None,
156 lists: Vec::new(),
157 tags: Vec::new(),
158 collapsed: false,
159 media: MediaIndex {
160 recordings: vec![live.id],
161 time_ms: Some(0),
162 },
163 ..place
164 };
165 self.focus_outline(outline)?;
166 let selection = self.selection();
167 self.commit(
168 engine,
169 DocumentEdit {
170 columns: BTreeMap::new(),
171 container,
172 range: index..index,
173 replacement: vec![node],
174 },
175 selection,
176 )
177 }
178
179 /// Links text `edit` writes while recording to the moment it is written, as OneNote
180 /// links notes taken then; blank paragraphs and those already linked stay as they are.
181 pub(super) fn link_notes(&self, edit: &mut DocumentEdit) -> Result<(), EditError> {
182 let Some(live) = &self.recording else {
183 return Ok(());
184 };
185 let nodes = &self.active_outline().document.container(edit.container)?[edit.range.clone()];
186 let before: BTreeMap<ExGuid, &str> = descendants(nodes, None)
187 .filter_map(|(_, _, node)| Some((node.id, node.text()?.text.text())))
188 .collect();
189 let time = u32::try_from(live.started.elapsed().as_millis()).unwrap_or(u32::MAX);
190 let mut pending: Vec<&mut PageParagraph> = edit.replacement.iter_mut().collect();
191 while let Some(node) = pending.pop() {
192 let written = node.text().is_some_and(|text| {
193 !text.text.text().is_empty() && before.get(&node.id) != Some(&text.text.text())
194 });
195 if written && node.media == MediaIndex::default() {
196 node.media = MediaIndex {
197 recordings: vec![live.id],
198 time_ms: Some(time),
199 };
200 }
201 if let ParagraphContent::Table(table) = &mut node.content {
202 pending.extend(
203 table
204 .rows
205 .iter_mut()
206 .flat_map(|row| &mut row.cells)
207 .flat_map(|cell| &mut cell.paragraphs),
208 );
209 }
210 }
211 Ok(())
212 }
213}
214
215/// A play button's side, and how far its right edge stands before the text it plays from,
216/// in points, as OneNote 2010 draws it at 100%.
217const BUTTON: [f32; 2] = [10.5, 24.0];
218
219impl CanvasEditor {
220 /// The recording `id` names, in an outline or on the page.
221 pub fn recording_file(&self, id: [u8; 16]) -> Option<&Attachment> {
222 let named = |file: &&Attachment| file.recording.is_some_and(|r| r.id == id);
223 self.objects
224 .iter()
225 .filter_map(|object| object.file().map(|(file, _)| file))
226 .find(named)
227 .or_else(|| {
228 self.visible_outlines()
229 .flat_map(|outline| descendants(outline.document.nodes(), None))
230 .filter_map(|(_, _, node)| match &node.content {
231 ParagraphContent::Attachment(file) => Some(file),
232 _ => None,
233 })
234 .find(named)
235 })
236 }
237
238 /// The play button OneNote shows for document point `point`: beside the linked note or
239 /// the recording level with it, over its outline or in the margin before it. Gives the
240 /// button's square, the recording and the moment it plays from.
241 pub fn play_button(&self, point: [f32; 2]) -> Option<([f32; 4], [u8; 16], u32)> {
242 let [x, y] = point;
243 let outline = self.visible_outlines().find(|outline| {
244 let bounds = outline.bounds();
245 (bounds.x0 as f32 - BUTTON[1] - BUTTON[0]..=bounds.x1 as f32).contains(&x)
246 && (bounds.y0 as f32..=bounds.y1 as f32).contains(&y)
247 })?;
248 let [left, top] = outline.origin();
249 let square = |text_left: f32, line_top: f32, height: f32| {
250 let x1 = left + text_left - BUTTON[1];
251 let y0 = top + line_top + (height - BUTTON[0]) / 2.0;
252 [x1 - BUTTON[0], y0, x1, y0 + BUTTON[0]]
253 };
254 let shaped = outline.shaped();
255 let (rect, recording, time) = if let Some(paragraph) = shaped
256 .paragraphs
257 .iter()
258 .find(|p| (top + p.origin[1]..=top + p.origin[1] + p.text.height()).contains(&y))
259 {
260 let (_, _, node) = descendants(outline.document.nodes(), None)
261 .find(|(_, _, node)| node.id == paragraph.id)?;
262 let line = paragraph
263 .text
264 .lines()
265 .next()
266 .map_or(paragraph.text.height(), |(_, line)| line.height);
267 (
268 square(paragraph.origin[0], paragraph.origin[1], line),
269 *node.media.recordings.first()?,
270 node.media.time_ms?,
271 )
272 } else {
273 let object = shaped.objects.iter().find(|object| {
274 matches!(object.kind, crate::outline::ObjectKind::File(_))
275 && (top + object.rect[1]..=top + object.bottom).contains(&y)
276 })?;
277 let [x0, y0, _, y1] = object.rect;
278 let recording = self.attachment(object.id)?.recording?.id;
279 (square(x0, y0, y1 - y0), recording, 0)
280 };
281 self.recording_file(recording)?;
282 Some((rect, recording, time))
283 }
284}
crates/canvas/src/gpu/page.rs+68-29
......@@ -96,12 +96,46 @@ fn outline_origin(
9696 Ok(origin)
9797}
9898
99fn append_ink(ink: &onestore::page::Ink, offset: [f32; 2], primitives: &mut Vec<Primitive<'_>>) {
99/// Draws `ink` moved by `offset`, strokes without a colour in the paper's `automatic` ink. A
100/// highlighter's rectangular tip sweeps a band as wide as the tip reaches across each
101/// segment, its colour multiplying what lies beneath as OneNote's MaskPen does.
102pub(crate) fn append_ink(
103 ink: &onestore::page::Ink,
104 offset: [f32; 2],
105 automatic: [f32; 4],
106 primitives: &mut Vec<Primitive<'_>>,
107) {
100108 for stroke in &ink.strokes {
101 let mut color = colorref(stroke.color.unwrap_or(0));
102 color[3] = 1.0 - f32::from(stroke.transparency.unwrap_or(0)) / 255.0;
103 let width = stroke.width.max(stroke.height);
109 let highlighter = stroke.raster_operation == Some(9);
110 let mut color = stroke.color.map_or(automatic, colorref);
111 if !highlighter {
112 color[3] = 1.0 - f32::from(stroke.transparency.unwrap_or(0)) / 255.0;
113 }
104114 let round = stroke.pen_tip != Some(1);
115 let segment = |from: [f32; 2], to: [f32; 2]| {
116 if !highlighter {
117 return Primitive::Segment {
118 from,
119 to,
120 width: stroke.width.max(stroke.height),
121 round,
122 color,
123 };
124 }
125 let [dx, dy] = [to[0] - from[0], to[1] - from[1]];
126 let length = dx.hypot(dy);
127 let [along, across] = if length > 0.0 {
128 [dx.abs() / length, dy.abs() / length]
129 } else {
130 [1.0, 0.0]
131 };
132 Primitive::Highlight {
133 from,
134 to,
135 width: stroke.width * across + stroke.height * along,
136 color,
137 }
138 };
105139 let mut points = stroke
106140 .points
107141 .iter()
......@@ -109,37 +143,35 @@ fn append_ink(ink: &onestore::page::Ink, offset: [f32; 2], primitives: &mut Vec<
109143 let Some(mut from) = points.next() else {
110144 continue;
111145 };
112 let mut drawn = false;
146 // Samples along a straight run make one segment, since each joint paints its overlap
147 // twice.
148 let mut end = from;
113149 for to in points {
114 // Pen samples far closer than a pixel add vertices without changing the stroke.
115 if (to[0] - from[0]).hypot(to[1] - from[1]) < 0.2 {
116 continue;
150 if !between(from, end, to) {
151 primitives.push(segment(from, end));
152 from = end;
117153 }
118 primitives.push(Primitive::Segment {
119 from,
120 to,
121 width,
122 round,
123 color,
124 });
125 from = to;
126 drawn = true;
127 }
128 if !drawn {
129 primitives.push(Primitive::Segment {
130 from,
131 to: from,
132 width,
133 round,
134 color,
135 });
154 end = to;
136155 }
156 primitives.push(segment(from, end));
137157 }
138158 for group in &ink.groups {
139 append_ink(group, offset, primitives);
159 append_ink(group, offset, automatic, primitives);
140160 }
141161}
142162
163/// Whether `middle` lies within a tenth of a point of the segment from `from` to `to`.
164fn between(from: [f32; 2], middle: [f32; 2], to: [f32; 2]) -> bool {
165 let [dx, dy] = [to[0] - from[0], to[1] - from[1]];
166 let [mx, my] = [middle[0] - from[0], middle[1] - from[1]];
167 let length = dx * dx + dy * dy;
168 if length == 0.0 {
169 return mx.hypot(my) <= 0.1;
170 }
171 let along = (mx * dx + my * dy) / length;
172 (0.0..=1.0).contains(&along) && (mx * dy - my * dx).abs() / length.sqrt() <= 0.1
173}
174
143175const FILE_ICON: &str = include_str!("../../assets/file.svg");
144176
145177/// The blank page for a file without an icon of its own, as the 32 pixel PNG OneNote 2010
......@@ -501,7 +533,9 @@ impl PageScene {
501533 primitives.push(Primitive::Image { image, rect });
502534 }
503535 match &object.kind {
504 crate::outline::ObjectKind::Ink(ink) => append_ink(ink, [rect[0], rect[1]], primitives),
536 crate::outline::ObjectKind::Ink(ink) => {
537 append_ink(ink, [rect[0], rect[1]], paper.ink, primitives)
538 }
505539 crate::outline::ObjectKind::Unsupported(_) => {
506540 append_placeholder(rect, paper, primitives)
507541 }
......@@ -801,7 +835,9 @@ impl PageScene {
801835 }
802836 Content::Ink(ink) => {
803837 let [x, y] = crate::editor::page::ink_offset(ink);
804 append_ink(ink, [offset[0] + x, offset[1] + y], primitives);
838 let [dx, dy] = editor.map_or([0.0; 2], |editor| editor.ink_drag_offset(ink.id));
839 let offset = [offset[0] + x + dx, offset[1] + y + dy];
840 append_ink(ink, offset, paper.ink, primitives);
805841 continue;
806842 }
807843 Content::File { source, layout } => {
......@@ -2217,6 +2253,7 @@ mod tests {
22172253 color,
22182254 transparency: Some(51),
22192255 pen_tip: None,
2256 raster_operation: None,
22202257 };
22212258 let ink = Ink {
22222259 id: ExGuid::default(),
......@@ -2228,7 +2265,9 @@ mod tests {
22282265 layout: Layout::default(),
22292266 strokes: vec![stroke(vec![[40.0, 30.0]], Some(0x0000ff))],
22302267 groups: Vec::new(),
2268 shape: None,
22312269 }],
2270 shape: None,
22322271 };
22332272 let page = Page {
22342273 identity: None,
crates/canvas/src/interaction/ink.rs created+326
......@@ -0,0 +1,326 @@
1//! The Draw tab's tools on the page: pens and highlighters drawing a stroke per press, shapes
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`).
4
5use super::{PageView, Response, Result, snap_to_grid};
6use crate::editor::Pen;
7use crate::gpu::colorref;
8use draw::{
9 Primitive, Stroke,
10 edit::{Key, NamedKey},
11};
12use onestore::{
13 ExGuid,
14 page::{Ink, InkStroke, ink::ShapeKind, text::new_id},
15};
16
17/// What a press on the page does.
18#[derive(Clone, Copy, Debug, Default, PartialEq)]
19pub enum Tool {
20 /// Select & Type: text, and drawings picked by a click.
21 #[default]
22 Select,
23 /// A stroke per press.
24 Pen(Pen),
25 /// A shape dragged out, its corners on the grid unless Option is held.
26 Shape(ShapeKind, Pen),
27 /// The stroke eraser.
28 Eraser,
29 Lasso,
30}
31
32#[derive(Default)]
33pub(super) struct State {
34 tool: Tool,
35 gesture: Option<Gesture>,
36 /// Drawings picked by the lasso or a click.
37 selection: Vec<ExGuid>,
38}
39
40enum Gesture {
41 Stroke(Vec<[f32; 2]>),
42 Shape([f32; 2]),
43 Erase { last: [f32; 2], erased: bool },
44 Lasso(Vec<[f32; 2]>),
45 Move { press: [f32; 2] },
46}
47
48/// The eraser's reach either side of its path, in logical pixels.
49const ERASER_REACH: f32 = 4.0;
50
51impl PageView {
52 pub fn tool(&self) -> Tool {
53 self.ink.tool
54 }
55
56 /// Picks what presses on the page do, as the Draw tab's tools do; the lasso's selection
57 /// goes.
58 pub fn set_tool(&mut self, tool: Tool) -> Response {
59 self.ink.tool = tool;
60 self.leave_ink();
61 Response::redraw()
62 }
63
64 /// Drawings the lasso or a click picked.
65 pub fn ink_selection(&self) -> &[ExGuid] {
66 &self.ink.selection
67 }
68
69 /// Ends a gesture unfinished, as a lost focus does.
70 pub(super) fn end_ink(&mut self) {
71 self.ink.gesture = None;
72 self.editor.drag_ink(None);
73 }
74
75 /// Another page shows: the gesture ends and nothing stays picked; the tool stays.
76 pub(super) fn leave_ink(&mut self) {
77 self.end_ink();
78 self.ink.selection.clear();
79 }
80
81 /// Forgets picked drawings the page no longer holds, after a change made elsewhere. A
82 /// stroke, shape or lasso under way goes on; a drag of drawings ends, and the eraser's
83 /// next touch starts an undo step of its own.
84 pub(super) fn refresh_ink(&mut self) {
85 match &mut self.ink.gesture {
86 Some(Gesture::Move { .. }) => self.ink.gesture = None,
87 Some(Gesture::Erase { erased, .. }) => *erased = false,
88 _ => {}
89 }
90 let editor = &self.editor;
91 self.ink
92 .selection
93 .retain(|id| editor.ink_extent(std::slice::from_ref(id)).is_some());
94 }
95
96 fn reach(&self) -> f32 {
97 ERASER_REACH * self.pixel()
98 }
99
100 /// A press at page point `point`, when a drawing tool takes it or it picks ink.
101 pub(super) fn ink_pressed(&mut self, point: [f32; 2]) -> Result<Option<Response>> {
102 let selected = !self.ink.selection.is_empty()
103 && self
104 .editor
105 .ink_extent(&self.ink.selection)
106 .is_some_and(|[x0, y0, x1, y1]| {
107 (x0..=x1).contains(&point[0]) && (y0..=y1).contains(&point[1])
108 });
109 let gesture = match self.ink.tool {
110 Tool::Select | Tool::Lasso if selected => Gesture::Move { press: point },
111 Tool::Select => {
112 self.ink.selection.clear();
113 if self.hit_test(point).is_some() {
114 return Ok(None);
115 }
116 let Some(id) = self.editor.ink_at(point, self.reach()) else {
117 return Ok(None);
118 };
119 self.set_object_focus(None);
120 self.ink.selection = vec![id];
121 Gesture::Move { press: point }
122 }
123 Tool::Pen(_) => Gesture::Stroke(vec![point]),
124 Tool::Shape(..) => Gesture::Shape(self.grid(point)),
125 Tool::Eraser => {
126 let erased = self.editor.erase(point, point, self.reach(), false)?;
127 Gesture::Erase {
128 last: point,
129 erased,
130 }
131 }
132 Tool::Lasso => {
133 self.ink.selection.clear();
134 Gesture::Lasso(vec![point])
135 }
136 };
137 self.drag = None;
138 self.ink.gesture = Some(gesture);
139 Ok(Some(self.changed()?))
140 }
141
142 fn grid(&self, point: [f32; 2]) -> [f32; 2] {
143 if self.modifiers.option {
144 point
145 } else {
146 snap_to_grid(point, self.editor.margin_origin())
147 }
148 }
149
150 /// The pointer moved to page point `point` during a gesture.
151 pub(super) fn ink_moved(&mut self, point: [f32; 2]) -> Result<Response> {
152 match &mut self.ink.gesture {
153 Some(Gesture::Stroke(points) | Gesture::Lasso(points)) => {
154 let last = points[points.len() - 1];
155 // Samples closer than a HIMETRIC unit store as one point.
156 if (point[0] - last[0]).hypot(point[1] - last[1]) >= 72.0 / 2540.0 {
157 points.push(point);
158 }
159 Ok(Response::redraw())
160 }
161 Some(Gesture::Erase { last, erased }) => {
162 let from = std::mem::replace(last, point);
163 let join = *erased;
164 if self.editor.erase(from, point, self.reach(), join)? {
165 if let Some(Gesture::Erase { erased, .. }) = &mut self.ink.gesture {
166 *erased = true;
167 }
168 return self.changed();
169 }
170 Ok(Response::default())
171 }
172 Some(Gesture::Move { press }) => {
173 let delta = [point[0] - press[0], point[1] - press[1]];
174 self.editor
175 .drag_ink(Some((self.ink.selection.clone(), delta)));
176 Ok(Response::redraw())
177 }
178 Some(Gesture::Shape(_)) => Ok(Response::redraw()),
179 None => Ok(Response::default()),
180 }
181 }
182
183 /// The press ended at page point `point`, finishing the gesture.
184 pub(super) fn ink_released(&mut self, point: [f32; 2]) -> Result<Response> {
185 let Some(gesture) = self.ink.gesture.take() else {
186 return Ok(Response::default());
187 };
188 match gesture {
189 Gesture::Stroke(points) => {
190 let Tool::Pen(pen) = self.ink.tool else {
191 return Ok(Response::default());
192 };
193 self.editor.draw(Ink {
194 id: new_id()?,
195 layout: Default::default(),
196 strokes: vec![pen.stroke(&points)?],
197 groups: Vec::new(),
198 shape: None,
199 })?;
200 }
201 Gesture::Shape(from) => {
202 let Tool::Shape(kind, pen) = self.ink.tool else {
203 return Ok(Response::default());
204 };
205 let to = self.grid(point);
206 if from == to {
207 return Ok(Response::redraw());
208 }
209 self.editor
210 .draw(Ink::drawn(kind, from, to, &pen.stroke(&[])?)?)?;
211 }
212 Gesture::Erase { .. } => return Ok(Response::redraw()),
213 Gesture::Lasso(mut points) => {
214 points.push(point);
215 self.ink.selection = self.editor.ink_within(&points);
216 return Ok(Response::redraw());
217 }
218 Gesture::Move { press } => {
219 let delta = [point[0] - press[0], point[1] - press[1]];
220 // As OneNote does, a drawing dropped outside the view stays where it was.
221 let inside = (0..2).all(|axis| {
222 (0.0..self.viewport.size[axis] as f32).contains(&self.pointer[axis])
223 });
224 let selection = self.ink.selection.clone();
225 self.editor
226 .move_ink(&selection, if inside { delta } else { [0.0; 2] })?;
227 }
228 }
229 self.changed()
230 }
231
232 /// Delete and Backspace delete picked drawings; Escape lets them go, then leaves a
233 /// drawing tool for Select & Type, as OneNote's Escape does.
234 pub(super) fn ink_key(&mut self, key: &Key) -> Result<Option<Response>> {
235 match key {
236 Key::Named(NamedKey::Backspace | NamedKey::Delete)
237 if !self.ink.selection.is_empty() =>
238 {
239 let selection = std::mem::take(&mut self.ink.selection);
240 self.editor.delete_ink(&selection)?;
241 Ok(Some(self.changed()?))
242 }
243 Key::Named(NamedKey::Escape) if !self.ink.selection.is_empty() => {
244 self.ink.selection.clear();
245 Ok(Some(Response::redraw()))
246 }
247 Key::Named(NamedKey::Escape) if self.ink.tool != Tool::Select => {
248 Ok(Some(self.set_tool(Tool::Select)))
249 }
250 _ => {
251 self.ink.selection.clear();
252 Ok(None)
253 }
254 }
255 }
256
257 /// Whether a press on the page began a gesture of the drawing tools.
258 pub(super) fn inking(&self) -> bool {
259 self.ink.gesture.is_some()
260 }
261
262 /// Whether a drawing tool takes presses, which then show a crosshair.
263 pub(super) fn drawing(&self) -> bool {
264 self.ink.tool != Tool::Select
265 }
266
267 /// The gesture under way and the picked drawings' frame, over the page.
268 pub(super) fn append_ink_feedback(
269 &self,
270 automatic: [f32; 4],
271 primitives: &mut Vec<Primitive<'_>>,
272 ) {
273 let pixel = self.pixel();
274 let point = self.viewport.document_point(self.pointer);
275 let preview = |ink: Ink, primitives: &mut Vec<Primitive<'_>>| {
276 crate::gpu::page::append_ink(&ink, [0.0; 2], automatic, primitives)
277 };
278 match (&self.ink.gesture, self.ink.tool) {
279 (Some(Gesture::Stroke(points)), Tool::Pen(pen)) => {
280 if let Ok(stroke) = pen.stroke(points) {
281 preview(ink(vec![stroke]), primitives);
282 }
283 }
284 (Some(Gesture::Shape(from)), Tool::Shape(kind, pen)) => {
285 let to = self.grid(point);
286 if let Ok(pen) = pen.stroke(&[])
287 && let Ok(shape) = Ink::drawn(kind, *from, to, &pen)
288 {
289 preview(shape, primitives);
290 }
291 }
292 (Some(Gesture::Lasso(points)), _) => {
293 let lasso = colorref(0x00808080);
294 for pair in points.windows(2) {
295 primitives.push(Primitive::Segment {
296 from: pair[0],
297 to: pair[1],
298 width: pixel,
299 round: true,
300 color: lasso,
301 });
302 }
303 }
304 _ => {}
305 }
306 if let Some([x0, y0, x1, y1]) = self.editor.ink_extent(&self.ink.selection) {
307 let pad = 3.0 * pixel;
308 primitives.push(Primitive::RoundedRect {
309 rect: [x0 - pad, y0 - pad, x1 + pad, y1 + pad],
310 radius: [0.0; 2],
311 stroke: Some(Stroke::Dashed(pixel)),
312 color: colorref(0x00e0a060),
313 });
314 }
315 }
316}
317
318fn ink(strokes: Vec<InkStroke>) -> Ink {
319 Ink {
320 id: ExGuid::default(),
321 layout: Default::default(),
322 strokes,
323 groups: Vec::new(),
324 shape: None,
325 }
326}
crates/canvas/src/interaction/mod.rs+106-1
......@@ -3,6 +3,7 @@
33//! translate their platform's events into these calls and carry out the returned requests.
44
55pub mod accessibility;
6pub mod ink;
67#[cfg(test)]
78mod profile;
89mod scroll;
......@@ -47,6 +48,8 @@ pub enum Cursor {
4748 RowResize,
4849 /// Insert Space's line down the page.
4950 ColResize,
51 /// A drawing tool's.
52 Crosshair,
5053}
5154
5255/// Work only the host can do, asked for by an event.
......@@ -62,6 +65,11 @@ pub enum Request {
6265 OpenLink(String),
6366 /// Open a copy of a file with the system's application for it, as a double click does.
6467 OpenAttachment(onestore::page::Attachment),
68 /// Play recording `file` from `at_ms`, as a note's play button does.
69 Play {
70 file: onestore::page::Attachment,
71 at_ms: u32,
72 },
6573}
6674
6775/// What a secondary press lands on, which its context menu offers commands for.
......@@ -235,6 +243,7 @@ pub struct PageView {
235243 caret: f32,
236244 /// When the caret last moved, which restarts its blink.
237245 blink_from: Instant,
246 ink: ink::State,
238247}
239248
240249/// Room, in OneNote pixels, the view leaves beyond content it scrolls to.
......@@ -293,6 +302,7 @@ impl PageView {
293302 found: Vec::new(),
294303 caret: 1.0,
295304 blink_from: Instant::now(),
305 ink: Default::default(),
296306 };
297307 view.place_opened();
298308 view
......@@ -306,6 +316,7 @@ impl PageView {
306316 self.space = false;
307317 self.object_focus = None;
308318 self.found.clear();
319 self.leave_ink();
309320 }
310321
311322 /// Shows the stored page after a change made elsewhere in place of the one shown,
......@@ -320,6 +331,7 @@ impl PageView {
320331 }
321332 self.drag = None;
322333 self.object_focus = None;
334 self.refresh_ink();
323335 self.moved()
324336 }
325337
......@@ -858,6 +870,15 @@ impl PageView {
858870 found: &self.found,
859871 },
860872 )?);
873 if self.drag.is_none()
874 && self.pointer_inside
875 && !self.space
876 && let Some((button, ..)) = self
877 .editor
878 .play_button(self.viewport.document_point(self.pointer))
879 {
880 append_play_button(button, colors.paper, &mut primitives);
881 }
861882 if let Some(axis) = self
862883 .space_axis()
863884 .filter(|_| self.pointer_inside || self.drag.is_some())
......@@ -879,6 +900,7 @@ impl PageView {
879900 &mut primitives,
880901 );
881902 }
903 self.append_ink_feedback(colors.paper.ink, &mut primitives);
882904 Ok(primitives)
883905 }
884906
......@@ -887,7 +909,24 @@ impl PageView {
887909 if let Some(axis) = self.space_axis() {
888910 return [Cursor::ColResize, Cursor::RowResize][axis];
889911 }
890 let hit = self.hit_test(self.viewport.document_point(self.pointer));
912 let point = self.viewport.document_point(self.pointer);
913 if self
914 .editor
915 .ink_extent(self.ink_selection())
916 .is_some_and(|[x0, y0, x1, y1]| {
917 (x0..=x1).contains(&point[0]) && (y0..=y1).contains(&point[1])
918 })
919 {
920 return Cursor::Move;
921 }
922 if self.drawing() {
923 return Cursor::Crosshair;
924 }
925 let point = self.viewport.document_point(self.pointer);
926 if self.drag.is_none() && self.play_button(point).is_some() {
927 return Cursor::Pointer;
928 }
929 let hit = self.hit_test(point);
891930 match (&self.drag, hit) {
892931 (Some(Drag::Image { handle, .. }), _) => handle_cursor(*handle),
893932 (None, Some(Hit::Image { handle, .. })) => handle_cursor(handle),
......@@ -939,6 +978,7 @@ impl PageView {
939978 if !focused {
940979 self.drag = None;
941980 self.space = false;
981 self.end_ink();
942982 }
943983 self.changed()
944984 }
......@@ -960,6 +1000,9 @@ impl PageView {
9601000 pub fn pointer_moved(&mut self, position: [f32; 2]) -> Result<Response> {
9611001 self.pointer_inside = true;
9621002 self.pointer = position;
1003 if self.inking() {
1004 return self.ink_moved(self.viewport.document_point(position));
1005 }
9631006 match &mut self.drag {
9641007 Some(Drag::Text { anchor, unit }) => {
9651008 let (anchor, unit) = (*anchor, *unit);
......@@ -1019,6 +1062,13 @@ impl PageView {
10191062 });
10201063 return Ok(Response::redraw());
10211064 }
1065 if let Some((file, at_ms)) = self.play_button(point) {
1066 self.drag = None;
1067 return Ok(Response::request(Request::Play { file, at_ms }));
1068 }
1069 if let Some(response) = self.ink_pressed(point)? {
1070 return Ok(response);
1071 }
10221072 let unit = self
10231073 .clicks
10241074 .press(now, self.pointer, 4.0 * self.display_scale);
......@@ -1143,6 +1193,9 @@ impl PageView {
11431193 }
11441194
11451195 pub fn pointer_released(&mut self) -> Result<Response> {
1196 if self.inking() {
1197 return self.ink_released(self.viewport.document_point(self.pointer));
1198 }
11461199 if let Some(Drag::Space { axis, line }) = self.drag {
11471200 self.drag = None;
11481201 let delta = self.viewport.document_point(self.pointer)[axis] - line;
......@@ -1436,6 +1489,39 @@ impl PageView {
14361489 self.edited()
14371490 }
14381491
1492 /// Record Audio: see [`CanvasEditor::start_recording`]; none where text cannot go.
1493 pub fn start_recording(&mut self, label: &str) -> Result<(Option<[u8; 16]>, Response)> {
1494 if !self.accepts_text() || self.editor.marked_range().is_some() {
1495 return Ok((None, Response::default()));
1496 }
1497 let id = self.editor.start_recording(&mut self.engine, label)?;
1498 Ok((Some(id), self.edited()?))
1499 }
1500
1501 /// Stop: see [`CanvasEditor::finish_recording`]. A file without an icon takes the
1502 /// page's blank one.
1503 pub fn finish_recording(&mut self, mut file: onestore::page::Attachment) -> Result<Response> {
1504 file.preview
1505 .get_or_insert_with(|| crate::gpu::page::file_icon().into());
1506 self.editor.finish_composition();
1507 self.editor.finish_recording(&mut self.engine, file)?;
1508 self.follow_pictures()?;
1509 self.edited()
1510 }
1511
1512 /// The recording a press at document point `point` plays and the moment it plays from:
1513 /// that of a play button OneNote shows beside a linked note or a recording.
1514 fn play_button(&self, point: [f32; 2]) -> Option<(onestore::page::Attachment, u32)> {
1515 if !self.pointer_inside || self.space {
1516 return None;
1517 }
1518 let pointer = self.viewport.document_point(self.pointer);
1519 let ([x0, y0, x1, y1], recording, at) = self.editor.play_button(pointer)?;
1520 ((x0..=x1).contains(&point[0]) && (y0..=y1).contains(&point[1]))
1521 .then(|| Some((self.editor.recording_file(recording)?.clone(), at)))
1522 .flatten()
1523 }
1524
14391525 /// Insert, Attach File: see [`CanvasEditor::insert_attachment`]. A file without an icon
14401526 /// takes the page's blank one.
14411527 pub fn insert_attachment(&mut self, mut file: onestore::page::Attachment) -> Result<Response> {
......@@ -1562,6 +1648,9 @@ impl PageView {
15621648 if key == &Key::Named(NamedKey::Modifier) {
15631649 return Ok(Response::default());
15641650 }
1651 if let Some(response) = self.ink_key(key)? {
1652 return Ok(response);
1653 }
15651654 if let Some(ObjectFocus::Image(id) | ObjectFocus::File(id)) = self.object_focus
15661655 && matches!(key, Key::Named(NamedKey::Backspace | NamedKey::Delete))
15671656 {
......@@ -1808,6 +1897,22 @@ pub fn page_hit_test(
18081897 page_hit(editor, scene, point, pixel, None)
18091898}
18101899
1900/// OneNote 2010's play button: a blue disc with a white triangle, in `rect`.
1901fn append_play_button(rect: [f32; 4], paper: Paper, primitives: &mut Vec<Primitive<'_>>) {
1902 let side = rect[2] - rect[0];
1903 primitives.push(Primitive::Gradient {
1904 rect,
1905 radius: [side / 2.0; 2],
1906 colors: [0x00e6a56e, 0x00c8783a].map(|color| paper.shade(crate::gpu::colorref(color))),
1907 });
1908 primitives.push(Primitive::Path {
1909 data: "M4 3L8 5.25L4 7.5Z",
1910 origin: [rect[0], rect[1]],
1911 style: draw::PathStyle::Fill,
1912 colors: [[1.0; 4]; 2],
1913 });
1914}
1915
18111916/// Logical pixels a finger's grip targets extend beyond the drawn grips.
18121917const TOUCH_REACH: f32 = 16.0;
18131918
crates/canvas/src/outline.rs+2
......@@ -2613,8 +2613,10 @@ mod tests {
26132613 color: None,
26142614 transparency: None,
26152615 pen_tip: None,
2616 raster_operation: None,
26162617 }],
26172618 groups: Vec::new(),
2619 shape: None,
26182620 });
26192621 let after = paragraph(4, "After", 1, None);
26202622 let mut engine = TextEngine::default();
crates/canvas/tests/ink_tools.rs created+287
......@@ -0,0 +1,287 @@
1//! The Draw tab's tools through the page view, as a host drives them: every stroke and shape
2//! is one edit adding one drawing, the eraser and the lasso's Delete remove drawings, a lasso
3//! drag moves them, and each is one undo step. The ops seal into a section reading back as the
4//! editor's page. `CANVAS_INK_EXPORT` names a directory receiving that section as a notebook,
5//! for a cold reopen in OneNote 2010 (`corpus/ink-tools`).
6#![cfg(feature = "interaction")]
7
8use canvas::{
9 editor::{FAVORITES, Pen},
10 gpu::page::PageScene,
11 interaction::{PageView, TextColors, ink::Tool},
12 layout::TextEngine,
13};
14use draw::edit::{Key, NamedKey};
15use onestore::{
16 Arena, ExGuid, Section, Store,
17 op::{Edit, Op, PageOp},
18 page::{Page, PageObject, ink::ShapeKind},
19};
20use std::time::{Duration, Instant};
21
22const COLORS: TextColors = TextColors {
23 caret: [0.0, 0.0, 0.0, 1.0],
24 selection: [0.7, 0.8, 1.0, 1.0],
25 paper: canvas::gpu::Paper::WHITE,
26};
27
28struct Lab<'a> {
29 section: Section<'a>,
30 space: ExGuid,
31 view: PageView,
32 at: u64,
33}
34
35impl Lab<'_> {
36 /// Stores what the last gesture recorded, returning its ops.
37 fn store(&mut self) -> Vec<PageOp> {
38 let ops = self.view.editor.take_ops().unwrap();
39 self.at += 10_000_000;
40 let edit = Edit {
41 at: self.at,
42 ops: ops
43 .iter()
44 .cloned()
45 .map(|op| Op::Page {
46 space: self.space,
47 op,
48 })
49 .collect(),
50 };
51 self.section.apply("Author", &edit).unwrap();
52 assert_eq!(
53 self.section.page(self.space).unwrap(),
54 self.view.editor.page().unwrap()
55 );
56 ops
57 }
58
59 /// A press at page point `from` dragged through `through`, in a view at 1 device pixel
60 /// per point.
61 fn drag(&mut self, path: &[[f32; 2]]) {
62 let view = &mut self.view;
63 let _ = view.pointer_moved(path[0]).unwrap();
64 let _ = view.pointer_pressed(Instant::now()).unwrap();
65 for point in &path[1..] {
66 let _ = view.pointer_moved(*point).unwrap();
67 }
68 let _ = view.pointer_released().unwrap();
69 }
70
71 fn drawings(&self) -> Vec<onestore::page::Ink> {
72 self.view
73 .editor
74 .page()
75 .unwrap()
76 .objects
77 .into_iter()
78 .filter_map(|object| match object {
79 PageObject::Ink(ink) => Some(ink),
80 _ => None,
81 })
82 .collect()
83 }
84}
85
86fn open(arena: &Arena, source: Vec<u8>) -> Lab<'_> {
87 let mut section = Section::open(arena, source).unwrap();
88 let (space, ..) = section.pages().unwrap()[0].clone();
89 let page: Page = section.page(space).unwrap();
90 let mut engine = TextEngine::default();
91 let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap();
92 let mut view = PageView::new(
93 editor,
94 engine,
95 Some((scene, [0.0; 2])),
96 [800, 600],
97 1.0,
98 Duration::from_millis(500),
99 );
100 view.viewport.scale = 1.0;
101 view.viewport.origin = [0.0; 2];
102 Lab {
103 section,
104 space,
105 view,
106 at: 133_000_000_000_000_000,
107 }
108}
109
110fn line(from: [f32; 2], to: [f32; 2], steps: usize) -> Vec<[f32; 2]> {
111 (0..=steps)
112 .map(|step| {
113 let t = step as f32 / steps as f32;
114 [
115 from[0] + (to[0] - from[0]) * t,
116 from[1] + (to[1] - from[1]) * t,
117 ]
118 })
119 .collect()
120}
121
122#[test]
123fn drawing_tools_store_each_stroke_and_undo_it_in_one_step() {
124 let source = onestore::create_section("ink.one", "Drawn in Snowbound", "Author").unwrap();
125 let arena = Arena::default();
126 let mut lab = open(&arena, source.clone());
127 let accent = Pen::new(35.0, Some(0x7a9a1f));
128
129 let _ = lab.view.set_tool(Tool::Pen(accent));
130 let wave: Vec<[f32; 2]> = (0..=40)
131 .map(|step| {
132 let x = 60.0 + step as f32 * 4.0;
133 [x, 150.0 + 12.0 * (x / 18.0).sin()]
134 })
135 .collect();
136 lab.drag(&wave);
137 let ops = lab.store();
138 let [
139 PageOp::Add {
140 object: PageObject::Ink(ink),
141 ..
142 },
143 ] = ops.as_slice()
144 else {
145 panic!("{ops:?}")
146 };
147 assert_eq!((ink.strokes.len(), ink.strokes[0].points.len()), (1, 41));
148 assert_eq!(ink.strokes[0].color, Some(0x7a9a1f));
149
150 let _ = lab.view.set_tool(Tool::Pen(FAVORITES[5]));
151 lab.drag(&line([60.0, 190.0], [220.0, 190.0], 20));
152 lab.store();
153 for (kind, from, to) in [
154 (ShapeKind::Rectangle, [250.0, 140.0], [326.0, 196.0]),
155 (ShapeKind::Ellipse, [340.0, 140.0], [430.0, 196.0]),
156 (ShapeKind::Arrow, [250.0, 230.0], [358.0, 266.0]),
157 (ShapeKind::Line, [378.0, 230.0], [450.0, 232.0]),
158 ] {
159 let _ = lab
160 .view
161 .set_tool(Tool::Shape(kind, Pen::new(50.0, Some(0x7a9a1f))));
162 lab.drag(&line(from, to, 8));
163 lab.store();
164 }
165 let drawn = lab.drawings();
166 assert_eq!(drawn.len(), 6);
167 // Shapes take their corners from the grid, anchored at the margin origin.
168 let rectangle = &drawn[2];
169 assert_eq!(
170 (
171 rectangle.strokes[0].points[0],
172 rectangle.strokes[0].points[2]
173 ),
174 (
175 [252.0, 140.4].map(onestore::page::ink::snap),
176 [324.0, 194.4].map(onestore::page::ink::snap)
177 )
178 );
179 assert_eq!(
180 drawn[4].strokes.len(),
181 2,
182 "an arrow's head is a stroke of its own"
183 );
184 let highlighter = drawn[1].strokes[0].clone();
185 assert_eq!(
186 (
187 highlighter.raster_operation,
188 highlighter.pen_tip,
189 highlighter.transparency
190 ),
191 (Some(9), Some(1), Some(127))
192 );
193 // A highlighter multiplies what lies beneath.
194 let primitives = lab.view.primitives(COLORS).unwrap();
195 assert!(
196 primitives
197 .iter()
198 .any(|primitive| matches!(primitive, draw::Primitive::Highlight { .. }))
199 );
200 if let Some(directory) = std::env::var_os("CANVAS_INK_EXPORT") {
201 let mut image = source.clone();
202 let mut sealed = Section::open(&arena, source.clone()).unwrap();
203 std::mem::swap(&mut sealed, &mut lab.section);
204 sealed.seal().unwrap().unwrap().apply(&mut image).unwrap();
205 let directory = std::path::PathBuf::from(directory);
206 std::fs::create_dir_all(&directory).unwrap();
207 std::fs::write(directory.join("ink.one"), &image).unwrap();
208 let file_id = Store::parse(&image).unwrap().header.file_id;
209 std::fs::write(
210 directory.join("Open Notebook.onetoc2"),
211 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("ink.one", file_id)])
212 .unwrap(),
213 )
214 .unwrap();
215 return;
216 }
217
218 // Undo takes the last drawing away, and only that.
219 let arrow = drawn[4].id;
220 let line_id = drawn[5].id;
221 let _ = lab.view.undo(false).unwrap();
222 assert_eq!(lab.store(), [PageOp::Delete { object: line_id }]);
223 let _ = lab.view.undo(true).unwrap();
224 lab.store();
225 assert_eq!(lab.drawings().len(), 6);
226
227 // The eraser takes the wave it crosses, and the arrow's two strokes with it at a touch.
228 let _ = lab.view.set_tool(Tool::Eraser);
229 lab.drag(&[
230 [100.0, 120.0],
231 [100.0, 160.0],
232 [240.0, 160.0],
233 [240.0, 215.0],
234 [320.0, 250.0],
235 ]);
236 let ops = lab.store();
237 assert_eq!(
238 ops,
239 [
240 PageOp::Delete {
241 object: drawn[0].id
242 },
243 PageOp::Delete { object: arrow },
244 ]
245 );
246 let _ = lab.view.undo(false).unwrap();
247 lab.store();
248 assert_eq!(lab.drawings().len(), 6);
249
250 // The lasso picks the highlighter, a drag moves it, Delete removes it.
251 let _ = lab.view.set_tool(Tool::Lasso);
252 lab.drag(&[
253 [50.0, 175.0],
254 [230.0, 175.0],
255 [230.0, 205.0],
256 [50.0, 205.0],
257 [50.0, 176.0],
258 ]);
259 assert_eq!(lab.view.ink_selection(), [drawn[1].id]);
260 lab.drag(&line([140.0, 190.0], [170.0, 220.0], 4));
261 let ops = lab.store();
262 assert!(matches!(
263 ops.as_slice(),
264 [PageOp::Outline { object, .. }] if *object == drawn[1].id
265 ));
266 let moved = &lab.drawings()[1];
267 assert_eq!((moved.layout.x, moved.layout.y), (Some(30.0), Some(30.0)));
268 let _ = lab.view.key(&Key::Named(NamedKey::Delete), None).unwrap();
269 assert_eq!(
270 lab.store(),
271 [PageOp::Delete {
272 object: drawn[1].id
273 }]
274 );
275 let _ = lab.view.undo(false).unwrap();
276 lab.store();
277 let _ = lab.view.undo(false).unwrap();
278 lab.store();
279 assert_eq!(lab.drawings()[1].layout.x, Some(0.0));
280
281 // Escape leaves the drawing tools for Select & Type, where a click picks a drawing.
282 let _ = lab.view.key(&Key::Named(NamedKey::Escape), None).unwrap();
283 assert_eq!(lab.view.tool(), Tool::Select);
284 lab.drag(&[[400.0, 231.0]]);
285 assert!(lab.view.editor.take_ops().unwrap().is_empty());
286 assert_eq!(lab.view.ink_selection(), [drawn[5].id]);
287}
crates/canvas/tests/ops_differential.rs+149-4
......@@ -7,7 +7,7 @@
77
88use canvas::{
99 document::TextPosition,
10 editor::{Alignment, CanvasEditor, Formatting, NoteTag, Toggle, Whole},
10 editor::{Alignment, CanvasEditor, FAVORITES, Formatting, NoteTag, Pen, Toggle, Whole},
1111 layout::TextEngine,
1212};
1313use draw::edit::Movement;
......@@ -31,7 +31,7 @@ const ART: [u8; 67] = [
3131#[path = "../../onestore/tests/support/sweep.rs"]
3232mod sweep;
3333
34const SECTIONS: [&str; 18] = [
34const SECTIONS: [&str; 19] = [
3535 "corpus/outline-edit/before/notebook/synthetic.one",
3636 "corpus/paragraph-edit/before/notebook/synthetic.one",
3737 "corpus/outline-edit/tree/before/notebook/synthetic.one",
......@@ -43,6 +43,7 @@ const SECTIONS: [&str; 18] = [
4343 "corpus/picture-edit/native-page-level/notebook/pictures.one",
4444 "corpus/attachment-edit/plain/cold/notebook/files.one",
4545 "corpus/ink-edit/drawing/cold/notebook/ink.one",
46 "corpus/ink-tools/native-ui/notebook/ink.one",
4647 "corpus/math-edit/native-editor/notebook/links.one",
4748 "corpus/link-edit/native-links/notebook/links.one",
4849 "corpus/link-edit/native-typed/notebook/links.one",
......@@ -110,6 +111,8 @@ enum Kind {
110111 Paper,
111112 Art,
112113 Attach,
114 /// Record Audio at a random caret, a note taken meanwhile, then Stop.
115 Record,
113116 /// A click on blank page, then Attach File: a file on the page.
114117 AttachPage,
115118 MoveFile,
......@@ -118,6 +121,15 @@ enum Kind {
118121 TypeRun,
119122 /// More typing where the caret is, joining the run before it.
120123 TypeOn,
124 /// A pen stroke, a highlighter stroke or a shape: a drawing of its own.
125 Pen,
126 Marker,
127 Shape,
128 /// The stroke eraser touching a drawing's first point.
129 Erase,
130 /// Lassoed drawings moved or deleted.
131 LassoMove,
132 LassoDelete,
121133 Undo,
122134 Redo,
123135}
......@@ -202,6 +214,13 @@ const ATTACH_PLANS: [&[Kind]; 2] = [
202214 &[Kind::Attach, Kind::Type, Kind::Attach, Kind::Undo],
203215];
204216
217/// Recordings at a random caret, in plans of their own so the random edits' sequences stay
218/// as they were.
219const RECORD_PLANS: [&[Kind]; 2] = [
220 &[Kind::Record, Kind::Undo, Kind::Redo],
221 &[Kind::Record, Kind::PageDelete, Kind::Undo],
222];
223
205224/// Files attached on blank page, moved, spaced and removed, in plans of their own so the
206225/// random edits' sequences stay as they were.
207226const FLOATING_PLANS: [&[Kind]; 4] = [
......@@ -224,6 +243,30 @@ const FLOATING_PLANS: [&[Kind]; 4] = [
224243 ],
225244];
226245
246/// The Draw tab's tools, in plans of their own so the random edits' sequences stay as they
247/// were.
248const INK_PLANS: [&[Kind]; 6] = [
249 &[Kind::Pen, Kind::Undo, Kind::Redo],
250 &[
251 Kind::Marker,
252 Kind::Shape,
253 Kind::Undo,
254 Kind::Undo,
255 Kind::Redo,
256 ],
257 &[Kind::Pen, Kind::Pen, Kind::Erase, Kind::Undo, Kind::Redo],
258 &[
259 Kind::Shape,
260 Kind::LassoMove,
261 Kind::Undo,
262 Kind::Redo,
263 Kind::LassoDelete,
264 Kind::Undo,
265 ],
266 &[Kind::Erase, Kind::Undo, Kind::Redo],
267 &[Kind::LassoMove, Kind::LassoDelete, Kind::Undo, Kind::Undo],
268];
269
227270/// Page colour, rule lines and art undone and redone across each other.
228271const PAPER_PLANS: [&[Kind]; 3] = [
229272 &[Kind::Art, Kind::Art, Kind::Undo, Kind::Undo, Kind::Redo],
......@@ -290,6 +333,12 @@ fn perform(
290333 | Kind::Date
291334 | Kind::Paper
292335 | Kind::Art
336 | Kind::Pen
337 | Kind::Marker
338 | Kind::Shape
339 | Kind::Erase
340 | Kind::LassoMove
341 | Kind::LassoDelete
293342 ) {
294343 if outlines.is_empty() {
295344 return false;
......@@ -561,6 +610,29 @@ fn perform(
561610 };
562611 editor.set_paper(editor.page_color(), editor.rule_lines(), Some(art))
563612 }
613 Kind::Record => {
614 place(editor, middle)
615 && editor
616 .start_recording(engine, "Audio recording started")
617 .is_ok_and(|id| {
618 let file = onestore::page::Attachment {
619 id: onestore::page::text::new_id().unwrap(),
620 filename: "Recorded.wav".into(),
621 source_path: None,
622 size: Some([24.0, 24.0]),
623 layout: Default::default(),
624 bytes: Some(std::sync::Arc::from(b"RIFF".as_slice())),
625 preview: Some(std::sync::Arc::from(ART.as_slice())),
626 recording: Some(onestore::page::Recording {
627 id,
628 kind: 1,
629 duration_ms: Some(1000),
630 }),
631 };
632 editor.insert(engine, "noted").is_ok()
633 && editor.finish_recording(engine, file).is_ok()
634 })
635 }
564636 Kind::Attach => {
565637 let file = onestore::page::Attachment {
566638 id: onestore::page::text::new_id().unwrap(),
......@@ -624,8 +696,79 @@ fn perform(
624696 && editor.delete_to(engine, Movement::WordLeft).is_ok()
625697 && editor.commit_text(engine, "ef".into()).is_ok()
626698 }
627 Kind::TypeOn => {
628 editor.insert(engine, "gh").is_ok() && editor.delete(engine, true).is_ok()
699 Kind::TypeOn => editor.insert(engine, "gh").is_ok() && editor.delete(engine, true).is_ok(),
700 Kind::Pen | Kind::Marker => {
701 let pen = if kind == Kind::Pen {
702 Pen::new(35.0, Some(0x7a9a1f))
703 } else {
704 FAVORITES[5]
705 };
706 let [x, y] = [
707 60.0 + 18.0 * random.below(20) as f32,
708 400.0 + 18.0 * random.below(10) as f32,
709 ];
710 let points: Vec<[f32; 2]> = (0..12)
711 .map(|step| [x + 6.0 * step as f32, y + 9.0 * (step as f32 / 2.0).sin()])
712 .collect();
713 let ink = onestore::page::Ink {
714 id: onestore::page::text::new_id().unwrap(),
715 layout: Default::default(),
716 strokes: vec![pen.stroke(&points).unwrap()],
717 groups: Vec::new(),
718 shape: None,
719 };
720 editor.draw(ink).is_ok()
721 }
722 Kind::Shape => {
723 use onestore::page::ink::ShapeKind;
724 let kinds = [
725 ShapeKind::Line,
726 ShapeKind::Arrow,
727 ShapeKind::Rectangle,
728 ShapeKind::Ellipse,
729 ];
730 let from = [
731 36.0 + 18.0 * random.below(20) as f32,
732 14.4 + 18.0 * random.below(30) as f32,
733 ];
734 let to = [
735 from[0] + 18.0 * (1 + random.below(6)) as f32,
736 from[1] + 18.0 * (1 + random.below(4)) as f32,
737 ];
738 let pen = Pen::new(50.0, Some(0x7a9a1f)).stroke(&[]).unwrap();
739 let ink = onestore::page::Ink::drawn(kinds[random.below(4)], from, to, &pen).unwrap();
740 editor.draw(ink).is_ok()
741 }
742 Kind::Erase | Kind::LassoMove | Kind::LassoDelete => {
743 let drawings: Vec<onestore::page::Ink> = editor
744 .page()
745 .unwrap()
746 .objects
747 .into_iter()
748 .filter_map(|object| match object {
749 onestore::page::PageObject::Ink(ink) => Some(ink),
750 _ => None,
751 })
752 .collect();
753 if drawings.is_empty() {
754 return false;
755 }
756 let ink = &drawings[random.below(drawings.len())];
757 match kind {
758 Kind::Erase => {
759 let Some([x, y]) = ink.strokes.first().map(|stroke| stroke.points[0]) else {
760 return false;
761 };
762 let [dx, dy] = [ink.layout.x, ink.layout.y].map(|v| v.unwrap_or(0.0));
763 let point = [x + dx, y + dy];
764 editor.erase(point, point, 1.0, false).unwrap_or(false)
765 }
766 Kind::LassoMove => {
767 let delta = [18.0, -9.0].map(|step| step * (1 + random.below(3)) as f32);
768 editor.move_ink(&[ink.id], delta).is_ok()
769 }
770 _ => editor.delete_ink(&[ink.id]).is_ok(),
771 }
629772 }
630773 Kind::Undo => editor.undo(engine).unwrap_or(false),
631774 Kind::Redo => editor.redo(engine).unwrap_or(false),
......@@ -915,7 +1058,9 @@ fn sweep(
9151058 .collect()
9161059 }))
9171060 .chain(PAPER_PLANS.map(<[Kind]>::to_vec))
1061 .chain(INK_PLANS.map(<[Kind]>::to_vec))
9181062 .chain(ATTACH_PLANS.map(<[Kind]>::to_vec))
1063 .chain(RECORD_PLANS.map(<[Kind]>::to_vec))
9191064 .chain(FLOATING_PLANS.map(<[Kind]>::to_vec))
9201065 .chain(TYPING_PLANS.map(<[Kind]>::to_vec))
9211066 .collect::<Vec<Vec<Kind>>>();
crates/canvas/tests/recording.rs created+187
......@@ -0,0 +1,187 @@
1//! Recording through the editor, as Snowbound's Record Audio stores it: the ops the editor
2//! records seal into a section that reads back as the editor's page, with the line saying
3//! when, the notes written meanwhile linked to their moments, and the file where recording
4//! started.
5
6use canvas::{editor::CanvasEditor, layout::TextEngine};
7use draw::edit::Movement;
8use onestore::{
9 Arena, Section,
10 op::{Edit, Op},
11 page::{Attachment, MediaIndex, Page, PageObject, ParagraphContent, Recording},
12};
13
14fn body(page: &Page) -> Vec<(String, MediaIndex)> {
15 page.objects
16 .iter()
17 .find_map(|object| match object {
18 PageObject::Outline(outline) if !outline.title => Some(outline),
19 _ => None,
20 })
21 .unwrap()
22 .paragraphs
23 .iter()
24 .map(|paragraph| {
25 let shown = match &paragraph.content {
26 ParagraphContent::Text(text) => text.text.text().to_owned(),
27 ParagraphContent::Attachment(file) => format!("[{}]", file.filename),
28 _ => panic!(),
29 };
30 (shown, paragraph.media.clone())
31 })
32 .collect()
33}
34
35#[test]
36fn a_recording_stores_its_line_its_linked_notes_and_its_file() {
37 let source = onestore::create_section("files.one", "Recorded", "Author").unwrap();
38 let arena = Arena::default();
39 let mut section = Section::open(&arena, source.clone()).unwrap();
40 let (space, ..) = section.pages().unwrap()[0].clone();
41 let mut engine = TextEngine::default();
42 let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap();
43 let outline = editor
44 .outlines()
45 .iter()
46 .find(|outline| !outline.title)
47 .unwrap()
48 .id;
49 editor.focus_outline(outline).unwrap();
50 editor
51 .move_selection(&mut engine, Movement::DocumentEnd, false)
52 .unwrap();
53 let mut at = 133_000_000_000_000_000;
54 let mut store = |editor: &mut CanvasEditor| {
55 let ops = editor.take_ops().unwrap();
56 at += 10_000_000;
57 let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect();
58 section.apply("Author", &Edit { at, ops }).unwrap();
59 let stored = section.page(space).unwrap();
60 assert_eq!(
61 Page {
62 title: stored.title.clone(),
63 ..editor.page().unwrap()
64 },
65 stored
66 );
67 stored
68 };
69 let id = editor
70 .start_recording(&mut engine, "Audio recording started: 5:18 PM")
71 .unwrap();
72 assert_eq!(editor.recording(), Some(id));
73 store(&mut editor);
74 std::thread::sleep(std::time::Duration::from_millis(20));
75 editor.insert(&mut engine, "A linked note").unwrap();
76 store(&mut editor);
77 editor.insert(&mut engine, "\n").unwrap();
78 store(&mut editor);
79 let file = Attachment {
80 id: onestore::page::text::new_id().unwrap(),
81 filename: "Recorded.wav".into(),
82 source_path: None,
83 size: Some([24.0, 24.0]),
84 layout: Default::default(),
85 bytes: Some(b"RIFF".as_slice().into()),
86 preview: Some(canvas::gpu::page::file_icon().into()),
87 recording: Some(Recording {
88 id,
89 kind: 1,
90 duration_ms: Some(1000),
91 }),
92 };
93 editor.finish_recording(&mut engine, file).unwrap();
94 assert_eq!(editor.recording(), None);
95 let stored = store(&mut editor);
96 let shown = body(&stored);
97 let texts: Vec<&str> = shown.iter().map(|(text, _)| text.as_str()).collect();
98 assert_eq!(
99 texts,
100 [
101 "Recorded",
102 "[Recorded.wav]",
103 "",
104 "Audio recording started: 5:18 PM",
105 "A linked note",
106 ""
107 ]
108 );
109 let linked = |time| MediaIndex {
110 recordings: vec![id],
111 time_ms: Some(time),
112 };
113 assert_eq!(shown[0].1, MediaIndex::default());
114 assert_eq!(shown[1].1, linked(0));
115 assert_eq!(shown[3].1, linked(0));
116 assert!(shown[4].1.time_ms.is_some_and(|time| time >= 20));
117 assert_eq!(shown[5].1, MediaIndex::default());
118
119 // Undo takes the file and the links away as stored, and redo brings them back.
120 let before = stored;
121 editor.undo(&mut engine).unwrap();
122 let undone = store(&mut editor);
123 assert!(!body(&undone).iter().any(|(text, _)| text.starts_with('[')));
124 editor.redo(&mut engine).unwrap();
125 assert_eq!(store(&mut editor), before);
126}
127
128/// With the caret in the title, OneNote 2010 records at the start of the body, the file first
129/// (`corpus/recording`); an empty first half of the caret's paragraph is the blank line.
130#[test]
131fn a_recording_from_the_title_goes_to_the_body_s_start() {
132 let source = include_bytes!("../../../corpus/recording/native/notebook/files.one");
133 let arena = Arena::default();
134 let mut section = Section::open(&arena, source.to_vec()).unwrap();
135 let (space, ..) = section
136 .pages()
137 .unwrap()
138 .into_iter()
139 .find(|(_, title, _)| title == "Recorded")
140 .unwrap();
141 let mut engine = TextEngine::default();
142 let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap();
143 let title = editor
144 .outlines()
145 .iter()
146 .find(|outline| outline.title)
147 .unwrap()
148 .id;
149 editor.focus_outline(title).unwrap();
150 let id = editor.start_recording(&mut engine, "Started").unwrap();
151 let file = Attachment {
152 id: onestore::page::text::new_id().unwrap(),
153 filename: "Recorded.wav".into(),
154 source_path: None,
155 size: Some([24.0, 24.0]),
156 layout: Default::default(),
157 bytes: Some(b"RIFF".as_slice().into()),
158 preview: Some(canvas::gpu::page::file_icon().into()),
159 recording: Some(Recording {
160 id,
161 kind: 1,
162 duration_ms: None,
163 }),
164 };
165 editor.finish_recording(&mut engine, file).unwrap();
166 let ops = editor.take_ops().unwrap();
167 let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect();
168 section
169 .apply(
170 "Author",
171 &Edit {
172 at: 133_000_000_000_000_000,
173 ops,
174 },
175 )
176 .unwrap();
177 let stored = section.page(space).unwrap();
178 let shown = body(&stored);
179 let texts: Vec<&str> = shown.iter().map(|(text, _)| text.as_str()).collect();
180 assert_eq!(
181 texts[..4],
182 ["[Recorded.wav]", "", "Started", "Before recording edited"]
183 );
184 // The note keeps its link to OneNote's recording.
185 assert_eq!(shown[3].1.time_ms, Some(20131));
186 assert_eq!(shown[1].1, MediaIndex::default());
187}
crates/draw/src/render.rs+24
......@@ -188,6 +188,8 @@ enum Blend {
188188 Image(u64),
189189 /// Clearing it by the batch's coverage.
190190 Erase,
191 /// Multiplying it by the batch's colour where covered.
192 Multiply,
191193}
192194
193195#[repr(C)]
......@@ -427,6 +429,14 @@ pub enum Primitive<'a> {
427429 round: bool,
428430 color: [f32; 4],
429431 },
432 /// A highlighter's stroke between two points, `width` units across with square ends:
433 /// its opaque `color` multiplies what lies beneath, so text under it stays dark.
434 Highlight {
435 from: [f32; 2],
436 to: [f32; 2],
437 width: f32,
438 color: [f32; 4],
439 },
430440}
431441
432442#[derive(Debug)]
......@@ -851,6 +861,15 @@ impl Renderer {
851861 round,
852862 color,
853863 } => self.segment(space, *from, *to, *width, *round, *color)?,
864 Primitive::Highlight {
865 from,
866 to,
867 width,
868 color,
869 } => {
870 blend = Blend::Multiply;
871 self.segment(space, *from, *to, *width, false, *color)?;
872 }
854873 Primitive::Image { image, rect } => {
855874 if let Some(rect) = space.visible_rect(*rect)? {
856875 self.image(image, active_images)?;
......@@ -1600,6 +1619,11 @@ pub fn srgb(red: u8, green: u8, blue: u8) -> [f32; 4] {
16001619 [red, green, blue, 1.0]
16011620}
16021621
1622/// The sRGB bytes of linear RGBA `color`, as `srgb` takes them.
1623pub fn srgb_bytes([red, green, blue, _]: [f32; 4]) -> [u8; 3] {
1624 [red, green, blue].map(srgb_byte)
1625}
1626
16031627#[cfg(all(test, feature = "wgpu"))]
16041628mod tests {
16051629 use super::*;
crates/draw/src/render/gl.rs+4
......@@ -21,6 +21,7 @@ const ZERO: GLenum = 0;
2121const ONE: GLenum = 1;
2222const SRC_ALPHA: GLenum = 0x0302;
2323const ONE_MINUS_SRC_ALPHA: GLenum = 0x0303;
24const DST_COLOR: GLenum = 0x0306;
2425const BLEND: GLenum = 0x0BE2;
2526const SCISSOR_TEST: GLenum = 0x0C11;
2627const MAX_TEXTURE_SIZE: GLenum = 0x0D33;
......@@ -463,6 +464,9 @@ impl Renderer {
463464 gpu.atlas.name,
464465 [ZERO, ONE_MINUS_SRC_ALPHA, ZERO, ONE_MINUS_SRC_ALPHA],
465466 ),
467 Blend::Multiply => {
468 (gpu.atlas.name, [DST_COLOR, ONE_MINUS_SRC_ALPHA, ZERO, ONE])
469 }
466470 };
467471 glBlendFuncSeparate(src_rgb, dst_rgb, src_alpha, dst_alpha);
468472 glBindTexture(TEXTURE_2D, texture);
crates/draw/src/render/webgpu.rs+17
......@@ -13,6 +13,7 @@ pub(super) struct Gpu {
1313 pipeline: wgpu::RenderPipeline,
1414 image_pipeline: wgpu::RenderPipeline,
1515 erase_pipeline: wgpu::RenderPipeline,
16 multiply_pipeline: wgpu::RenderPipeline,
1617 atlas: wgpu::Texture,
1718 bind_group: wgpu::BindGroup,
1819 image_sampler: wgpu::Sampler,
......@@ -98,6 +99,20 @@ impl Renderer {
9899 color: erase,
99100 alpha: erase,
100101 });
102 // The fragment's colour is scaled by its coverage, so this leaves the target times
103 // the colour where covered and the target elsewhere; alpha stays.
104 let multiply_pipeline = make_pipeline(wgpu::BlendState {
105 color: wgpu::BlendComponent {
106 src_factor: wgpu::BlendFactor::Dst,
107 dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
108 operation: wgpu::BlendOperation::Add,
109 },
110 alpha: wgpu::BlendComponent {
111 src_factor: wgpu::BlendFactor::Zero,
112 dst_factor: wgpu::BlendFactor::One,
113 operation: wgpu::BlendOperation::Add,
114 },
115 });
101116 let (atlas, bind_group) = atlas(&device, &pipeline, ATLAS_SIZE);
102117 let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
103118 label: Some("Draw vertices"),
......@@ -118,6 +133,7 @@ impl Renderer {
118133 pipeline,
119134 image_pipeline,
120135 erase_pipeline,
136 multiply_pipeline,
121137 atlas,
122138 bind_group,
123139 image_sampler,
......@@ -268,6 +284,7 @@ impl Renderer {
268284 Blend::Over => (&gpu.pipeline, &gpu.bind_group),
269285 Blend::Image(id) => (&gpu.image_pipeline, &self.images[&id].texture),
270286 Blend::Erase => (&gpu.erase_pipeline, &gpu.bind_group),
287 Blend::Multiply => (&gpu.multiply_pipeline, &gpu.bind_group),
271288 };
272289 pass.set_pipeline(pipeline);
273290 pass.set_bind_group(0, binding, &[]);
crates/mobile/include/snowbound.h+5-1
......@@ -16,9 +16,10 @@ typedef void (*sb_coordinator)(const char *path, bool write, void (*body)(void *
1616void sb_set_coordinator(sb_coordinator coordinator);
1717void sb_set_sync_wake(void (*wake)(void));
1818char *sb_tags(void);
19char *sb_pens(void);
1920bool sb_tag_icon(uint16_t shape, bool checked, uint32_t pixels, uint8_t *rgba);
2021
21const Library *sb_library_open(const char *path, const char *cache, char **error);
22const Library *sb_library_open(const char *path, const char *cache, bool local, char **error);
2223const Library *sb_library_server(const char *address, const char *share, const char *user, const char *password,
2324 const char *domain, const char *root, const char *cache, char **error);
2425char *sb_library_sections(const Library *library);
......@@ -28,6 +29,7 @@ char *sb_library_search(const Library *library, const Section *open, const char
2829char *sb_library_tagged(const Library *library, const Section *open, const char *path);
2930char *sb_library_sync_status(const Library *library);
3031void sb_library_set_offline(const Library *library, bool offline);
32void sb_library_touched(const Library *library, const char *path);
3133void sb_library_sync_now(const Library *library);
3234void sb_library_free(const Library *library);
3335
......@@ -73,6 +75,8 @@ bool sb_view_find(View *view, const char *query);
7375bool sb_view_select_paragraph(View *view, const char *id);
7476char *sb_view_copy(View *view, bool cut);
7577void sb_view_insert_space(View *view);
78void sb_view_set_tool(View *view, uint8_t tool, uint8_t pen);
79bool sb_view_ink_selection(const View *view, float rect[4]);
7680char *sb_view_paper(const View *view);
7781bool sb_view_set_paper(View *view, int16_t red, uint8_t green, uint8_t blue, int8_t ruled);
7882bool sb_view_set_art(View *view, const char *name);
crates/mobile/src/lib.rs+76-12
......@@ -15,9 +15,9 @@ pub use library::{Library, Section, Share};
1515use canvas::{
1616 date::DateField,
1717 document::TextPosition,
18 editor::{Formatting, NoteTag, Selection, TextOutline, Toggle},
18 editor::{FAVORITES, Formatting, NoteTag, Selection, TextOutline, Toggle},
1919 gpu::{Paper, Viewport, page::PageScene},
20 interaction::{Hit, ObjectFocus, PageView, Request, Response, TextColors},
20 interaction::{Hit, ObjectFocus, PageView, Request, Response, TextColors, ink::Tool},
2121 layout::TextEngine,
2222};
2323use draw::edit::{Key, NamedKey, SelectionUnit};
......@@ -74,7 +74,8 @@ enum Target {
7474 /// Another outline's text, which a touch focuses before the platform's text interaction
7575 /// takes over.
7676 Text = 2,
77 /// The focused outline's move or width grip, or the selected picture and its handles.
77 /// The focused outline's move or width grip, the selected picture and its handles, or
78 /// picked drawings.
7879 Grip = 3,
7980}
8081
......@@ -210,10 +211,17 @@ impl Canvas {
210211 }
211212
212213 fn target(&self, point: [f32; 2]) -> Target {
213 if self.page.inserting_space() {
214 let device = self.device(point);
215 let [x, y] = self.page.viewport.document_point(device);
216 let on_ink = self
217 .page
218 .editor
219 .ink_extent(self.page.ink_selection())
220 .is_some_and(|[x0, y0, x1, y1]| (x0..=x1).contains(&x) && (y0..=y1).contains(&y));
221 if self.page.inserting_space() || on_ink {
214222 return Target::Grip;
215223 }
216 match self.page.hit(self.device(point)) {
224 match self.page.hit(device) {
217225 Some(Hit::Handle { .. } | Hit::Resize { .. }) => Target::Grip,
218226 Some(Hit::Image { id, .. })
219227 if self.page.object_focus() == Some(ObjectFocus::Image(id)) =>
......@@ -512,17 +520,43 @@ impl Canvas {
512520 Ok(moved(self.page.change_date(timestamp, text)?))
513521 }
514522
523 /// Picks what touches on the page do; see `sb_view_set_tool`.
524 fn set_tool(&mut self, tool: u8, pen: u8) -> Result<()> {
525 let tool = match tool {
526 0 => Tool::Select,
527 1 => Tool::Pen(*FAVORITES.get(usize::from(pen)).ok_or("No such pen")?),
528 2 => Tool::Eraser,
529 3 => Tool::Lasso,
530 _ => return Err("No such tool".into()),
531 };
532 let _ = self.page.set_tool(tool);
533 Ok(())
534 }
535
536 /// The frame of the drawings the lasso or a tap picked.
537 fn ink_selection(&self) -> Option<[f32; 4]> {
538 self.page
539 .editor
540 .ink_extent(self.page.ink_selection())
541 .map(|rect| self.document_rect(rect))
542 }
543
515544 /// A rectangle in the active outline's coordinates, in the view's points as `[x, y,
516545 /// width, height]`.
517546 fn view_rect(&self, rect: BoundingBox) -> [f32; 4] {
547 let [x, y] = self.active().origin();
548 let [x0, y0, x1, y1] = [rect.x0, rect.y0, rect.x1, rect.y1].map(|value| value as f32);
549 self.document_rect([x0 + x, y0 + y, x1 + x, y1 + y])
550 }
551
552 /// Page rectangle `[x0, y0, x1, y1]` in the view's points as `[x, y, width, height]`.
553 fn document_rect(&self, [x0, y0, x1, y1]: [f32; 4]) -> [f32; 4] {
518554 let viewport = self.page.viewport;
519 let origin = self.active().origin();
520 let [x, y] = [rect.x0 as f32 + origin[0], rect.y0 as f32 + origin[1]];
521555 [
522 (x * viewport.scale + viewport.origin[0]) / self.scale,
523 (y * viewport.scale + viewport.origin[1]) / self.scale,
524 rect.width() as f32 * viewport.scale / self.scale,
525 rect.height() as f32 * viewport.scale / self.scale,
556 (x0 * viewport.scale + viewport.origin[0]) / self.scale,
557 (y0 * viewport.scale + viewport.origin[1]) / self.scale,
558 (x1 - x0) * viewport.scale / self.scale,
559 (y1 - y0) * viewport.scale / self.scale,
526560 ]
527561 }
528562
......@@ -848,7 +882,7 @@ pub extern "C" fn sb_view_set_transform(view: &mut View, zoom: f32, x: f32, y: f
848882
849883/// What the view point `x`, `y` lands on: 0 for the page, where the canvas takes a tap; 1
850884/// for the text taking input; 2 for another outline's text; 3 for the focused outline's
851/// move or width grip, or the selected picture and its handles.
885/// move or width grip, the selected picture and its handles, or picked drawings.
852886#[unsafe(no_mangle)]
853887pub extern "C" fn sb_view_target(view: &View, x: f32, y: f32) -> u8 {
854888 view.canvas.target([x, y]) as u8
......@@ -957,6 +991,36 @@ pub extern "C" fn sb_view_copy(view: &mut View, cut: bool) -> *mut c_char {
957991 copied.map_or(std::ptr::null_mut(), owned)
958992}
959993
994/// Picks what touches do, as OneNote's Draw tab does: 0 Select & Type, where a tap on a
995/// drawing picks it; 1 pen `pen` of `sb_pens`, a stroke per press, drag and release; 2 the
996/// stroke eraser; 3 the lasso. Picked drawings move with a drag and go with
997/// `sb_delete_backward`. Setting a tool, even the same one, drops a stroke under way and
998/// lets picked drawings go.
999#[unsafe(no_mangle)]
1000pub extern "C" fn sb_view_set_tool(view: &mut View, tool: u8, pen: u8) {
1001 let _ = report(view.canvas.set_tool(tool, pen));
1002}
1003
1004/// The frame of the drawings the lasso or a tap picked, as `[x, y, width, height]` in the
1005/// view's points; false with none picked.
1006#[unsafe(no_mangle)]
1007pub extern "C" fn sb_view_ink_selection(view: &View, rect: &mut [f32; 4]) -> bool {
1008 view.canvas
1009 .ink_selection()
1010 .map(|frame| *rect = frame)
1011 .is_some()
1012}
1013
1014/// OneNote 2010's favourite pens as JSON, in its gallery's order: each `[color, width,
1015/// highlighter]`, the colour sRGB or null for the paper's ink and the width in HIMETRIC.
1016#[unsafe(no_mangle)]
1017pub extern "C" fn sb_pens() -> *mut c_char {
1018 library::json(Ok(FAVORITES
1019 .iter()
1020 .map(|pen| (pen.color.map(srgb), pen.width, pen.highlighter))
1021 .collect::<Vec<_>>()))
1022}
1023
9601024/// Insert Space: the next drag on the page moves what lies below or right of where it
9611025/// starts, as far as it goes.
9621026#[unsafe(no_mangle)]
crates/mobile/src/library.rs+31-9
......@@ -7,7 +7,7 @@ use canvas::search::{Entry, Index, Query, Tagged};
77use notebook::{
88 Remote, Replica,
99 discover::{Folder, SectionState},
10 session::{self, Background, Event, Notebook, SyncStatus},
10 session::{self, Background, Event, Known, Notebook, SyncStatus},
1111 smb::{Client, Credentials},
1212};
1313use onestore::{
......@@ -265,15 +265,17 @@ fn tabs(folder: &Folder, tabs: &mut Vec<Tab>) {
265265}
266266
267267impl Library {
268 pub(crate) fn open(path: &Path, cache: &Path) -> Result<Self> {
268 /// The notebook folder or lone section file at `path`. A folder `local` to this device
269 /// reports every change to `touched`; any other, as a file provider keeps it, gets offline
270 /// copies of its sections and is checked every few seconds.
271 pub(crate) fn open(path: &Path, cache: &Path, local: bool) -> Result<Self> {
269272 let (notebook, place, background) = if path.is_file() {
270273 (None, Place::File(path.to_owned()), None)
271274 } else {
272275 let notebook = Notebook::open(path, cache)?;
273 // Nothing here tells a file provider's folder on this device from one elsewhere.
274276 let background = notebook.background_with(
275 Background::UNWATCHED,
276 false,
277 local,
278 !local,
277279 |file| Coordinated(file.to_owned()),
278280 sync_woken,
279281 )?;
......@@ -331,8 +333,10 @@ impl Library {
331333 background.watch(
332334 files
333335 .iter()
334 .map(|(path, identity)| {
335 (path.clone(), Some(smb.join(format!("{identity}.sqlite"))))
336 .map(|(path, identity)| Known {
337 path: path.clone(),
338 replica: Some(smb.join(format!("{identity}.sqlite"))),
339 found: None,
336340 })
337341 .collect(),
338342 );
......@@ -467,6 +471,9 @@ impl Library {
467471 }
468472 };
469473 section.set_offline(self.offline.load(Ordering::Relaxed));
474 if let Some(background) = &self.background {
475 background.hold(path, &section);
476 }
470477 Ok(section)
471478 }
472479
......@@ -1033,7 +1040,9 @@ fn space(text: *const c_char) -> Result<ExGuid> {
10331040}
10341041
10351042/// The notebook folder or lone section file at `path`, its section replicas kept in the
1036/// directory `cache`; null with `error` set if it cannot be read.
1043/// directory `cache`; null with `error` set if it cannot be read. A folder `local` to this
1044/// device has the host report its changes to `sb_library_touched`; any other is checked
1045/// every few seconds and kept in offline copies.
10371046///
10381047/// # Safety
10391048/// `path` and `cache` are NUL-terminated UTF-8; `error` is null or a place for a string.
......@@ -1041,9 +1050,10 @@ fn space(text: *const c_char) -> Result<ExGuid> {
10411050pub unsafe extern "C" fn sb_library_open(
10421051 path: *const c_char,
10431052 cache: *const c_char,
1053 local: bool,
10441054 error: *mut *mut c_char,
10451055) -> *const Library {
1046 match Library::open(Path::new(&string(path)), Path::new(&string(cache))) {
1056 match Library::open(Path::new(&string(path)), Path::new(&string(cache)), local) {
10471057 Ok(library) => Arc::into_raw(Arc::new(library)),
10481058 Err(cause) => {
10491059 failed(cause, error);
......@@ -1168,6 +1178,18 @@ pub extern "C" fn sb_library_set_offline(library: &Library, offline: bool) {
11681178 }
11691179}
11701180
1181/// Checks the sections at or below `path`, `/`-separated from the notebook's folder, as the
1182/// host's watch on a local folder reports them changed; `""` names the folder.
1183///
1184/// # Safety
1185/// `path` is NUL-terminated UTF-8.
1186#[unsafe(no_mangle)]
1187pub unsafe extern "C" fn sb_library_touched(library: &Library, path: *const c_char) {
1188 if let Some(background) = &library.background {
1189 background.touched(&[string(path)]);
1190 }
1191}
1192
11711193/// Syncs every section of the notebook now, working offline included.
11721194#[unsafe(no_mangle)]
11731195pub extern "C" fn sb_library_sync_now(library: &Library) {
crates/mobile/src/tests.rs+116-13
......@@ -53,7 +53,7 @@ fn canvas(section: &Section, title: &str) -> Canvas {
5353
5454fn features() -> (tempfile::TempDir, Section) {
5555 let (directory, file) = copy("media-edit/candidate/Features.one");
56 let library = Arc::new(Library::open(&file, &directory.path().join("cache")).unwrap());
56 let library = Arc::new(Library::open(&file, &directory.path().join("cache"), true).unwrap());
5757 let section = open(&library, &file.to_string_lossy());
5858 (directory, section)
5959}
......@@ -82,8 +82,12 @@ fn focus(canvas: &mut Canvas, text: &str) {
8282#[test]
8383fn a_notebook_folder_lists_sections_in_order_with_groups_and_colours() {
8484 let directory = tempfile::tempdir().unwrap();
85 let library =
86 Library::open(&corpus("m6/native-features-01/notebook"), directory.path()).unwrap();
85 let library = Library::open(
86 &corpus("m6/native-features-01/notebook"),
87 directory.path(),
88 true,
89 )
90 .unwrap();
8791 let tabs = library.tabs().unwrap();
8892 let names: Vec<_> = tabs
8993 .iter()
......@@ -94,6 +98,7 @@ fn a_notebook_folder_lists_sections_in_order_with_groups_and_colours() {
9498 let lone = Library::open(
9599 &corpus("media-edit/candidate/Features.one"),
96100 directory.path(),
101 true,
97102 )
98103 .unwrap();
99104 assert_eq!(lone.tabs().unwrap().len(), 1);
......@@ -255,7 +260,7 @@ fn stored_text(file: &Path, space: ExGuid) -> String {
255260fn typing_is_stored_in_the_cache_and_published_to_the_file() {
256261 let (directory, file) = copy("media-edit/candidate/Features.one");
257262 let cache = directory.path().join("cache");
258 let library = Arc::new(Library::open(&file, &cache).unwrap());
263 let library = Arc::new(Library::open(&file, &cache, true).unwrap());
259264 let section = open(&library, &file.to_string_lossy());
260265 let mut canvas = canvas(&section, "Paragraph controls");
261266 focus(&mut canvas, "Collapsed parent");
......@@ -285,7 +290,7 @@ fn typing_is_stored_in_the_cache_and_published_to_the_file() {
285290#[test]
286291fn a_new_page_and_subpage_list_after_their_parent_and_delete_to_the_recycle_bin() {
287292 let (directory, root) = copy("m6/native-features-01/notebook");
288 let library = Arc::new(Library::open(&root, &directory.path().join("cache")).unwrap());
293 let library = Arc::new(Library::open(&root, &directory.path().join("cache"), true).unwrap());
289294 let section = open(&library, "Features.one");
290295 let first = section.rows().unwrap()[0].id.parse().unwrap();
291296 let page = section
......@@ -338,7 +343,7 @@ fn a_new_page_and_subpage_list_after_their_parent_and_delete_to_the_recycle_bin(
338343#[test]
339344fn conflict_pages_list_under_their_page_and_take_no_edits() {
340345 let (directory, root) = copy("conflict-page/candidate");
341 let library = Arc::new(Library::open(&root, &directory.path().join("cache")).unwrap());
346 let library = Arc::new(Library::open(&root, &directory.path().join("cache"), true).unwrap());
342347 let section = open(&library, "synthetic.one");
343348 let rows = section.rows().unwrap();
344349 let version = rows
......@@ -362,8 +367,12 @@ fn conflict_pages_list_under_their_page_and_take_no_edits() {
362367#[test]
363368fn search_finds_pages_by_title_and_text_across_sections() {
364369 let directory = tempfile::tempdir().unwrap();
365 let library =
366 Library::open(&corpus("m6/native-features-01/notebook"), directory.path()).unwrap();
370 let library = Library::open(
371 &corpus("m6/native-features-01/notebook"),
372 directory.path(),
373 true,
374 )
375 .unwrap();
367376 let found = library.search(None, "collapsed PARENT").unwrap();
368377 assert!(!found.is_empty());
369378 assert!(found[0].snippet.to_lowercase().contains("collapsed parent"));
......@@ -446,7 +455,7 @@ fn a_picture_goes_after_the_caret_paragraph_and_undoes() {
446455#[test]
447456fn to_do_marks_a_new_pages_body() {
448457 let (directory, root) = copy("m6/native-features-01/notebook");
449 let library = Arc::new(Library::open(&root, &directory.path().join("cache")).unwrap());
458 let library = Arc::new(Library::open(&root, &directory.path().join("cache"), true).unwrap());
450459 let section = open(&library, "Features.one");
451460 let page = section
452461 .new_page(None, "Sunday, September 27, 2026", "3:04 AM")
......@@ -571,7 +580,7 @@ extern "C" fn coordinator(
571580fn a_local_sections_reads_and_publications_go_through_the_hosts_coordination() {
572581 library::sb_set_coordinator(coordinator);
573582 let (directory, root) = copy("m6/native-features-01/notebook");
574 let library = Arc::new(Library::open(&root, &directory.path().join("cache")).unwrap());
583 let library = Arc::new(Library::open(&root, &directory.path().join("cache"), true).unwrap());
575584 let section = open(&library, "Features.one");
576585 let mut canvas = canvas(&section, "Paragraph controls");
577586 focus(&mut canvas, "Collapsed parent");
......@@ -640,7 +649,7 @@ fn tapping_the_date_asks_for_it_and_a_new_date_is_stored() {
640649/// A notebook copy with its library, and a section of it open for editing.
641650fn notebook_open(path: &str) -> (tempfile::TempDir, PathBuf, Arc<Library>, Section) {
642651 let (directory, root) = copy("m6/native-features-01/notebook");
643 let library = Arc::new(Library::open(&root, &directory.path().join("cache")).unwrap());
652 let library = Arc::new(Library::open(&root, &directory.path().join("cache"), true).unwrap());
644653 let section = open(&library, path);
645654 (directory, root, library, section)
646655}
......@@ -703,7 +712,7 @@ fn sync_status_lists_every_section_and_work_offline_holds_edits_until_sync_now()
703712#[test]
704713fn sections_open_the_first_time_while_the_background_syncs() {
705714 let (directory, root) = copy("m6/native-features-01/notebook");
706 let library = Arc::new(Library::open(&root, &directory.path().join("cache")).unwrap());
715 let library = Arc::new(Library::open(&root, &directory.path().join("cache"), true).unwrap());
707716 let rounds = {
708717 let library = Arc::clone(&library);
709718 std::thread::spawn(move || {
......@@ -735,7 +744,7 @@ fn sections_open_the_first_time_while_the_background_syncs() {
735744#[test]
736745fn the_tags_summary_lists_tagged_paragraphs_and_opens_on_them() {
737746 let (directory, file) = copy("structural-probe/tag-gallery.one");
738 let library = Arc::new(Library::open(&file, &directory.path().join("cache")).unwrap());
747 let library = Arc::new(Library::open(&file, &directory.path().join("cache"), true).unwrap());
739748 let path = file.to_string_lossy().into_owned();
740749 let section = open(&library, &path);
741750 let tagged = library.tagged(Some((&path, &section))).unwrap();
......@@ -825,3 +834,97 @@ fn insert_space_turns_the_next_touch_into_a_drag() {
825834 let _ = canvas.page.insert_space();
826835 assert_eq!(canvas.target([300.0, 1500.0]), Target::Grip);
827836}
837
838/// The page's drawings as the section stores them.
839fn stored_ink(section: &Section, space: ExGuid) -> Vec<onestore::page::Ink> {
840 let (page, _) = section.shared.page(space).unwrap();
841 page.objects
842 .into_iter()
843 .filter_map(|object| match object {
844 onestore::page::PageObject::Ink(ink) => Some(ink),
845 _ => None,
846 })
847 .collect()
848}
849
850/// A press, drags through `points` and a release, and the edit it made stored.
851fn trace(canvas: &mut Canvas, section: &Section, points: &[[f32; 2]]) {
852 canvas.press(points[0]).unwrap();
853 for &point in &points[1..] {
854 canvas.drag(point).unwrap();
855 }
856 canvas.release().unwrap();
857 if let Some(edit) = canvas.edit().unwrap() {
858 section.shared.apply(edit).unwrap();
859 }
860}
861
862fn line(from: [f32; 2], to: [f32; 2]) -> Vec<[f32; 2]> {
863 (0..=20)
864 .map(|step| {
865 let t = step as f32 / 20.0;
866 [from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t]
867 })
868 .collect()
869}
870
871#[test]
872fn pencil_tools_draw_pick_move_delete_and_erase_drawings_as_edits() {
873 let (_directory, section) = features();
874 let mut canvas = canvas(&section, "Paragraph controls");
875 let space = canvas.space;
876 let before = stored_ink(&section, space).len();
877 // The red pen, second in OneNote's gallery.
878 canvas.set_tool(1, 1).unwrap();
879 trace(&mut canvas, &section, &line([100.0, 600.0], [300.0, 660.0]));
880 let drawn = stored_ink(&section, space);
881 assert_eq!(drawn.len(), before + 1);
882 let stroke = &drawn.last().unwrap().strokes[0];
883 assert_eq!(stroke.color, Some(0x241ced));
884 assert!(stroke.points.len() > 2);
885 // A highlighter stroke is a drawing of its own.
886 canvas.set_tool(1, 5).unwrap();
887 trace(&mut canvas, &section, &line([100.0, 760.0], [300.0, 760.0]));
888 assert_eq!(stored_ink(&section, space).len(), before + 2);
889 assert!(stored_ink(&section, space).last().unwrap().strokes[0].raster_operation == Some(9));
890
891 // The lasso picks the pen stroke; a touch on it then drags it.
892 canvas.set_tool(3, 0).unwrap();
893 let lasso = [
894 [80.0, 580.0],
895 [320.0, 580.0],
896 [320.0, 700.0],
897 [80.0, 700.0],
898 [80.0, 582.0],
899 ];
900 trace(&mut canvas, &section, &lasso);
901 let frame = canvas.ink_selection().unwrap();
902 assert!(frame[2] > 150.0 && frame[3] > 40.0, "{frame:?}");
903 let on = [200.0, 630.0];
904 assert_eq!(canvas.target(on), Target::Grip);
905 trace(&mut canvas, &section, &[on, [230.0, 645.0], [260.0, 660.0]]);
906 let moved = stored_ink(&section, space);
907 let layout = &moved[before].layout;
908 assert!(layout.x.is_some_and(|x| x > 0.0), "{layout:?}");
909 let _ = canvas
910 .page
911 .key(&Key::Named(NamedKey::Backspace), None)
912 .unwrap();
913 section
914 .shared
915 .apply(canvas.edit().unwrap().unwrap())
916 .unwrap();
917 assert_eq!(stored_ink(&section, space).len(), before + 1);
918 assert!(canvas.ink_selection().is_none());
919
920 // The eraser takes the highlighter's stroke it crosses; undo brings it back.
921 canvas.set_tool(2, 0).unwrap();
922 trace(&mut canvas, &section, &line([200.0, 720.0], [200.0, 800.0]));
923 assert_eq!(stored_ink(&section, space).len(), before);
924 let _ = canvas.page.undo(false).unwrap();
925 section
926 .shared
927 .apply(canvas.edit().unwrap().unwrap())
928 .unwrap();
929 assert_eq!(stored_ink(&section, space).len(), before + 1);
930}
crates/notebook/README.md+30-16
......@@ -49,7 +49,9 @@ thread rereads the queue.
4949
5050## Sessions
5151
52`session::Notebook::open(root, cache_dir)` discovers a notebook directory and
52`session::Notebook::open(root, cache_dir)` discovers a notebook directory, reading only
53the files its folders list otherwise than when last opened (`discover::Cache`, kept under
54`cache_dir/listings`), and
5355`session::Section::open(file, cache_dir, notify)` opens one section file through
5456a replica named by the section's document identity, so the same file reopens the
5557same queue after a relaunch. A section is `Send + Sync`: `apply(author, edit)`
......@@ -66,20 +68,25 @@ worker stops connecting and edits queue until `wake()` (Sync Now) or `set_offlin
6668
6769`session::Background` keeps the sections no session holds in sync, as OneNote 2010 keeps
6870every section of an open notebook: `Background::smb(root, limit, connect, notify)` on a
69share, `Notebook::background(interval, copies, notify)` for a mounted notebook, then
71share, `Notebook::background(watched, copies, notify)` for a mounted notebook, then
7072`watch(notebook.replicas())`. On a share, one CHANGE_NOTIFY on the notebook's folder
7173(`smb::Client::watch`) reports what changed; a host watching a mounted folder passes the
72changed paths to `touched`. A reported section is checked a second later, a failing one
73every 31 seconds, and any other every `interval` (`Background::BACKSTOP` while a watch
74reports). Newly watched sections, and every section after a reconnect, are checked one at a
75time 100 ms apart. A check reads the file's stamp; a section whose replica has edits waiting,
76or whose file moved past the replica's base, has the replica opened for the synchronization
77steps that publish or rebase it and closed again, and a section without a replica gets one
78from the file, its offline copy, on a share or with `copies`. A replica a session holds is
79skipped (`Error::busy`), so opening a section may wait out one step. `status()` gives each
80section's `SyncStatus`, `changed()` the sections another client changed, `wake` and
81`set_offline` follow Sync Now and Work Offline, and `discard` stops the thread and deletes the
82replicas holding nothing unpublished, as when the notebook closes.
74changed paths to `touched`, and says whether it reports every change as `watched`. A
75reported section is checked a second later; a reported folder is listed a second later and
76only its sections listed otherwise are checked. A failing section is tried every 31 seconds,
77and any other every `Background::BACKSTOP` while a watch reports, `UNWATCHED` otherwise. On
78connecting, and on working online again, every folder is listed: a section whose file lists as
79discovery or the last check found it needs no reading, the others are checked one at a time
80100 ms apart. A check reads the file's stamp; a section whose replica has edits waiting, or
81whose file moved past the replica's base, has the replica opened for the synchronization steps
82that publish or rebase it and closed again, and a section without a replica gets one from the
83file, its offline copy, on a share or with `copies`. `hold(path, section)` leaves an open
84section to its session: its worker no longer polls while a watch reports, the watch wakes it
85instead, and once its replica is released the background checks the file at once. A replica
86a session holds is otherwise skipped (`Error::busy`), so opening a section may wait out one
87step. `status()` gives each section's `SyncStatus`, `changed()` the sections another client
88changed, `wake` and `set_offline` follow Sync Now and Work Offline, and `discard` stops the
89thread and deletes the replicas holding nothing unpublished, as when the notebook closes.
8390`Notebook::replica_path(path)` names a section's replica for either kind of notebook.
8491
8592`Section::resume(file, replica, notify)` starts from an owned `Replica` without
......@@ -130,7 +137,8 @@ An `Arc<Replica>` can own one background worker. Supply a connection factory, po
130137interval and observer; successful publications drain immediately, durable local
131138edits wake the worker, and `wake()` requests an immediate reachability retry.
132139While nothing is queued, or the queue waits on a remote that has not changed since,
133a remote whose `Remote::stamp` holds is not read again.
140a remote whose `Remote::stamp` holds is not read again; a worker a `Background` holds reads
141the stamp only when the notebook's watch wakes it.
134142Transport failures discard the old connection and retry through the factory;
135143Read contention and `NotCommitted` operations with `WouldBlock` or `ResourceBusy`
136144reuse the connection. Contended `NotCommitted` operations use randomized backoff,
......@@ -252,6 +260,12 @@ another client holds delete-pending reads as denied) is kept as `unavailable` an
252260tried again on the next discovery; the root itself, malformed storage, other failed
253261reads and ambiguous identities reject the discovery.
254262
263`Cache::discover` keeps each file's listing (size and last write time), stamp and what
264discovery took from it; the next discovery reads only the files listed otherwise, taking one
265from a copy where `Source::copy` has it as it stands (on a share, a replica whose base has the
266file's stamp), and `found(path)` gives a section's listing and stamp for `Background::watch`. A file written
267during discovery is read again next time; a failed discovery leaves the cache as it was.
268
255269Each file read must be a consistent, bounded snapshot. The result is an
256270observation across multiple files, not an atomic notebook transaction or
257271authorization to publish an edit. Refresh rejects observed topology changes and
......@@ -374,8 +388,8 @@ before retrying it. The transport does not automatically replay requests.
374388`Client::read_dir(path, entry_limit)` enumerates a directory, including the share
375389root with an empty path. It follows every response page and returns no partial
376390list on interruption, entry-limit overflow or close failure. Entries retain exact
377Unicode names, observed sizes and MS-FSCC attributes, including directory/reparse
378flags. Concurrent directory changes are not an atomic snapshot; repeated names
391Unicode names, observed sizes, last write times and MS-FSCC attributes, including
392directory/reparse flags. Concurrent directory changes are not an atomic snapshot; repeated names
379393are rejected with `ResourceBusy`. Notebook identities come from the files, not
380394directory names or sizes. Missing paths, denied access and non-directory paths
381395have distinct I/O error kinds.
crates/notebook/examples/launch_probe.rs deleted-228
......@@ -1,228 +0,0 @@
1//! Temporary: measures a notebook's launch (catalog and background first pass).
2//! `generate DIR N` | `local DIR CACHE` | `upload ADDRESS SHARE DIR ROOT` |
3//! `smb ADDRESS SHARE ROOT CACHE [SECONDS]`
4
5use notebook::{
6 discover::{Entry, Limits, Local, Source, discover},
7 session::{Background, Notebook},
8};
9use onestore::{
10 document::{Format, Layout},
11 op::{Edit, Op, SectionOp},
12 page::{
13 Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject,
14 text::new_id,
15 },
16};
17use std::{
18 io,
19 path::Path,
20 time::{Duration, Instant},
21};
22
23type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
24
25struct Counting<S>(S, u64, u64);
26
27impl<S: Source> Source for Counting<S> {
28 fn entries(&mut self, path: &str, limit: usize) -> io::Result<Vec<Entry>> {
29 self.2 += 1;
30 self.0.entries(path, limit)
31 }
32 fn read(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> {
33 let bytes = self.0.read(path, limit)?;
34 self.1 += bytes.len() as u64;
35 Ok(bytes)
36 }
37}
38
39const LIMITS: Limits = Limits {
40 entries: 100_000,
41 bytes_per_file: 256 * 1024 * 1024,
42 depth: 64,
43};
44
45fn page(count: usize) -> Result<Page> {
46 let paragraph = |i: usize| PageParagraph {
47 id: new_id().unwrap(),
48 parent: None,
49 level: 1,
50 style: None,
51 format: Format::default(),
52 content: ParagraphContent::Text(TextObject {
53 id: new_id().unwrap(),
54 date_field: None,
55 text: Paragraph::new(
56 format!("Paragraph {i} of the launch probe, with some words."),
57 Format::default(),
58 ),
59 tags: Vec::new(),
60 }),
61 lists: Vec::new(),
62 tags: Vec::new(),
63 media: Default::default(),
64 collapsed: false,
65 };
66 Ok(Page {
67 title: "Probe".into(),
68 identity: None,
69 created: None,
70 margin_origin: [0.0; 2],
71 color: None,
72 rule_lines: None,
73 objects: vec![PageObject::Outline(Outline {
74 id: new_id()?,
75 title: false,
76 min_width: None,
77 layout: Layout {
78 x: Some(36.0),
79 y: Some(86.4),
80 ..Layout::default()
81 },
82 indents: Vec::new(),
83 paragraphs: (0..count).map(paragraph).collect(),
84 unsupported: Vec::new(),
85 })],
86 definitions: Default::default(),
87 })
88}
89
90fn generate(root: &Path, count: usize) -> Result<()> {
91 let cache = tempfile::tempdir()?;
92 let creation = onestore::PageCreation::new(None, Some("First"), "Probe")?;
93 let mut notebook = Notebook::create(root, cache.path(), 0x00e4a88a, &creation)?;
94 notebook.create_group("", "Group A")?;
95 notebook.create_group("", "Group B")?;
96 for n in 0..count {
97 let folder = ["", "Group A", "Group B"][n % 3];
98 let path = notebook.create_section(folder, &format!("Section {n:03}"), &creation)?;
99 let file = root.join(&path);
100 let arena = onestore::Arena::default();
101 let mut section = onestore::Section::open(&arena, std::fs::read(&file)?)?;
102 let creation = onestore::PageCreation::new(None, Some("Probe"), "Probe")?;
103 section.apply(
104 "Probe",
105 &Edit {
106 at: 133_000_000_000_000_000,
107 ops: vec![Op::Section(SectionOp::Import {
108 creation,
109 page: page(1200)?,
110 })],
111 },
112 )?;
113 let transaction = section.seal()?.ok_or("nothing sealed")?;
114 transaction.commit_file(&file)?;
115 }
116 Ok(())
117}
118
119fn size(root: &Path) -> u64 {
120 std::fs::read_dir(root)
121 .into_iter()
122 .flatten()
123 .flatten()
124 .map(|entry| match entry.file_type() {
125 Ok(kind) if kind.is_dir() => size(&entry.path()),
126 _ => entry.metadata().map_or(0, |metadata| metadata.len()),
127 })
128 .sum()
129}
130
131#[cfg(feature = "smb")]
132fn client(address: &str, share: &str) -> Result<notebook::smb::Client> {
133 Ok(notebook::smb::Client::connect(
134 address,
135 share,
136 notebook::smb::Credentials {
137 username: "",
138 password: "",
139 domain: "",
140 },
141 Duration::from_secs(10),
142 )?)
143}
144
145#[cfg(feature = "smb")]
146fn upload(client: &notebook::smb::Client, local: &Path, remote: &str) -> Result<()> {
147 client.create_directory(remote)?;
148 for entry in std::fs::read_dir(local)? {
149 let entry = entry?;
150 let name = entry.file_name().into_string().map_err(|_| "name")?;
151 let target = format!("{remote}/{name}");
152 if entry.file_type()?.is_dir() {
153 upload(client, &entry.path(), &target)?;
154 } else {
155 client.create(&target, &std::fs::read(entry.path())?)?;
156 }
157 }
158 Ok(())
159}
160
161fn main() -> Result<()> {
162 let args: Vec<String> = std::env::args().skip(1).collect();
163 match args.iter().map(String::as_str).collect::<Vec<_>>().as_slice() {
164 ["generate", root, count] => {
165 generate(Path::new(root), count.parse()?)?;
166 println!("{} bytes", size(Path::new(root)));
167 }
168 ["local", root, cache] => {
169 let start = Instant::now();
170 let notebook = Notebook::open(root, cache)?;
171 let replicas = notebook.replicas();
172 println!(
173 "open: {:?}, {} sections",
174 start.elapsed(),
175 replicas.len()
176 );
177 let mut counting = Counting(Local::open(root)?, 0, 0);
178 discover(&mut counting, LIMITS)?;
179 println!("discovery reads {} bytes, {} listings", counting.1, counting.2);
180 }
181 #[cfg(feature = "smb")]
182 ["upload", address, share, local, root] => {
183 upload(&client(address, share)?, Path::new(local), root)?;
184 }
185 #[cfg(feature = "smb")]
186 ["smb", address, share, root, cache, rest @ ..] => {
187 let seconds: u64 = rest.first().map_or(Ok(0), |s| s.parse())?;
188 let start = Instant::now();
189 let connected = client(address, share)?;
190 let notebook = Notebook::open_smb(std::sync::Arc::new(connected), root, cache)?;
191 let replicas = notebook.replicas();
192 println!(
193 "open: {:?}, {} sections",
194 start.elapsed(),
195 replicas.len()
196 );
197 if seconds > 0 {
198 let (address, share) = (address.to_string(), share.to_string());
199 let background = Background::smb(
200 root,
201 256 * 1024 * 1024,
202 move || {
203 client(&address, &share)
204 .map_err(|error| io::Error::other(error.to_string()))
205 },
206 || {},
207 )?;
208 background.watch(replicas);
209 let start = Instant::now();
210 loop {
211 let status = background.status();
212 if status.iter().all(|(_, status)| status.synced.is_some()) {
213 println!("background first pass: {:?}", start.elapsed());
214 break;
215 }
216 if start.elapsed() > Duration::from_secs(seconds) {
217 println!("background still going after {seconds} s");
218 break;
219 }
220 std::thread::sleep(Duration::from_millis(50));
221 }
222 std::thread::sleep(Duration::from_secs(2));
223 }
224 }
225 _ => return Err("usage".into()),
226 }
227 Ok(())
228}
crates/notebook/src/background.rs+688-139
......@@ -4,11 +4,13 @@
44
55use crate::{
66 EditStatus, Error, Remote, Replica, Result,
7 session::{SyncStatus, reached},
7 discover::{Entry, Listed},
8 session::{Section, SyncStatus, reached},
89 worker::Signal,
910};
1011use onestore::Stamp;
1112use std::{
13 collections::{BTreeMap, BTreeSet},
1214 io,
1315 path::{Path, PathBuf},
1416 sync::{
......@@ -28,16 +30,30 @@ const STAGGER: Duration = Duration::from_millis(100);
2830/// How long a reported change settles before its section is checked, so the writes of one
2931/// commit cost one check.
3032const SETTLE: Duration = Duration::from_secs(1);
33/// The most entries a folder listing takes.
34const ENTRIES: usize = 100_000;
3135
3236/// Keeps each watched section in sync: a section is checked when a watch on the notebook's
3337/// folder reports it changed, when it could not be reached, and otherwise every interval.
34/// A check costs one stamp read; a section whose replica has edits waiting, or whose file
35/// moved past the replica's base, has its replica opened for the synchronization steps that
36/// publish or rebase it, then closed again. A section without a replica gets one, its offline
37/// copy, where the notebook keeps them. A replica a session holds is left to that session's
38/// worker. Dropping requests cancellation without waiting for the step in flight.
38/// On connecting, and when a watch reports a folder rather than a file, the folders are
39/// listed and only the sections listed otherwise than before are checked, as OneNote 2010
40/// reopens a notebook. A check costs one stamp read; a section whose replica has edits waiting,
41/// or whose file moved past the replica's base, has its replica opened for the synchronization
42/// steps that publish or rebase it, then closed again. A section without a replica gets one,
43/// its offline copy, where the notebook keeps them. A section a session holds is left to that
44/// session's worker, which the watch wakes. Dropping requests cancellation without waiting for
45/// the step in flight.
3946pub struct Background(Arc<Shared>);
4047
48/// A section for `Background::watch`, as its notebook knows it.
49pub struct Known {
50 /// The catalog path.
51 pub path: String,
52 pub replica: Option<PathBuf>,
53 /// How the notebook's discovery last found the file: its listing, and its stamp then.
54 pub found: Option<(Listed, Stamp)>,
55}
56
4157/// What the background thread and whatever reports changes to it share.
4258pub(crate) struct Shared {
4359 signal: Arc<Signal>,
......@@ -57,20 +73,35 @@ struct Watched {
5773 sections: Vec<Watch>,
5874 /// Sections whose file changed since `changed` was last asked.
5975 changed: Vec<String>,
76 /// Folders a watch reported without naming the file, listed at `relist`.
77 folders: BTreeSet<String>,
78 relist: Option<Instant>,
6079 /// Counts connections, so a watch that ended with an earlier one is not this one's.
6180 connection: u64,
62 /// The current connection's watch ended: changes since went unreported.
81 /// The current connection's watch ended, or none was connected: changes since went
82 /// unreported.
6383 lost: bool,
84 /// The current connection's watch reports the folder's changes.
85 reported: bool,
6486}
6587
6688struct Watch {
6789 path: String,
6890 replica: Option<PathBuf>,
69 /// The file's stamp when last reached.
91 /// The file's stamp when last read.
7092 stamp: Option<Stamp>,
93 /// How the file was listed when its folder was last listed; the stamp was read since.
94 listed: Option<Listed>,
95 /// The folder's listing since `stamp` shows the file unchanged, so the next check need not
96 /// read it.
97 current: bool,
98 /// A watch reported the file changed since its folder's listing began.
99 reported: bool,
71100 status: SyncStatus,
72101 /// When the section is next checked.
73102 due: Instant,
103 /// The worker of the session that holds the section.
104 held: Option<Weak<Signal>>,
74105}
75106
76107impl Background {
......@@ -80,18 +111,19 @@ impl Background {
80111 /// How often each section is checked where nothing reports its folder's changes.
81112 pub const UNWATCHED: Duration = Duration::from_secs(15);
82113
83 /// Starts the thread. `connect` binds each catalog path to its file, arming any watch
84 /// that reports the folder's changes through `Reports`, and answers how long a section
85 /// may go unchecked while nothing reports it; it runs again after a transport failure.
86 /// `copies` keeps an offline copy of every section.
87 pub(crate) fn start<R, B>(
114 /// Starts the thread. `connect` answers how the connection reaches each section file and
115 /// lists each folder, both by catalog path, arming any watch that reports the folder's
116 /// changes through `Reports`, and whether one does; it runs again after a transport
117 /// failure. `copies` keeps an offline copy of every section.
118 pub(crate) fn start<R, B, L>(
88119 copies: bool,
89 mut connect: impl FnMut(Reports) -> io::Result<(B, Duration)> + Send + 'static,
120 mut connect: impl FnMut(Reports) -> io::Result<((B, L), bool)> + Send + 'static,
90121 notify: impl Fn() + Send + 'static,
91122 ) -> Result<Self>
92123 where
93124 R: Remote,
94125 B: FnMut(&str) -> R,
126 L: FnMut(&str) -> io::Result<Vec<Entry>>,
95127 {
96128 let (signal, receiver) = Signal::new();
97129 let shared = Arc::new(Shared {
......@@ -105,51 +137,48 @@ impl Background {
105137 .name("onestore-background".into())
106138 .spawn(move || {
107139 let signal = &owner.signal;
108 let mut bound: Option<(B, Duration)> = None;
140 let mut bound: Option<(B, L)> = None;
109141 let mut news = false;
110142 while !signal.stopped.load(Ordering::Acquire) {
111143 if signal.offline.load(Ordering::Acquire)
112144 && !signal.requested.load(Ordering::Acquire)
113145 {
146 // Working online again lists every folder again.
147 if let Ok(mut watched) = owner.watched.lock() {
148 watched.watching(false);
149 watched.lost = true;
150 }
114151 bound = None;
115152 let _ = receiver.recv();
116153 continue;
117154 }
118155 let now = Instant::now();
119 let (path, replica, seen, connection) = {
156 let next = {
120157 let Ok(mut watched) = owner.watched.lock() else {
121158 return;
122159 };
123160 if std::mem::take(&mut watched.lost) {
124161 bound = None;
125 watched.rescan(now);
126 }
127 let due = watched.next().map(|index| &watched.sections[index]);
128 match due.filter(|watch| watch.due <= now) {
129 Some(watch) => (
130 watch.path.clone(),
131 watch.replica.clone(),
132 watch.stamp.clone(),
133 watched.connection,
134 ),
135 None => {
136 let wait = due.map(|watch| watch.due - now);
137 drop(watched);
138 signal.requested.store(false, Ordering::Release);
139 if std::mem::take(&mut news) {
140 notify();
141 }
142 let _ = match wait {
143 Some(wait) => receiver.recv_timeout(wait).ok(),
144 None => receiver.recv().ok(),
145 };
146 continue;
162 watched.watching(false);
163 for watch in &mut watched.sections {
164 watch.due = now;
147165 }
148166 }
167 watched.next(now, bound.is_some())
149168 };
150 let (bind, interval) = match &mut bound {
151 Some(bound) => bound,
152 None => {
169 let (path, replica, seen, current) = match next {
170 Next::Wait(wait) => {
171 signal.requested.store(false, Ordering::Release);
172 if std::mem::take(&mut news) {
173 notify();
174 }
175 let _ = match wait {
176 Some(wait) => receiver.recv_timeout(wait).ok(),
177 None => receiver.recv().ok(),
178 };
179 continue;
180 }
181 Next::Connect(connection) => {
153182 let reports = Reports {
154183 shared: weak.clone(),
155184 connection: connection + 1,
......@@ -162,31 +191,64 @@ impl Background {
162191 watched.connection += 1;
163192 watched.lost = false;
164193 match connected {
165 // Changes while unwatched went unreported.
166 Ok(connected) => {
167 watched.rescan(now);
168 bound = Some(connected);
194 Ok((mut files, reported)) => {
195 watched.watching(reported);
196 let folders = watched.rescanning();
197 drop(watched);
198 let listings = list(&mut files.1, folders);
199 let Ok(mut watched) = owner.watched.lock() else {
200 return;
201 };
202 watched.listed(&listings, Some(copies), Instant::now());
203 bound = Some(files);
169204 }
170205 Err(error) => {
171206 let error = Error::RemoteIo(error);
207 let retry = now + RETRY;
172208 for watch in &mut watched.sections {
173209 news |= watch.fail(&error, None);
174 watch.due = now + RETRY;
210 watch.due = watch.due.max(retry);
175211 }
212 watched.relist = watched.relist.map(|due| due.max(retry));
176213 }
177214 }
178215 continue;
179216 }
217 Next::List(folders) => {
218 let Some((_, list_folder)) = &mut bound else {
219 continue;
220 };
221 let listings = list(list_folder, folders);
222 let Ok(mut watched) = owner.watched.lock() else {
223 return;
224 };
225 watched.listed(&listings, None, Instant::now());
226 continue;
227 }
228 Next::Check {
229 path,
230 replica,
231 seen,
232 current,
233 } => (path, replica, seen, current),
180234 };
181 let (queued, outcome) =
182 step(&mut bind(&path), replica.as_deref(), seen.as_ref(), copies);
183 let interval = *interval;
235 let Some((bind, _)) = &mut bound else {
236 continue;
237 };
238 let (queued, outcome) = step(
239 &mut bind(&path),
240 replica.as_deref(),
241 seen.as_deref(),
242 current,
243 copies,
244 );
184245 if outcome.as_ref().is_err_and(disconnected) {
185246 bound = None;
186247 }
187248 let Ok(mut watched) = owner.watched.lock() else {
188249 return;
189250 };
251 let interval = watched.interval();
190252 let Watched {
191253 sections, changed, ..
192254 } = &mut *watched;
......@@ -194,6 +256,7 @@ impl Background {
194256 continue;
195257 };
196258 let now = Instant::now();
259 watch.current = false;
197260 news |= match outcome {
198261 Ok((stamp, moved)) => {
199262 watch.stamp = Some(stamp);
......@@ -217,15 +280,16 @@ impl Background {
217280 }
218281 };
219282 }
220 if owner.discard.load(Ordering::Acquire)
221 && let Ok(watched) = owner.watched.lock()
222 {
223 for replica in watched
224 .sections
225 .iter()
226 .filter_map(|watch| watch.replica.as_ref())
227 {
228 discard(replica);
283 if let Ok(mut watched) = owner.watched.lock() {
284 watched.watching(false);
285 if owner.discard.load(Ordering::Acquire) {
286 for replica in watched
287 .sections
288 .iter()
289 .filter_map(|watch| watch.replica.as_ref())
290 {
291 discard(replica);
292 }
229293 }
230294 }
231295 })?;
......@@ -248,39 +312,75 @@ impl Background {
248312 true,
249313 move |reports| {
250314 let client = Arc::new(connect()?);
251 let interval = match client.watch(&root, move |changed| match changed {
315 let reported = match client.watch(&root, move |changed| match changed {
252316 Ok(paths) => reports.touched(&paths),
253317 Err(_) => reports.lost(),
254318 }) {
255 Ok(()) => Self::BACKSTOP,
256 Err(error) if error.kind() == io::ErrorKind::Unsupported => Self::UNWATCHED,
319 Ok(()) => true,
320 Err(error) if error.kind() == io::ErrorKind::Unsupported => false,
257321 Err(error) => return Err(error),
258322 };
259 let root = root.clone();
323 let (bound, folder) = (Arc::clone(&client), root.clone());
260324 let bind = move |path: &str| {
261 let file = match root.as_str() {
325 let file = match folder.as_str() {
262326 "" => path.to_owned(),
263327 root => format!("{root}/{path}"),
264328 };
265 crate::SmbRemote::new(Arc::clone(&client), file, limit)
329 crate::SmbRemote::new(Arc::clone(&bound), file, limit)
330 };
331 let root = root.clone();
332 let list = move |folder: &str| {
333 use crate::discover::Source;
334 crate::discover::Smb::new(&client, &root)?.entries(folder, ENTRIES)
266335 };
267 Ok((bind, interval))
336 Ok(((bind, list), reported))
268337 },
269338 notify,
270339 )
271340 }
272341
273 /// Watches these sections, by catalog path and replica (`Notebook::replicas`); a section
274 /// not watched before is first checked soon after, the next one a little later.
275 pub fn watch(&self, sections: Vec<(String, Option<PathBuf>)>) {
342 /// Watches these sections (`Notebook::replicas`); a section not watched before is first
343 /// checked soon after, the next one a little later.
344 pub fn watch(&self, sections: Vec<Known>) {
276345 if let Ok(mut watched) = self.0.watched.lock() {
277346 watched.watch(sections, Instant::now());
278347 }
279348 self.0.signal.wake();
280349 }
281350
282 /// Checks the sections at or below these catalog paths soon, as a watch on the
283 /// notebook's folder reports them changed; `""` names every section.
351 /// Leaves the section at catalog `path` to `section`'s worker, which from now on the watch
352 /// on the notebook's folder wakes when the file changes, instead of the worker checking
353 /// it every few seconds. Once the section closes, this takes it over again.
354 pub fn hold(&self, path: &str, section: &Section) {
355 let replica = section.replica();
356 let Some(worker) = replica
357 .worker
358 .lock()
359 .ok()
360 .and_then(|worker| worker.upgrade())
361 else {
362 return;
363 };
364 if let Ok(mut watched) = self.0.watched.lock() {
365 let reported = watched.reported;
366 if let Some(watch) = watched.sections.iter_mut().find(|watch| watch.path == path) {
367 worker.watched.store(reported, Ordering::Release);
368 watch.held = Some(Arc::downgrade(&worker));
369 }
370 }
371 let (shared, path) = (Arc::downgrade(&self.0), path.to_owned());
372 if let Ok(mut released) = replica.released.0.lock() {
373 *released = Some(Box::new(move || {
374 if let Some(shared) = shared.upgrade() {
375 shared.released(&path);
376 }
377 }));
378 }
379 }
380
381 /// Checks the sections at or below these catalog paths soon, as a watch on the notebook's
382 /// folder reports them changed: a section's own path checks it, a folder's lists it first
383 /// and checks the sections listed otherwise. `""` names the notebook's folder.
284384 pub fn touched(&self, paths: &[String]) {
285385 self.0.touched(paths);
286386 }
......@@ -322,12 +422,13 @@ impl Background {
322422 .unwrap_or_default()
323423 }
324424
325 /// Checks every section now, working offline included (Sync Now).
425 /// Reads every section's stamp now, working offline included (Sync Now).
326426 pub fn wake(&self) {
327427 if let Ok(mut watched) = self.0.watched.lock() {
328428 let now = Instant::now();
329429 for watch in &mut watched.sections {
330430 watch.due = now;
431 watch.current = false;
331432 }
332433 }
333434 self.0.signal.requested.store(true, Ordering::Release);
......@@ -343,7 +444,8 @@ impl Background {
343444 self.0.signal.wake();
344445 }
345446
346 /// Working offline, nothing is checked until `wake`, or until working online again.
447 /// Working offline, nothing is checked until `wake`, or until working online again, which
448 /// lists every folder again.
347449 pub fn set_offline(&self, offline: bool) {
348450 self.0.signal.offline.store(offline, Ordering::Release);
349451 self.0.signal.wake();
......@@ -364,6 +466,18 @@ impl Shared {
364466 }
365467 self.signal.wake();
366468 }
469
470 /// The session holding the section at `path` let it go: it is checked now.
471 fn released(&self, path: &str) {
472 if let Ok(mut watched) = self.watched.lock()
473 && let Some(watch) = watched.sections.iter_mut().find(|watch| watch.path == path)
474 {
475 watch.held = None;
476 watch.current = false;
477 watch.due = Instant::now();
478 }
479 self.signal.wake();
480 }
367481}
368482
369483impl Reports {
......@@ -387,30 +501,50 @@ impl Reports {
387501 }
388502}
389503
504/// What the background thread does next.
505enum Next {
506 Wait(Option<Duration>),
507 /// Connects, as the current connection is `.0`, then lists every folder.
508 Connect(u64),
509 /// Lists these folders, checking the sections listed otherwise.
510 List(BTreeSet<String>),
511 Check {
512 path: String,
513 replica: Option<PathBuf>,
514 seen: Option<Box<Stamp>>,
515 current: bool,
516 },
517}
518
390519impl Watched {
391 fn watch(&mut self, sections: Vec<(String, Option<PathBuf>)>, now: Instant) {
520 fn watch(&mut self, sections: Vec<Known>, now: Instant) {
392521 let mut previous = std::mem::take(&mut self.sections);
393522 let mut new = 0;
394523 self.sections = sections
395524 .into_iter()
396525 .map(
397 |(path, replica)| match previous.iter().position(|watch| watch.path == path) {
526 |known| match previous.iter().position(|watch| watch.path == known.path) {
398527 Some(index) => Watch {
399 replica,
528 replica: known.replica,
400529 ..previous.swap_remove(index)
401530 },
402531 None => {
403532 new += 1;
533 let (listed, stamp) = known.found.unzip();
404534 Watch {
405 path,
406 replica,
407 stamp: None,
535 path: known.path,
536 replica: known.replica,
537 stamp,
538 listed,
539 current: false,
540 reported: false,
408541 status: SyncStatus {
409542 synced: None,
410543 error: None,
411544 queued: 0,
412545 },
413546 due: now + STAGGER * (new - 1),
547 held: None,
414548 }
415549 }
416550 },
......@@ -418,27 +552,181 @@ impl Watched {
418552 .collect();
419553 }
420554
421 /// Checks every section from `now`, one `STAGGER` after another.
422 fn rescan(&mut self, now: Instant) {
423 for (index, watch) in (0..).zip(&mut self.sections) {
424 watch.due = now + STAGGER * index;
555 fn interval(&self) -> Duration {
556 if self.reported {
557 Background::BACKSTOP
558 } else {
559 Background::UNWATCHED
560 }
561 }
562
563 /// Whether a watch reports the folder's changes from now on, as the held sessions' workers
564 /// learn.
565 fn watching(&mut self, reported: bool) {
566 self.reported = reported;
567 for signal in self
568 .sections
569 .iter()
570 .filter_map(|watch| watch.held.as_ref()?.upgrade())
571 {
572 signal.watched.store(reported, Ordering::Release);
573 signal.wake();
425574 }
426575 }
427576
428577 fn touched(&mut self, paths: &[String], now: Instant) {
578 for path in paths {
579 let path = path.trim_matches('/');
580 let section = self
581 .sections
582 .iter()
583 .position(|watch| watch.path.eq_ignore_ascii_case(path));
584 match section {
585 Some(index) => {
586 let watch = &mut self.sections[index];
587 watch.due = watch.due.min(now + SETTLE);
588 watch.current = false;
589 watch.reported = true;
590 }
591 None if self.sections.iter().any(|watch| within(&watch.path, path)) => {
592 self.folders.insert(path.to_owned());
593 self.relist = Some(
594 self.relist
595 .map_or(now + SETTLE, |due| due.min(now + SETTLE)),
596 );
597 }
598 None => {}
599 }
600 }
601 }
602
603 /// Every folder holding a section, to list them all, as a listing begins.
604 fn rescanning(&mut self) -> BTreeSet<String> {
605 for watch in &mut self.sections {
606 watch.reported = false;
607 }
608 self.parents(&[String::new()])
609 }
610
611 /// The folders holding the sections at or below `paths`.
612 fn parents<'a>(&self, paths: impl IntoIterator<Item = &'a String> + Clone) -> BTreeSet<String> {
613 self.sections
614 .iter()
615 .filter(|watch| {
616 paths
617 .clone()
618 .into_iter()
619 .any(|path| within(&watch.path, path))
620 })
621 .map(|watch| split(&watch.path).0.to_owned())
622 .collect()
623 }
624
625 /// Takes the folders' listings. With `rescan`, as on connecting (whether copies are kept),
626 /// every section of a listed folder is checked, from now: first those whose file lists as
627 /// when its stamp was last read, which need no reading unless for a copy, then the others
628 /// one `STAGGER` after another. Otherwise only the sections listed otherwise are, as are
629 /// those a watch reported meanwhile either way.
630 fn listed(
631 &mut self,
632 listings: &BTreeMap<String, Vec<Entry>>,
633 rescan: Option<bool>,
634 now: Instant,
635 ) {
636 let mut staggered = 0;
429637 for watch in &mut self.sections {
430 if paths.iter().any(|path| within(&watch.path, path)) {
431 watch.due = watch.due.min(now + SETTLE);
638 let (folder, name) = split(&watch.path);
639 let Some(entries) = listings.get(folder) else {
640 continue;
641 };
642 let listed = entries
643 .iter()
644 .find(|entry| entry.name.eq_ignore_ascii_case(name))
645 .map(|entry| entry.listed);
646 let unchanged = watch.stamp.is_some() && listed.is_some() && listed == watch.listed;
647 watch.listed = listed;
648 match rescan {
649 // A report while listing may name a change the listing missed.
650 Some(_) if watch.reported => watch.current = false,
651 Some(copies) => {
652 watch.current = unchanged;
653 let read = !unchanged
654 || copies
655 && watch
656 .replica
657 .as_ref()
658 .is_some_and(|replica| !replica.exists());
659 watch.due = if read {
660 staggered += 1;
661 now + STAGGER * (staggered - 1)
662 } else {
663 now
664 };
665 }
666 None if !unchanged => {
667 watch.current = false;
668 watch.due = watch.due.min(now);
669 }
670 None => {}
432671 }
433672 }
434673 }
435674
436 /// The section checked next.
437 fn next(&self) -> Option<usize> {
438 (0..self.sections.len()).min_by_key(|&index| self.sections[index].due)
675 /// What to do at `now`, waking the held sections that are due; `bound` while connected.
676 fn next(&mut self, now: Instant, bound: bool) -> Next {
677 let interval = self.interval();
678 loop {
679 let due = (0..self.sections.len()).min_by_key(|&index| self.sections[index].due);
680 let at = due.map(|index| self.sections[index].due);
681 if let Some(relist) = self.relist
682 && relist <= now
683 && at.is_none_or(|at| relist <= at)
684 {
685 if !bound {
686 return Next::Connect(self.connection);
687 }
688 let folders = std::mem::take(&mut self.folders);
689 self.relist = None;
690 return Next::List(self.parents(&folders));
691 }
692 let Some(index) = due.filter(|&index| self.sections[index].due <= now) else {
693 let wait = [at, self.relist].into_iter().flatten().min();
694 return Next::Wait(wait.map(|at| at.saturating_duration_since(now)));
695 };
696 let watch = &mut self.sections[index];
697 if let Some(worker) = watch.held.as_ref().and_then(Weak::upgrade) {
698 worker.wake();
699 watch.due = now + interval;
700 continue;
701 }
702 if !bound {
703 return Next::Connect(self.connection);
704 }
705 return Next::Check {
706 path: watch.path.clone(),
707 replica: watch.replica.clone(),
708 seen: watch.stamp.clone().map(Box::new),
709 current: watch.current,
710 };
711 }
439712 }
440713}
441714
715/// Lists each of `folders` through `list`; a folder that cannot be listed lists nothing, so
716/// that its sections are checked.
717fn list(
718 list: &mut impl FnMut(&str) -> io::Result<Vec<Entry>>,
719 folders: BTreeSet<String>,
720) -> BTreeMap<String, Vec<Entry>> {
721 folders
722 .into_iter()
723 .map(|folder| {
724 let entries = list(&folder).unwrap_or_default();
725 (folder, entries)
726 })
727 .collect()
728}
729
442730/// Deletes the replica at `replica` if it holds nothing unpublished and no one holds it.
443731fn discard(replica: &Path) {
444732 if matches!(crate::peek(replica), Ok((_, 0))) {
......@@ -460,6 +748,11 @@ fn within(section: &str, path: &str) -> bool {
460748 && matches!(section.as_bytes().get(path.len()), None | Some(b'/'))
461749}
462750
751/// A catalog path's folder and name.
752fn split(path: &str) -> (&str, &str) {
753 path.rsplit_once('/').unwrap_or(("", path))
754}
755
463756/// Whether a failed step lost the connection, rather than failing for its one file.
464757fn disconnected(error: &Error) -> bool {
465758 use io::ErrorKind::*;
......@@ -495,17 +788,22 @@ fn summary(status: &SyncStatus) -> (bool, Option<io::ErrorKind>, u64) {
495788}
496789
497790/// One section's step: how many of its edits wait (`None` while unknown, as while a session
498/// holds its replica), then the file's stamp now and whether it changed since `seen`. With
499/// `copies`, a section without a replica gets one from the file as it is now.
791/// holds its replica), then the file's stamp now and whether it changed since `seen`, which
792/// with `current` is the stamp now, unread. With `copies`, a section without a replica gets
793/// one from the file as it is now.
500794fn step<R: Remote>(
501795 remote: &mut R,
502796 replica: Option<&Path>,
503797 seen: Option<&Stamp>,
798 current: bool,
504799 copies: bool,
505800) -> (Option<u64>, Result<(Stamp, bool)>) {
506 let stamp = match remote.stamp() {
507 Ok(stamp) => stamp,
508 Err(error) => return (None, Err(Error::RemoteIo(error))),
801 let stamp = match seen.filter(|_| current) {
802 Some(seen) => seen.clone(),
803 None => match remote.stamp() {
804 Ok(stamp) => stamp,
805 Err(error) => return (None, Err(Error::RemoteIo(error))),
806 },
509807 };
510808 let moved = seen.is_some_and(|seen| *seen != stamp);
511809 let Some(replica) = replica else {
......@@ -562,6 +860,7 @@ fn step<R: Remote>(
562860#[cfg(test)]
563861mod tests {
564862 use super::*;
863 use crate::discover::EntryKind;
565864 const BACKSTOP: Duration = Background::BACKSTOP;
566865 use onestore::{CommitError, Transaction};
567866 use std::{
......@@ -569,37 +868,120 @@ mod tests {
569868 sync::mpsc::{self, Receiver},
570869 };
571870
572 fn sections(count: usize) -> Vec<(String, Option<PathBuf>)> {
871 fn path(n: usize) -> String {
872 let folder = if n.is_multiple_of(2) { "" } else { "Group/" };
873 format!("{folder}Section {n:03}.one")
874 }
875
876 fn listed(n: usize) -> Listed {
877 Listed {
878 size: 1000 + n as u64,
879 modified: 7,
880 }
881 }
882
883 fn stamp(n: usize) -> Stamp {
884 Stamp {
885 header: [n as u8; 1024],
886 length: n as u64,
887 }
888 }
889
890 /// `count` sections, each found by discovery as `listed` and `stamp` have it, with `warm`.
891 fn sections(count: usize, warm: bool) -> Vec<Known> {
573892 (0..count)
574 .map(|n| {
575 let folder = if n % 2 == 0 { "" } else { "Group/" };
576 (format!("{folder}Section {n:03}.one"), None)
893 .map(|n| Known {
894 path: path(n),
895 replica: None,
896 found: warm.then(|| (listed(n), stamp(n))),
577897 })
578898 .collect()
579899 }
580900
581 /// Runs the schedule as the thread would with every check succeeding, up to `end`,
582 /// answering which sections were checked when.
583 fn run(watched: &mut Watched, end: Instant, interval: Duration) -> Vec<(Instant, String)> {
901 /// The notebook's folders as a listing shows its first `count` sections.
902 fn listing(count: usize) -> BTreeMap<String, Vec<Entry>> {
903 let mut folders: BTreeMap<String, Vec<Entry>> = BTreeMap::new();
904 for n in 0..count {
905 let path = path(n);
906 let (folder, name) = split(&path);
907 folders.entry(folder.to_owned()).or_default().push(Entry {
908 name: name.to_owned(),
909 kind: EntryKind::File,
910 listed: listed(n),
911 });
912 }
913 folders
914 }
915
916 /// Connects at `now`, a watch reporting, and the folders listing as `listings` has them;
917 /// `meanwhile` runs while they are listed.
918 fn connect(
919 watched: &mut Watched,
920 listings: &BTreeMap<String, Vec<Entry>>,
921 now: Instant,
922 meanwhile: impl FnOnce(&mut Watched),
923 ) {
924 watched.watching(true);
925 let folders = watched.rescanning();
926 meanwhile(watched);
927 let listings = folders
928 .into_iter()
929 .map(|folder| (folder.clone(), listings[&folder].clone()))
930 .collect();
931 watched.listed(&listings, Some(false), now);
932 }
933
934 /// Runs the schedule as the thread would from `now` to `end`, every check succeeding and
935 /// every folder listing as `listings` has it, answering each check: when, which section,
936 /// and whether it read the file's stamp.
937 fn run(
938 watched: &mut Watched,
939 mut now: Instant,
940 end: Instant,
941 listings: &BTreeMap<String, Vec<Entry>>,
942 ) -> Vec<(Instant, String, bool)> {
584943 let mut checks = Vec::new();
585 while let Some(index) = watched.next() {
586 let watch = &mut watched.sections[index];
587 if watch.due > end {
588 break;
944 loop {
945 match watched.next(now, true) {
946 Next::Wait(Some(wait)) if now + wait <= end => now += wait,
947 Next::Wait(_) => return checks,
948 Next::Connect(_) => unreachable!("connected"),
949 Next::List(folders) => {
950 let listings = folders
951 .into_iter()
952 .map(|folder| (folder.clone(), listings[&folder].clone()))
953 .collect();
954 watched.listed(&listings, None, now);
955 }
956 Next::Check { path, current, .. } => {
957 let interval = watched.interval();
958 let watch = watched
959 .sections
960 .iter_mut()
961 .find(|watch| watch.path == path)
962 .unwrap();
963 watch.current = false;
964 watch.due = now + interval;
965 checks.push((now, path, !current));
966 }
589967 }
590 checks.push((watch.due, watch.path.clone()));
591 watch.due += interval;
592968 }
593 checks
594969 }
595970
596971 #[test]
597 fn an_idle_notebook_is_read_once_staggered_then_left_alone() {
972 fn a_new_notebook_is_read_once_staggered_then_left_alone() {
598973 let start = Instant::now();
599974 let mut watched = Watched::default();
600 watched.watch(sections(200), start);
601 let checks = run(&mut watched, start + BACKSTOP - SETTLE, BACKSTOP);
975 watched.watch(sections(200, false), start);
976 connect(&mut watched, &listing(200), start, |_| {});
977 let checks = run(
978 &mut watched,
979 start,
980 start + BACKSTOP - SETTLE,
981 &listing(200),
982 );
602983 assert_eq!(checks.len(), 200, "one first check each, then none");
984 assert!(checks.iter().all(|(.., read)| *read));
603985 for pair in checks.windows(2) {
604986 assert!(pair[1].0 - pair[0].0 >= STAGGER, "never a burst");
605987 }
......@@ -607,19 +989,59 @@ mod tests {
607989 // where a 15-second poll made 5,760.
608990 let day = run(
609991 &mut watched,
992 start + BACKSTOP - SETTLE,
610993 start + Duration::from_secs(86_400) - SETTLE,
611 BACKSTOP,
994 &listing(200),
612995 );
613996 assert_eq!(day.len(), 200 * (86_400 / BACKSTOP.as_secs() as usize - 1));
614997 }
615998
999 #[test]
1000 fn reopening_reads_only_the_files_listed_otherwise() {
1001 let start = Instant::now();
1002 let mut watched = Watched::default();
1003 watched.watch(sections(200, true), start);
1004 let mut listings = listing(200);
1005 // Another client wrote one section while the notebook was closed.
1006 listings.get_mut("Group").unwrap()[3].listed.modified += 1;
1007 connect(&mut watched, &listings, start, |_| {});
1008 let checks = run(&mut watched, start, start + SETTLE, &listings);
1009 assert_eq!(checks.len(), 200);
1010 let read: Vec<_> = checks.iter().filter(|(.., read)| *read).collect();
1011 assert_eq!(read.len(), 1, "{read:?}");
1012 assert_eq!(read[0].1, "Group/Section 007.one");
1013 assert!(
1014 checks.iter().all(|(at, ..)| *at == start),
1015 "nothing to read, nothing to stagger"
1016 );
1017 }
1018
1019 #[test]
1020 fn a_change_reported_while_listing_is_read_though_the_listing_missed_it() {
1021 let start = Instant::now();
1022 let mut watched = Watched::default();
1023 watched.watch(sections(4, true), start);
1024 connect(&mut watched, &listing(4), start, |watched| {
1025 watched.touched(&[path(2)], start);
1026 });
1027 let checks = run(&mut watched, start, start + SETTLE * 2, &listing(4));
1028 let read: Vec<_> = checks
1029 .into_iter()
1030 .filter(|(.., read)| *read)
1031 .map(|(_, path, _)| path)
1032 .collect();
1033 assert_eq!(read, vec![path(2)]);
1034 }
1035
6161036 #[test]
6171037 fn a_reported_change_checks_exactly_the_sections_it_names() {
6181038 let start = Instant::now();
6191039 let mut watched = Watched::default();
620 watched.watch(sections(200), start);
1040 watched.watch(sections(200, true), start);
1041 let mut listings = listing(200);
1042 connect(&mut watched, &listings, start, |_| {});
6211043 let now = start + Duration::from_secs(60);
622 run(&mut watched, now, BACKSTOP);
1044 run(&mut watched, start, now, &listings);
6231045 // A commit's several writes are reported apiece, and settle into one check.
6241046 for _ in 0..3 {
6251047 watched.touched(&["section 004.ONE".into()], now);
......@@ -628,31 +1050,81 @@ mod tests {
6281050 &["Section 004.one".into()],
6291051 now + Duration::from_millis(300),
6301052 );
631 let checks = run(&mut watched, now + Duration::from_secs(60), BACKSTOP);
632 assert_eq!(checks, vec![(now + SETTLE, "Section 004.one".to_owned())]);
633 // A group's folder names the sections in it, and "" every section.
1053 let checks = run(&mut watched, now, now + Duration::from_secs(60), &listings);
1054 assert_eq!(
1055 checks,
1056 vec![(now + SETTLE, "Section 004.one".to_owned(), true)]
1057 );
1058 // A folder, as a watch names it when it cannot name the file, is listed, and only the
1059 // section listed otherwise is checked.
6341060 let now = now + Duration::from_secs(60);
1061 listings.get_mut("Group").unwrap()[2].listed.size += 1;
6351062 watched.touched(&["Group".into()], now);
636 let group = run(&mut watched, now + SETTLE, BACKSTOP);
637 assert_eq!(group.len(), 100);
638 assert!(group.iter().all(|(_, path)| path.starts_with("Group/")));
639 watched.touched(&["Grou".into(), String::new()], now);
640 assert_eq!(run(&mut watched, now + SETTLE, BACKSTOP).len(), 200);
1063 let group = run(&mut watched, now, now + SETTLE * 2, &listings);
1064 assert_eq!(
1065 group,
1066 vec![(now + SETTLE, "Group/Section 005.one".to_owned(), true)]
1067 );
1068 // `""` lists every folder; nothing listed otherwise, nothing is checked.
1069 watched.touched(&["Grou".into(), String::new()], now + SETTLE * 2);
1070 assert!(run(&mut watched, now + SETTLE * 2, now + SETTLE * 4, &listings).is_empty());
6411071 }
6421072
1073 /// Each file's image and how many times it was written, by path.
1074 type Images = HashMap<String, (Vec<u8>, u64)>;
1075
6431076 /// Section files in memory, each stamp read reported as it happens.
6441077 #[derive(Clone)]
6451078 struct Files {
646 images: Arc<Mutex<HashMap<String, Vec<u8>>>>,
1079 images: Arc<Mutex<Images>>,
6471080 stamps: mpsc::Sender<String>,
6481081 }
6491082
1083 impl Files {
1084 fn write(&self, path: &str, image: Vec<u8>) {
1085 let mut images = self.images.lock().unwrap();
1086 let version = images.get(path).map_or(0, |(_, version)| version + 1);
1087 images.insert(path.to_owned(), (image, version));
1088 }
1089
1090 fn list(&self, folder: &str) -> Vec<Entry> {
1091 let images = self.images.lock().unwrap();
1092 images
1093 .iter()
1094 .filter(|(path, _)| split(path).0 == folder)
1095 .map(|(path, (image, version))| Entry {
1096 name: split(path).1.to_owned(),
1097 kind: EntryKind::File,
1098 listed: Listed {
1099 size: image.len() as u64,
1100 modified: *version,
1101 },
1102 })
1103 .collect()
1104 }
1105
1106 fn known(&self, path: &str) -> Known {
1107 let (image, version) = self.images.lock().unwrap()[path].clone();
1108 Known {
1109 path: path.to_owned(),
1110 replica: None,
1111 found: Some((
1112 Listed {
1113 size: image.len() as u64,
1114 modified: version,
1115 },
1116 Stamp::of(&image).unwrap(),
1117 )),
1118 }
1119 }
1120 }
1121
6501122 struct File(Files, String);
6511123
6521124 impl Remote for File {
6531125 fn read(&mut self) -> io::Result<Vec<u8>> {
6541126 let images = self.0.images.lock().unwrap();
655 Ok(images[&self.1].clone())
1127 Ok(images[&self.1].0.clone())
6561128 }
6571129
6581130 fn stamp(&mut self) -> io::Result<Stamp> {
......@@ -678,17 +1150,19 @@ mod tests {
6781150 }
6791151
6801152 #[test]
681 fn a_watch_wakes_the_section_it_reports_and_a_lost_one_rescans() {
1153 fn a_watch_wakes_the_section_it_reports_and_a_lost_one_lists_again() {
6821154 let (sender, stamps) = mpsc::channel();
6831155 let files = Files {
6841156 images: Arc::default(),
6851157 stamps: sender,
6861158 };
687 let mut paths: Vec<String> = sections(6).into_iter().map(|(path, _)| path).collect();
1159 let mut paths: Vec<String> = (0..6).map(path).collect();
6881160 paths.sort();
6891161 for path in &paths {
690 let image = onestore::create_section("s.one", path, "Author").unwrap();
691 files.images.lock().unwrap().insert(path.clone(), image);
1162 files.write(
1163 path,
1164 onestore::create_section("s.one", path, "Author").unwrap(),
1165 );
6921166 }
6931167 let (armed, watches) = mpsc::channel();
6941168 let remote = files.clone();
......@@ -696,38 +1170,113 @@ mod tests {
6961170 false,
6971171 move |reports| {
6981172 armed.send(reports).unwrap();
699 let files = remote.clone();
700 Ok((
701 move |path: &str| File(files.clone(), path.to_owned()),
702 Duration::from_secs(3600),
703 ))
1173 let (files, listed) = (remote.clone(), remote.clone());
1174 let bind = move |path: &str| File(files.clone(), path.to_owned());
1175 let list = move |folder: &str| Ok(listed.list(folder));
1176 Ok(((bind, list), true))
7041177 },
7051178 || {},
7061179 )
7071180 .unwrap();
708 background.watch(sections(6));
709 let mut first = quiet(&stamps, STAGGER * 4);
710 first.sort();
711 assert_eq!(first, paths, "each section's first check");
1181 // A section the catalog read unchanged is not read again; a new one is.
1182 let mut known: Vec<Known> = paths[1..].iter().map(|path| files.known(path)).collect();
1183 known.push(Known {
1184 path: paths[0].clone(),
1185 replica: None,
1186 found: None,
1187 });
1188 background.watch(known);
1189 assert_eq!(quiet(&stamps, STAGGER * 4), vec![paths[0].clone()]);
7121190 let watch = watches.recv().unwrap();
7131191 assert!(quiet(&stamps, SETTLE).is_empty(), "idle reads nothing");
7141192
1193 // A folder reported without its file is listed; only the file listed otherwise is read.
1194 let other = &paths[4];
1195 files.write(
1196 other,
1197 onestore::create_section("s.one", "Other", "Author").unwrap(),
1198 );
1199 watch.touched(&[String::new()]);
1200 assert_eq!(quiet(&stamps, SETTLE * 2), vec![other.clone()]);
1201 assert_eq!(background.changed(), vec![other.clone()]);
1202
7151203 let changed = &paths[3];
716 files.images.lock().unwrap().insert(
717 changed.clone(),
1204 files.write(
1205 changed,
7181206 onestore::create_section("s.one", "Changed", "Author").unwrap(),
7191207 );
7201208 watch.touched(std::slice::from_ref(changed));
7211209 assert_eq!(quiet(&stamps, SETTLE * 2), vec![changed.clone()]);
7221210 assert_eq!(background.changed(), vec![changed.clone()]);
7231211
724 // A watch that ended leaves changes unreported, so everything is read again.
1212 // A watch that ended leaves changes unreported, so every folder is listed again, and
1213 // the files listed otherwise since the last listing are read.
1214 let third = &paths[5];
1215 files.write(
1216 third,
1217 onestore::create_section("s.one", "Third", "Author").unwrap(),
1218 );
7251219 watch.lost();
7261220 let rewatch = watches.recv_timeout(SETTLE).unwrap();
727 assert_eq!(quiet(&stamps, STAGGER * 4).len(), 6);
1221 let mut read = quiet(&stamps, STAGGER * 4);
1222 read.sort();
1223 assert_eq!(read, vec![changed.clone(), third.clone()]);
7281224 // The ended watch's connection is gone; only the current one's loss counts.
7291225 watch.lost();
7301226 assert!(quiet(&stamps, SETTLE).is_empty());
7311227 drop(rewatch);
7321228 }
1229
1230 #[test]
1231 fn a_held_section_is_left_to_its_worker_which_the_watch_wakes() {
1232 let (sender, stamps) = mpsc::channel();
1233 let files = Files {
1234 images: Arc::default(),
1235 stamps: sender,
1236 };
1237 let held = path(0);
1238 files.write(
1239 &held,
1240 onestore::create_section("s.one", &held, "Author").unwrap(),
1241 );
1242 let (armed, watches) = mpsc::channel();
1243 let remote = files.clone();
1244 let background = Background::start(
1245 false,
1246 move |reports| {
1247 armed.send(reports).unwrap();
1248 let (files, listed) = (remote.clone(), remote.clone());
1249 let bind = move |path: &str| File(files.clone(), path.to_owned());
1250 let list = move |folder: &str| Ok(listed.list(folder));
1251 Ok(((bind, list), true))
1252 },
1253 || {},
1254 )
1255 .unwrap();
1256 background.watch(vec![files.known(&held)]);
1257 let watch = watches.recv().unwrap();
1258 assert!(quiet(&stamps, SETTLE).is_empty());
1259 // As `hold` leaves it to a session's worker.
1260 let (signal, woken) = Signal::new();
1261 if let Ok(mut watched) = background.0.watched.lock() {
1262 watched.sections[0].held = Some(Arc::downgrade(&signal));
1263 signal.watched.store(watched.reported, Ordering::Release);
1264 }
1265 assert!(
1266 signal.watched.load(Ordering::Acquire),
1267 "the watch reports, so the worker need not poll"
1268 );
1269 watch.touched(std::slice::from_ref(&held));
1270 assert!(woken.recv_timeout(SETTLE * 2).is_ok(), "the watch wakes it");
1271 assert!(
1272 quiet(&stamps, SETTLE).is_empty(),
1273 "the worker reads, not this"
1274 );
1275 // The watch ending hands polling back to the worker.
1276 watch.lost();
1277 assert!(woken.recv_timeout(SETTLE).is_ok());
1278 assert!(!signal.watched.load(Ordering::Acquire));
1279 drop(signal);
1280 drop(background);
1281 }
7331282}
crates/notebook/src/discover.rs+254-123
......@@ -1,10 +1,10 @@
11//! Read-only notebook discovery over a caller-supplied root.
22
33use onestore::{
4 FileType, RevisionIndex, Store,
4 FileType, RevisionIndex, Stamp, Store,
55 document::{Document, Kind},
66};
7use serde::Serialize;
7use serde::{Deserialize, Serialize};
88use std::{
99 collections::{BTreeMap, BTreeSet},
1010 io,
......@@ -64,7 +64,7 @@ pub struct Toc {
6464 pub unresolved: Vec<TocReference>,
6565}
6666
67#[derive(Debug, Serialize)]
67#[derive(Debug, Clone, Serialize, Deserialize)]
6868pub struct TocReference {
6969 /// Native TOCs can retain an identity after removing its cached filename.
7070 pub filename: Option<String>,
......@@ -83,6 +83,86 @@ pub enum EntryKind {
8383pub struct Entry {
8484 pub name: String,
8585 pub kind: EntryKind,
86 pub listed: Listed,
87}
88
89/// A file as its folder's listing shows it: enough to tell that it changed without reading it.
90#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
91pub struct Listed {
92 pub size: u64,
93 /// When it was last written, in the source's own units.
94 pub modified: u64,
95}
96
97/// Each file a discovery read, with the listing it was read under, so that the next discovery
98/// reads only the files listed otherwise, as OneNote 2010 reopens a notebook it has cached.
99#[derive(Default, Serialize, Deserialize)]
100pub struct Cache(BTreeMap<String, Read>);
101
102/// A notebook file as discovery read it.
103#[derive(Clone, Serialize, Deserialize)]
104struct Read {
105 listed: Listed,
106 /// The file's stamp: its header in hex, and its length.
107 header: String,
108 length: u64,
109 held: Held,
110}
111
112/// What discovery takes from a file.
113#[derive(Clone, Serialize, Deserialize)]
114enum Held {
115 Section {
116 name: Option<String>,
117 color: Option<u32>,
118 document: [u8; 16],
119 },
120 Locked,
121 Toc(Vec<TocReference>),
122}
123
124impl Cache {
125 /// `discover`, reading only the files listed otherwise than when this cache last read them.
126 /// A failed discovery leaves the cache as it was.
127 pub fn discover(&mut self, source: &mut impl Source, limits: Limits) -> Result<Folder, Error> {
128 let mut remaining = limits.entries;
129 let mut identities = BTreeMap::new();
130 let mut read = BTreeMap::new();
131 let folder = scan(
132 source,
133 "",
134 &limits,
135 0,
136 &mut remaining,
137 &mut identities,
138 (&self.0, &mut read),
139 )?;
140 self.0 = read;
141 Ok(folder)
142 }
143
144 /// How the file at `path` was listed when last read, and its stamp then.
145 pub fn found(&self, path: &str) -> Option<(Listed, Stamp)> {
146 let read = self.0.get(path)?;
147 Some((read.listed, read.stamp()?))
148 }
149}
150
151impl Read {
152 fn stamp(&self) -> Option<Stamp> {
153 let header: Vec<u8> = (0..self.header.len())
154 .step_by(2)
155 .map(|at| u8::from_str_radix(self.header.get(at..at + 2)?, 16).ok())
156 .collect::<Option<_>>()?;
157 Some(Stamp {
158 header: header.try_into().ok()?,
159 length: self.length,
160 })
161 }
162
163 fn file_id(&self) -> Option<[u8; 16]> {
164 Some(onestore::Header::parse(&self.stamp()?.header).ok()?.file_id)
165 }
86166}
87167
88168/// Rooted file access. Paths are relative, UTF-8, and use `/` between components.
......@@ -95,6 +175,11 @@ pub trait Source {
95175 fn read_asset(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> {
96176 self.read(path, limit)
97177 }
178 /// The file at `path` as a local copy of the file whose identity was `known` holds it,
179 /// where the copy holds it as it stands now, so that it need not be read.
180 fn copy(&mut self, _path: &str, _known: [u8; 16]) -> Option<Vec<u8>> {
181 None
182 }
98183}
99184
100185/// Reads an external file-data reference from the section's sibling `_onefiles` folder.
......@@ -170,9 +255,7 @@ pub enum Error {
170255/// Discovers rooted notebook topology within caller-specified work and size limits.
171256/// Reserved `_onefiles` directories contain payloads, not section groups.
172257pub fn discover(source: &mut impl Source, limits: Limits) -> Result<Folder, Error> {
173 let mut remaining = limits.entries;
174 let mut identities = BTreeMap::new();
175 scan(source, "", &limits, 0, &mut remaining, &mut identities)
258 Cache::default().discover(source, limits)
176259}
177260
178261fn scan(
......@@ -182,6 +265,7 @@ fn scan(
182265 depth: usize,
183266 remaining: &mut usize,
184267 identities: &mut BTreeMap<[u8; 16], String>,
268 (cached, read): (&BTreeMap<String, Read>, &mut BTreeMap<String, Read>),
185269) -> Result<Folder, Error> {
186270 if depth > limits.depth {
187271 return Err(Error::Limit { path: path.into() });
......@@ -218,7 +302,15 @@ fn scan(
218302 continue;
219303 }
220304 // A group whose own listing or TOC cannot be read is unavailable, not fatal.
221 match scan(source, &child, limits, depth + 1, remaining, identities) {
305 match scan(
306 source,
307 &child,
308 limits,
309 depth + 1,
310 remaining,
311 identities,
312 (cached, read),
313 ) {
222314 Ok(group) => result.groups.push(group),
223315 Err(error @ Error::Io { .. }) if unavailable(&error) => {
224316 result.unavailable.push(Unavailable {
......@@ -244,134 +336,102 @@ fn scan(
244336 {
245337 return Err(Error::Entry { path: child });
246338 }
247 let bytes = match source.read(&child, limits.bytes_per_file) {
248 Ok(bytes) => bytes,
249 Err(error) => {
250 let error = Error::Io {
339 let reused = cached
340 .get(&child)
341 .filter(|known| known.listed == entry.listed)
342 .and_then(|known| Some((known.file_id()?, known)));
343 let (file_id, held) = match reused {
344 Some((file_id, known)) => {
345 read.insert(child.clone(), known.clone());
346 (file_id, Ok(known.held.clone()))
347 }
348 None => {
349 let copied = cached
350 .get(&child)
351 .and_then(Read::file_id)
352 .and_then(|known| source.copy(&child, known));
353 let bytes =
354 match copied.map_or_else(|| source.read(&child, limits.bytes_per_file), Ok) {
355 Ok(bytes) => bytes,
356 Err(error) => {
357 let error = Error::Io {
358 path: child.clone(),
359 error,
360 };
361 if expected == FileType::Section && unavailable(&error) {
362 result.unavailable.push(Unavailable {
363 path: child,
364 group: false,
365 error: error.to_string(),
366 });
367 continue;
368 }
369 return Err(error);
370 }
371 };
372 if bytes.len() > limits.bytes_per_file {
373 return Err(Error::Limit { path: child });
374 }
375 let store = Store::parse(&bytes).map_err(|error| Error::Document {
251376 path: child.clone(),
252377 error,
253 };
254 if expected == FileType::Section && unavailable(&error) {
255 result.unavailable.push(Unavailable {
378 })?;
379 if store.header.file_type != expected || !store.checksum_mismatches.is_empty() {
380 return Err(Error::Document {
256381 path: child,
257 group: false,
258 error: error.to_string(),
382 error: onestore::Error {
383 offset: 0,
384 message: "Unexpected file type or checksum mismatch",
385 },
259386 });
260 continue;
261387 }
262 return Err(error);
388 let held = held(&store, expected);
389 if let Ok(held) = &held {
390 read.insert(
391 child.clone(),
392 Read {
393 listed: entry.listed,
394 header: bytes[..1024].iter().map(|b| format!("{b:02x}")).collect(),
395 length: bytes.len() as u64,
396 held: held.clone(),
397 },
398 );
399 }
400 (store.header.file_id, held)
263401 }
264402 };
265 if bytes.len() > limits.bytes_per_file {
266 return Err(Error::Limit { path: child });
267 }
268 let store = Store::parse(&bytes).map_err(|error| Error::Document {
269 path: child.clone(),
270 error,
271 })?;
272 if store.header.file_type != expected || !store.checksum_mismatches.is_empty() {
273 return Err(Error::Document {
274 path: child,
275 error: onestore::Error {
276 offset: 0,
277 message: "Unexpected file type or checksum mismatch",
403 match held {
404 Ok(Held::Section {
405 name,
406 color,
407 document,
408 }) => result.sections.push(Section {
409 path: child.clone(),
410 file_id,
411 state: SectionState::Readable {
412 name,
413 color,
414 document,
278415 },
279 });
280 }
281 let file_id = store.header.file_id;
282 let parsed = (|| {
283 let index = RevisionIndex::parse(&store)?;
284 let document = Document::parse(&index)?;
285 let revision = document.active(document.root)?;
286 if expected == FileType::Section {
287 if revision
288 .roots
289 .get(&1)
290 .and_then(|id| revision.nodes.get(id))
291 .is_some_and(|node| matches!(node.kind, Kind::Encrypted { .. }))
292 {
293 result.sections.push(Section {
294 path: child.clone(),
295 file_id,
296 state: SectionState::Locked,
297 });
298 return Ok(());
299 }
300 index.validate_current()?;
301 document.pages()?;
302 let (name, color) = revision
303 .roots
304 .get(&2)
305 .and_then(|id| revision.nodes.get(id))
306 .map_or((None, None), |node| match &node.kind {
307 Kind::SectionMetadata { name, color } => (name.clone(), *color),
308 _ => (None, None),
309 });
310 result.sections.push(Section {
311 path: child.clone(),
312 file_id,
313 state: SectionState::Readable {
314 name,
315 color: color.filter(|color| *color != 0xffff_ffff),
316 document: index.root.guid,
317 },
318 });
319 } else {
320 index.validate_current()?;
321 let node = revision.roots.get(&1).and_then(|id| revision.nodes.get(id));
322 let Some(Kind::Toc { entries, .. }) = node.map(|node| &node.kind) else {
323 return Err(onestore::Error {
324 offset: 0,
325 message: "Missing notebook TOC root",
326 });
327 };
328 let mut seen = BTreeSet::new();
329 let mut unresolved = Vec::new();
330 for id in entries {
331 let Some(Kind::Toc {
332 filename,
333 identity: Some(file),
334 order: Some(order),
335 ..
336 }) = revision.nodes.get(id).map(|node| &node.kind)
337 else {
338 return Err(onestore::Error {
339 offset: 0,
340 message: "Incomplete notebook TOC reference",
341 });
342 };
343 if filename.as_deref().is_some_and(|name| !component(name))
344 || !seen.insert(*file)
345 {
346 return Err(onestore::Error {
347 offset: 0,
348 message: "Invalid or duplicated notebook TOC reference",
349 });
350 }
351 unresolved.push(TocReference {
352 filename: filename.clone(),
353 file: *file,
354 order: *order,
355 });
356 }
416 }),
417 Ok(Held::Locked) => result.sections.push(Section {
418 path: child.clone(),
419 file_id,
420 state: SectionState::Locked,
421 }),
422 Ok(Held::Toc(unresolved)) => {
357423 result.toc = Some(Toc {
358424 filename: entry.name.clone(),
359425 file_id,
360426 unresolved,
361 });
362 }
363 Ok(())
364 })();
365 if let Err(error) = parsed {
366 if expected == FileType::Section {
367 result.sections.push(Section {
368 path: child.clone(),
369 file_id,
370 state: SectionState::Unreadable(error),
371 });
372 } else {
373 return Err(Error::Document { path: child, error });
427 })
374428 }
429 Err(error) if expected == FileType::Section => result.sections.push(Section {
430 path: child.clone(),
431 file_id,
432 state: SectionState::Unreadable(error),
433 }),
434 Err(error) => return Err(Error::Document { path: child, error }),
375435 }
376436 if let Some(first) = identities.insert(file_id, child.clone()) {
377437 return Err(Error::DuplicateIdentity {
......@@ -429,12 +489,83 @@ fn scan(
429489 error,
430490 })?;
431491 observed.sort();
432 if observed != listing {
492 let names = |entries: &[Entry]| {
493 entries
494 .iter()
495 .map(|entry| (entry.name.clone(), entry.kind))
496 .collect::<Vec<_>>()
497 };
498 // A file written meanwhile lists otherwise, and is read again next time.
499 if names(&observed) != names(&listing) {
433500 return Err(Error::Changed { path: path.into() });
434501 }
435502 Ok(result)
436503}
437504
505/// What discovery takes from the file `store` holds, a section or a TOC as `expected`.
506fn held(store: &Store, expected: FileType) -> Result<Held, onestore::Error> {
507 let index = RevisionIndex::parse(store)?;
508 let document = Document::parse(&index)?;
509 let revision = document.active(document.root)?;
510 let root = |id| {
511 revision
512 .roots
513 .get(&id)
514 .and_then(|id| revision.nodes.get(id))
515 };
516 if expected == FileType::Section {
517 if root(1).is_some_and(|node| matches!(node.kind, Kind::Encrypted { .. })) {
518 return Ok(Held::Locked);
519 }
520 index.validate_current()?;
521 document.pages()?;
522 let (name, color) = root(2).map_or((None, None), |node| match &node.kind {
523 Kind::SectionMetadata { name, color } => (name.clone(), *color),
524 _ => (None, None),
525 });
526 return Ok(Held::Section {
527 name,
528 color: color.filter(|color| *color != 0xffff_ffff),
529 document: index.root.guid,
530 });
531 }
532 index.validate_current()?;
533 let Some(Kind::Toc { entries, .. }) = root(1).map(|node| &node.kind) else {
534 return Err(onestore::Error {
535 offset: 0,
536 message: "Missing notebook TOC root",
537 });
538 };
539 let mut seen = BTreeSet::new();
540 let mut unresolved = Vec::new();
541 for id in entries {
542 let Some(Kind::Toc {
543 filename,
544 identity: Some(file),
545 order: Some(order),
546 ..
547 }) = revision.nodes.get(id).map(|node| &node.kind)
548 else {
549 return Err(onestore::Error {
550 offset: 0,
551 message: "Incomplete notebook TOC reference",
552 });
553 };
554 if filename.as_deref().is_some_and(|name| !component(name)) || !seen.insert(*file) {
555 return Err(onestore::Error {
556 offset: 0,
557 message: "Invalid or duplicated notebook TOC reference",
558 });
559 }
560 unresolved.push(TocReference {
561 filename: filename.clone(),
562 file: *file,
563 order: *order,
564 });
565 }
566 Ok(Held::Toc(unresolved))
567}
568
438569/// Access denied or a file gone mid-listing; a lost connection stays fatal.
439570fn unavailable(error: &Error) -> bool {
440571 matches!(error, Error::Io { error, .. } if matches!(
crates/notebook/src/discover/source.rs+38-2
......@@ -1,4 +1,4 @@
1use super::{Entry, EntryKind, Source, component};
1use super::{Entry, EntryKind, Listed, Source, component};
22use std::{
33 io,
44 path::{Path, PathBuf},
......@@ -43,6 +43,7 @@ impl Source for Local {
4343 .into_string()
4444 .map_err(|_| io::ErrorKind::InvalidData)?;
4545 let kind = entry.file_type()?;
46 let metadata = entry.metadata()?;
4647 entries.push(Entry {
4748 name,
4849 kind: if kind.is_dir() {
......@@ -52,6 +53,13 @@ impl Source for Local {
5253 } else {
5354 EntryKind::Other
5455 },
56 listed: Listed {
57 size: metadata.len(),
58 modified: metadata
59 .modified()?
60 .duration_since(std::time::UNIX_EPOCH)
61 .map_or(0, |since| since.as_nanos() as u64),
62 },
5563 });
5664 }
5765 Ok(entries)
......@@ -67,6 +75,8 @@ impl Source for Local {
6775pub struct Smb<'a> {
6876 client: &'a crate::smb::Client,
6977 root: String,
78 /// Where the sections' replicas are, named by file identity.
79 copies: Option<PathBuf>,
7080}
7181
7282#[cfg(feature = "smb")]
......@@ -76,7 +86,19 @@ impl<'a> Smb<'a> {
7686 if !root.is_empty() && !root.split('/').all(component) {
7787 return Err(io::ErrorKind::InvalidInput.into());
7888 }
79 Ok(Self { client, root })
89 Ok(Self {
90 client,
91 root,
92 copies: None,
93 })
94 }
95
96 /// Takes a section whose file stands as its replica's base in `folder` from the replica.
97 pub(crate) fn copies(self, folder: PathBuf) -> Self {
98 Self {
99 copies: Some(folder),
100 ..self
101 }
80102 }
81103
82104 fn path(&self, relative: &str) -> io::Result<String> {
......@@ -107,6 +129,10 @@ impl Source for Smb<'_> {
107129 } else {
108130 EntryKind::File
109131 },
132 listed: Listed {
133 size: entry.size,
134 modified: entry.modified,
135 },
110136 })
111137 .collect())
112138 }
......@@ -118,4 +144,14 @@ impl Source for Smb<'_> {
118144 fn read_asset(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> {
119145 self.client.read_asset(&self.path(path)?, limit)
120146 }
147
148 /// Reads the file's stamp, then its replica's base if that has the same.
149 fn copy(&mut self, path: &str, known: [u8; 16]) -> Option<Vec<u8>> {
150 let replica = crate::session::replica_file(self.copies.as_ref()?, &known);
151 if !replica.exists() {
152 return None;
153 }
154 let stamp = self.client.stamp(&self.path(path).ok()?).ok()?;
155 crate::copied(&replica, &stamp).ok().flatten()
156 }
121157}
crates/notebook/src/lib.rs+32-2
......@@ -99,6 +99,20 @@ pub struct Replica {
9999 worker: Arc<Mutex<std::sync::Weak<worker::Signal>>>,
100100 /// The section's root object space, which names the document.
101101 root: ExGuid,
102 /// Last, so that it runs once the fields above have closed the cache.
103 released: Released,
104}
105
106/// Runs once the replica is released and its cache closed, as `Background::hold` asks.
107#[derive(Default)]
108struct Released(Mutex<Option<Box<dyn FnOnce() + Send>>>);
109
110impl Drop for Released {
111 fn drop(&mut self) {
112 if let Some(released) = self.0.get_mut().ok().and_then(Option::take) {
113 released();
114 }
115 }
102116}
103117
104118impl Replica {
......@@ -210,6 +224,7 @@ impl Replica {
210224 synchronization: Mutex::new(()),
211225 worker,
212226 root,
227 released: Released::default(),
213228 })
214229 }
215230
......@@ -340,8 +355,8 @@ fn validate(source: &[u8]) -> Result<ExGuid> {
340355 Ok(onestore::Section::open(&arena, source.to_vec())?.root())
341356}
342357
343/// A closed cache's base stamp and how many edits wait, without opening the replica.
344fn peek(path: &Path) -> Result<(onestore::Stamp, u64)> {
358/// A closed cache, without opening the replica.
359fn closed(path: &Path) -> Result<Connection> {
345360 let connection = cache_connection(path)?;
346361 let application: u32 =
347362 connection.pragma_query_value(None, "application_id", |row| row.get(0))?;
......@@ -349,12 +364,27 @@ fn peek(path: &Path) -> Result<(onestore::Stamp, u64)> {
349364 if application != APPLICATION_ID || version != schema::VERSION {
350365 return Err(io::Error::from(io::ErrorKind::InvalidData).into());
351366 }
367 Ok(connection)
368}
369
370/// A closed cache's base stamp and how many edits wait.
371fn peek(path: &Path) -> Result<(onestore::Stamp, u64)> {
372 let connection = closed(path)?;
352373 let base = base::stamp(&connection, base::Image::Base)?
353374 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image"))?;
354375 let queued: i64 = connection.query_row("SELECT count(*) FROM edits", [], |row| row.get(0))?;
355376 Ok((base, unsigned(queued)?))
356377}
357378
379/// A closed cache's base image, if its stamp is `stamp`: the section file as it stands.
380fn copied(path: &Path, stamp: &onestore::Stamp) -> Result<Option<Vec<u8>>> {
381 let connection = closed(path)?;
382 if base::stamp(&connection, base::Image::Base)?.as_ref() != Some(stamp) {
383 return Ok(None);
384 }
385 base::read(&connection, base::Image::Base)
386}
387
358388fn pending(connection: &Connection) -> Result<Vec<PendingEdit>> {
359389 queue::load(connection, None)
360390}
crates/notebook/src/session.rs+100-31
......@@ -1,7 +1,7 @@
11//! The application's view of a notebook: sections opened through a local replica that
22//! publishes their edits to the section file in the background.
33
4pub use crate::background::Background;
4pub use crate::background::{Background, Known};
55use crate::{
66 EditStatus, Error, PendingEdit, Remote, Replica, Resolution, Result, SyncWorker, discover,
77};
......@@ -72,7 +72,14 @@ fn entries(folder: &discover::Folder) -> Entries {
7272/// Where a notebook's files live: a mounted directory or an SMB share. Paths are catalog
7373/// paths, `/`-separated and relative to the notebook root.
7474pub trait Storage: Send + Sync {
75 fn discover(&self, limits: discover::Limits) -> Result<discover::Folder>;
75 /// Reads the notebook's catalog, reading only the files `cache` holds as listed otherwise.
76 fn discover(
77 &self,
78 cache: &mut discover::Cache,
79 limits: discover::Limits,
80 ) -> Result<discover::Folder>;
81 /// Where the notebook lives, which names its catalog's cache.
82 fn location(&self) -> String;
7683 fn exists(&self, path: &str) -> bool;
7784 fn read(&self, path: &str) -> Result<Vec<u8>>;
7885 /// Creates a file holding `bytes`; an existing file is an error.
......@@ -110,11 +117,16 @@ impl Directory {
110117}
111118
112119impl Storage for Directory {
113 fn discover(&self, limits: discover::Limits) -> Result<discover::Folder> {
114 Ok(discover::discover(
115 &mut discover::Local::open(&self.0)?,
116 limits,
117 )?)
120 fn discover(
121 &self,
122 cache: &mut discover::Cache,
123 limits: discover::Limits,
124 ) -> Result<discover::Folder> {
125 Ok(cache.discover(&mut discover::Local::open(&self.0)?, limits)?)
126 }
127
128 fn location(&self) -> String {
129 self.0.to_string_lossy().into_owned()
118130 }
119131
120132 fn exists(&self, path: &str) -> bool {
......@@ -162,6 +174,8 @@ impl Storage for Directory {
162174pub struct Share {
163175 client: Arc<crate::smb::Client>,
164176 root: String,
177 /// Where the sections' replicas are.
178 copies: PathBuf,
165179}
166180
167181#[cfg(feature = "smb")]
......@@ -177,11 +191,17 @@ impl Share {
177191
178192#[cfg(feature = "smb")]
179193impl Storage for Share {
180 fn discover(&self, limits: discover::Limits) -> Result<discover::Folder> {
181 Ok(discover::discover(
182 &mut discover::Smb::new(&self.client, &self.root)?,
183 limits,
184 )?)
194 fn discover(
195 &self,
196 cache: &mut discover::Cache,
197 limits: discover::Limits,
198 ) -> Result<discover::Folder> {
199 let mut source = discover::Smb::new(&self.client, &self.root)?.copies(self.copies.clone());
200 Ok(cache.discover(&mut source, limits)?)
201 }
202
203 fn location(&self) -> String {
204 format!("{}/{}", self.client.share(), self.root)
185205 }
186206
187207 fn exists(&self, path: &str) -> bool {
......@@ -240,6 +260,9 @@ pub struct Notebook {
240260 root: Option<PathBuf>,
241261 cache: PathBuf,
242262 catalog: discover::Folder,
263 /// What reading the catalog took from each file, kept at `listing` in the cache.
264 read: discover::Cache,
265 listing: PathBuf,
243266}
244267
245268impl Notebook {
......@@ -274,7 +297,16 @@ impl Notebook {
274297 cache: impl AsRef<Path>,
275298 ) -> Result<Self> {
276299 let root = root.replace('\\', "/");
277 Self::with(Box::new(Share { client, root }), None, cache)
300 let copies = cache.as_ref().join("smb");
301 Self::with(
302 Box::new(Share {
303 client,
304 root,
305 copies,
306 }),
307 None,
308 cache,
309 )
278310 }
279311
280312 fn with(
......@@ -283,14 +315,40 @@ impl Notebook {
283315 cache: impl AsRef<Path>,
284316 ) -> Result<Self> {
285317 let cache = cache.as_ref().to_path_buf();
286 std::fs::create_dir_all(&cache)?;
287 let catalog = storage.discover(LIMITS)?;
288 Ok(Self {
318 let listings = cache.join("listings");
319 std::fs::create_dir_all(&listings)?;
320 let name: String = <sha2::Sha256 as sha2::Digest>::digest(storage.location())[..16]
321 .iter()
322 .map(|byte| format!("{byte:02x}"))
323 .collect();
324 let listing = listings.join(format!("{name}.json"));
325 let mut read = std::fs::read(&listing)
326 .ok()
327 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
328 .unwrap_or_default();
329 let catalog = storage.discover(&mut read, LIMITS)?;
330 let notebook = Self {
289331 storage,
290332 root,
291333 cache,
292334 catalog,
293 })
335 read,
336 listing,
337 };
338 notebook.keep_listing();
339 Ok(notebook)
340 }
341
342 /// Keeps what the catalog read for the next time the notebook opens; failing costs only
343 /// reading the files again.
344 fn keep_listing(&self) {
345 let _ = (|| -> Result<()> {
346 let folder = self.listing.parent().unwrap_or(Path::new("."));
347 let mut file = tempfile::NamedTempFile::new_in(folder)?;
348 serde_json::to_writer(&mut file, &self.read).map_err(io::Error::from)?;
349 file.persist(&self.listing).map_err(|error| error.error)?;
350 Ok(())
351 })();
294352 }
295353
296354 pub fn catalog(&self) -> &discover::Folder {
......@@ -301,7 +359,8 @@ impl Notebook {
301359 /// file identity so a renamed or moved section stays the same section. A failed read
302360 /// keeps the previous catalog: an unreachable notebook is not an empty one.
303361 pub fn refresh(&mut self) -> Result<Vec<Change>> {
304 let catalog = self.storage.discover(LIMITS)?;
362 let catalog = self.storage.discover(&mut self.read, LIMITS)?;
363 self.keep_listing();
305364 let (before, before_orders) = entries(&self.catalog);
306365 let (after, after_orders) = entries(&catalog);
307366 let mut changes = Vec::new();
......@@ -900,15 +959,18 @@ impl Notebook {
900959 })
901960 }
902961
903 /// Every readable section's catalog path and replica, for `Background::watch`.
904 pub fn replicas(&self) -> Vec<(String, Option<PathBuf>)> {
962 /// Every readable section, for `Background::watch`.
963 pub fn replicas(&self) -> Vec<Known> {
905964 let mut sections = Vec::new();
906965 let mut folders = vec![&self.catalog];
907966 while let Some(folder) = folders.pop() {
908967 for section in &folder.sections {
909968 if matches!(section.state, discover::SectionState::Readable { .. }) {
910 let replica = self.replica_path(&section.path).ok();
911 sections.push((section.path.clone(), replica));
969 sections.push(Known {
970 path: section.path.clone(),
971 replica: self.replica_path(&section.path).ok(),
972 found: self.read.found(&section.path),
973 });
912974 }
913975 }
914976 folders.extend(folder.groups.iter().rev());
......@@ -917,23 +979,24 @@ impl Notebook {
917979 }
918980
919981 /// Keeps the sections of a mounted notebook in sync while they are not open
920 /// (`Background`): each file is checked every `interval`, and sooner when
921 /// `Background::touched` reports it changed. `copies` keeps an offline copy of every
922 /// section, for a folder that is not on this computer.
982 /// (`Background`): a file is checked when `Background::touched` reports it changed, and
983 /// otherwise every `Background::BACKSTOP` when `watched`, as a host that watches the
984 /// folder reports every change, or else every `Background::UNWATCHED`. `copies` keeps an
985 /// offline copy of every section, for a folder that is not on this computer.
923986 pub fn background(
924987 &self,
925 interval: Duration,
988 watched: bool,
926989 copies: bool,
927990 notify: impl Fn() + Send + 'static,
928991 ) -> Result<Background> {
929 self.background_with(interval, copies, |file| FileRemote(file.to_owned()), notify)
992 self.background_with(watched, copies, |file| FileRemote(file.to_owned()), notify)
930993 }
931994
932995 /// `background`, reaching each section file through the remote `remote` makes for it
933996 /// (`section_with`).
934997 pub fn background_with<R: Remote + 'static>(
935998 &self,
936 interval: Duration,
999 watched: bool,
9371000 copies: bool,
9381001 remote: impl Fn(&Path) -> R + Clone + Send + 'static,
9391002 notify: impl Fn() + Send + 'static,
......@@ -948,8 +1011,14 @@ impl Notebook {
9481011 Background::start(
9491012 copies,
9501013 move |_| {
951 let (root, remote) = (root.clone(), remote.clone());
952 Ok((move |path: &str| remote(&root.join(path)), interval))
1014 let (folder, remote) = (root.clone(), remote.clone());
1015 let bind = move |path: &str| remote(&folder.join(path));
1016 let mut local = discover::Local::open(&root)?;
1017 let list = move |folder: &str| {
1018 use discover::Source;
1019 local.entries(folder, LIMITS.entries)
1020 };
1021 Ok(((bind, list), watched))
9531022 },
9541023 notify,
9551024 )
......@@ -1085,7 +1154,7 @@ fn catalog_path(folder: &str, name: &str) -> String {
10851154}
10861155
10871156/// The replica in `cache` of the section `identity` names.
1088fn replica_file(cache: &Path, identity: &[u8; 16]) -> PathBuf {
1157pub(crate) fn replica_file(cache: &Path, identity: &[u8; 16]) -> PathBuf {
10891158 let name: String = identity.iter().map(|byte| format!("{byte:02x}")).collect();
10901159 cache.join(format!("{name}.sqlite"))
10911160}
crates/notebook/src/smb/directory.rs+8-2
......@@ -9,6 +9,8 @@ use std::collections::BTreeMap;
99pub struct DirectoryEntry {
1010 pub name: String,
1111 pub size: u64,
12 /// LastWriteTime, FILETIME.
13 pub modified: u64,
1214 /// MS-FSCC file attributes; directory is 0x10 and reparse point is 0x400.
1315 pub attributes: u32,
1416}
......@@ -71,7 +73,7 @@ impl Client {
7173 };
7274 for entry in decode(&response.output_buffer)? {
7375 if entries
74 .insert(entry.name, (entry.size, entry.attributes))
76 .insert(entry.name, (entry.size, entry.modified, entry.attributes))
7577 .is_some()
7678 {
7779 return Err(io::ErrorKind::ResourceBusy.into());
......@@ -89,9 +91,10 @@ impl Client {
8991 Ok(entries
9092 .into_iter()
9193 .filter(|(name, _)| name != "." && name != "..")
92 .map(|(name, (size, attributes))| DirectoryEntry {
94 .map(|(name, (size, modified, attributes))| DirectoryEntry {
9395 name,
9496 size,
97 modified,
9598 attributes,
9699 })
97100 .collect())
......@@ -199,6 +202,7 @@ fn decode(mut bytes: &[u8]) -> io::Result<Vec<DirectoryEntry>> {
199202 entries.push(DirectoryEntry {
200203 name,
201204 size: size.try_into().map_err(|_| io::ErrorKind::InvalidData)?,
205 modified: u64::from_le_bytes(bytes[24..32].try_into().unwrap()),
202206 attributes: u32::from_le_bytes(bytes[56..60].try_into().unwrap()),
203207 });
204208 if next == 0 {
......@@ -214,6 +218,7 @@ mod tests {
214218
215219 fn record(name: &str, size: u64, attributes: u32) -> Vec<u8> {
216220 let mut bytes = vec![0; 64];
221 bytes[24..32].copy_from_slice(&(size + 7).to_le_bytes());
217222 bytes[40..48].copy_from_slice(&size.to_le_bytes());
218223 bytes[56..60].copy_from_slice(&attributes.to_le_bytes());
219224 let name: Vec<_> = name.encode_utf16().flat_map(u16::to_le_bytes).collect();
......@@ -240,6 +245,7 @@ mod tests {
240245 expected.push(DirectoryEntry {
241246 name,
242247 size,
248 modified: size + 7,
243249 attributes,
244250 });
245251 }
crates/notebook/src/smb/mod.rs+5
......@@ -48,6 +48,11 @@ pub struct Client {
4848}
4949
5050impl Client {
51 /// The server and share, as `//server/share`.
52 pub(crate) fn share(&self) -> String {
53 format!("//{}/{}", self.tree.server, self.tree.share_name)
54 }
55
5156 pub fn connect(
5257 address: &str,
5358 share: &str,
crates/notebook/src/worker.rs+20-3
......@@ -18,6 +18,9 @@ pub(super) struct Signal {
1818 synced: AtomicU64,
1919 /// Asked for by `wake`: steps run while offline until one leaves nothing to do.
2020 pub(crate) requested: AtomicBool,
21 /// A watch on the file's folder wakes the worker when the file changes, so an idle worker
22 /// waits for that instead of checking the file on its own (`Background::hold`).
23 pub(crate) watched: AtomicBool,
2124 sender: SyncSender<()>,
2225}
2326
......@@ -29,6 +32,7 @@ impl Signal {
2932 offline: AtomicBool::new(false),
3033 synced: AtomicU64::new(0),
3134 requested: AtomicBool::new(false),
35 watched: AtomicBool::new(false),
3236 sender,
3337 };
3438 (Arc::new(signal), receiver)
......@@ -91,7 +95,8 @@ impl Drop for SyncWorker {
9195
9296impl Replica {
9397 /// Starts one worker, reconnecting through `connect` after transport failures.
94 /// Local edits wake it; `interval` controls idle polling and transport retries. While
98 /// Local edits wake it; `interval` controls idle polling, unless a watch wakes it
99 /// (`Background::hold`), and transport retries. While
95100 /// nothing is queued, or the queue waits on a remote that has not changed since, a
96101 /// remote whose `stamp` holds is not read again.
97102 /// Contended operations returning `NotCommitted` also back off by up to one second.
......@@ -134,6 +139,15 @@ impl Replica {
134139 // The first step, and the first after a failure, always runs, so `observe`
135140 // hears that the remote is reachable and what state the queue is in.
136141 let mut reported = false;
142 // With nothing to do: a watched worker waits for the watch, any other looks
143 // again after `interval`.
144 let rest = || {
145 if worker_signal.watched.load(Ordering::Acquire) {
146 let _ = receiver.recv();
147 } else {
148 let _ = receiver.recv_timeout(interval);
149 }
150 };
137151 while !worker_signal.stopped.load(Ordering::Acquire) {
138152 if worker_signal.offline.load(Ordering::Acquire)
139153 && !worker_signal.requested.load(Ordering::Acquire)
......@@ -148,7 +162,7 @@ impl Replica {
148162 if reported && replica.settled(remote).unwrap_or(false) {
149163 worker_signal.requested.store(false, Ordering::Release);
150164 worker_signal.synced.store(crate::now(), Ordering::Release);
151 let _ = receiver.recv_timeout(interval);
165 rest();
152166 continue;
153167 }
154168 let result = replica.sync_once(remote);
......@@ -167,6 +181,7 @@ impl Replica {
167181 worker_signal.synced.store(crate::now(), Ordering::Release);
168182 }
169183 observe(&result);
184 let idle = matches!(result, Ok(Synced { edit: None, .. }));
170185 match result {
171186 Ok(Synced {
172187 edit: Some((_, EditStatus::Published { .. })),
......@@ -209,7 +224,9 @@ impl Replica {
209224 Err(error) => return Err(error),
210225 }
211226 worker_signal.requested.store(false, Ordering::Release);
212 if !worker_signal.stopped.load(Ordering::Acquire) {
227 if idle {
228 rest();
229 } else if !worker_signal.stopped.load(Ordering::Acquire) {
213230 let _ = receiver.recv_timeout(interval);
214231 }
215232 }
crates/notebook/tests/discovery.rs+80-1
......@@ -1,4 +1,4 @@
1use notebook::discover::{Entry, Error, Limits, Local, Source, discover};
1use notebook::discover::{Cache, Entry, Error, Limits, Local, Source, discover};
22use std::{fs, io, path::Path};
33
44fn limits() -> Limits {
......@@ -357,3 +357,82 @@ fn unreadable_children_are_listed_as_unavailable_and_retried() {
357357 fs::set_permissions(&root, fs::Permissions::from_mode(0o755)).unwrap();
358358 assert!(denied.is_err());
359359}
360
361/// A local folder that notes each file read, with copies of some files by identity.
362struct Counting {
363 source: Local,
364 read: Vec<String>,
365 copies: Vec<([u8; 16], Vec<u8>)>,
366}
367
368impl Source for Counting {
369 fn entries(&mut self, path: &str, limit: usize) -> io::Result<Vec<Entry>> {
370 self.source.entries(path, limit)
371 }
372
373 fn read(&mut self, path: &str, limit: usize) -> io::Result<Vec<u8>> {
374 self.read.push(path.to_owned());
375 self.source.read(path, limit)
376 }
377
378 fn copy(&mut self, _: &str, known: [u8; 16]) -> Option<Vec<u8>> {
379 let (_, copy) = self
380 .copies
381 .iter()
382 .find(|(identity, _)| *identity == known)?;
383 Some(copy.clone())
384 }
385}
386
387#[test]
388fn a_cached_discovery_reads_only_the_files_listed_otherwise() {
389 let root = tempfile::tempdir().unwrap();
390 fixture(root.path());
391 let two = |title| onestore::create_section("two.one", title, "Fixture").unwrap();
392 fs::write(root.path().join("two.one"), two("Two")).unwrap();
393 let mut source = Counting {
394 source: Local::open(root.path()).unwrap(),
395 read: Vec::new(),
396 copies: Vec::new(),
397 };
398 let mut cache = Cache::default();
399 let first = cache.discover(&mut source, limits()).unwrap();
400 assert_eq!(std::mem::take(&mut source.read).len(), 3);
401 let again = cache.discover(&mut source, limits()).unwrap();
402 assert!(source.read.is_empty(), "{:?}", source.read);
403 assert_eq!(
404 serde_json::to_value(&again).unwrap(),
405 serde_json::to_value(&first).unwrap()
406 );
407
408 let changed = two("Two, changed");
409 fs::write(root.path().join("two.one"), &changed).unwrap();
410 let after = cache.discover(&mut source, limits()).unwrap();
411 assert_eq!(std::mem::take(&mut source.read), ["two.one"]);
412 let fresh = discover(&mut Local::open(root.path()).unwrap(), limits()).unwrap();
413 assert_eq!(
414 serde_json::to_value(&after).unwrap(),
415 serde_json::to_value(&fresh).unwrap()
416 );
417 assert!(cache.found("two.one").unwrap().1 == onestore::Stamp::of(&changed).unwrap());
418
419 // Kept across launches, it reads nothing unchanged.
420 let mut kept: Cache = serde_json::from_slice(&serde_json::to_vec(&cache).unwrap()).unwrap();
421 kept.discover(&mut source, limits()).unwrap();
422 assert!(source.read.is_empty(), "{:?}", source.read);
423
424 // A file written as a copy of it holds, as a replica its own edits published from, is
425 // taken from the copy.
426 let copied = two("Two, published");
427 fs::write(root.path().join("two.one"), &copied).unwrap();
428 let identity = onestore::Store::parse(&changed).unwrap().header.file_id;
429 source.copies.push((identity, copied.clone()));
430 kept.discover(&mut source, limits()).unwrap();
431 assert!(source.read.is_empty(), "{:?}", source.read);
432 assert!(kept.found("two.one").unwrap().1 == onestore::Stamp::of(&copied).unwrap());
433
434 // A failed discovery leaves the cache as it was.
435 fs::write(root.path().join("one.one"), b"unfinished").unwrap();
436 assert!(kept.discover(&mut source, limits()).is_err());
437 assert!(kept.found("two.one").is_some());
438}
crates/notebook/tests/session.rs+111-13
......@@ -386,6 +386,14 @@ fn a_notebook_directory_lists_its_sections_and_opens_them() {
386386 section.close().unwrap();
387387}
388388
389/// The replicas in `cache`, beside the folder of catalog listings.
390fn replicas(cache: &Path) -> usize {
391 std::fs::read_dir(cache)
392 .unwrap()
393 .filter(|entry| entry.as_ref().unwrap().path().is_file())
394 .count()
395}
396
389397#[test]
390398fn a_notebook_only_opens_discovered_section_paths() {
391399 let directory = tempfile::tempdir().unwrap();
......@@ -404,7 +412,7 @@ fn a_notebook_only_opens_discovered_section_paths() {
404412 Err(notebook::Error::Io(error)) if error.kind() == std::io::ErrorKind::NotFound
405413 ));
406414 }
407 assert_eq!(std::fs::read_dir(&cache).unwrap().count(), 0);
415 assert_eq!(replicas(&cache), 0);
408416 assert_eq!(std::fs::read(&outside).unwrap(), source);
409417}
410418
......@@ -427,7 +435,7 @@ fn a_catalog_section_replaced_by_an_outside_symlink_is_rejected() {
427435 notebook.section("notes.one", || {}),
428436 Err(notebook::Error::Io(error)) if error.kind() == std::io::ErrorKind::PermissionDenied
429437 ));
430 assert_eq!(std::fs::read_dir(&cache).unwrap().count(), 0);
438 assert_eq!(replicas(&cache), 0);
431439 assert_eq!(std::fs::read(&outside).unwrap(), source);
432440}
433441
......@@ -1028,9 +1036,7 @@ fn a_closed_sections_queued_edits_publish_in_the_background_when_online() {
10281036 let directory = tempfile::tempdir().unwrap();
10291037 let notebook = two_sections(directory.path());
10301038 let file = directory.path().join("Shared/Second.one");
1031 let background = notebook
1032 .background(Duration::from_millis(20), false, || {})
1033 .unwrap();
1039 let background = notebook.background(false, false, || {}).unwrap();
10341040 background.set_offline(true);
10351041 background.watch(notebook.replicas());
10361042 // The round started with the background ends before the edit exists.
......@@ -1083,7 +1089,7 @@ fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() {
10831089 let notified = Arc::new(AtomicUsize::new(0));
10841090 let counter = Arc::clone(&notified);
10851091 let background = notebook
1086 .background(Duration::from_millis(20), false, move || {
1092 .background(true, false, move || {
10871093 counter.fetch_add(1, Ordering::SeqCst);
10881094 })
10891095 .unwrap();
......@@ -1107,6 +1113,8 @@ fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() {
11071113 .unwrap();
11081114 changed.push((name, space, native));
11091115 }
1116 // As a watch reports a folder when it cannot name the file.
1117 background.touched(&[String::new()]);
11101118 let mut noticed = Vec::new();
11111119 until("both changes were noticed", || {
11121120 noticed.extend(background.changed());
......@@ -1130,9 +1138,7 @@ fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() {
11301138fn a_reported_change_wakes_its_section_without_polling() {
11311139 let directory = tempfile::tempdir().unwrap();
11321140 let notebook = two_sections(directory.path());
1133 let background = notebook
1134 .background(Duration::from_secs(3600), false, || {})
1135 .unwrap();
1141 let background = notebook.background(true, false, || {}).unwrap();
11361142 background.watch(notebook.replicas());
11371143 until("both sections were reached", || {
11381144 background
......@@ -1169,12 +1175,10 @@ fn every_section_gets_an_offline_copy_that_closing_the_notebook_discards() {
11691175 let replicas: Vec<_> = notebook
11701176 .replicas()
11711177 .into_iter()
1172 .map(|(_, replica)| replica.unwrap())
1178 .map(|known| known.replica.unwrap())
11731179 .collect();
11741180 assert!(replicas.iter().all(|replica| !replica.exists()));
1175 let background = notebook
1176 .background(Duration::from_secs(3600), true, || {})
1177 .unwrap();
1181 let background = notebook.background(true, true, || {}).unwrap();
11781182 background.watch(notebook.replicas());
11791183 until("both sections have offline copies", || {
11801184 replicas.iter().all(|replica| replica.exists())
......@@ -1199,3 +1203,97 @@ fn every_section_gets_an_offline_copy_that_closing_the_notebook_discards() {
11991203 let kept = notebook::Replica::open(&second).unwrap();
12001204 assert_eq!(kept.pending().unwrap().len(), 1);
12011205}
1206
1207/// A section file whose stamp reads are counted.
1208struct Counted(std::path::PathBuf, Arc<AtomicUsize>);
1209
1210impl notebook::Remote for Counted {
1211 fn read(&mut self) -> std::io::Result<Vec<u8>> {
1212 onestore::read_file(&self.0)
1213 }
1214
1215 fn stamp(&mut self) -> std::io::Result<onestore::Stamp> {
1216 self.1.fetch_add(1, Ordering::SeqCst);
1217 onestore::Stamp::of(&self.read()?).map_err(std::io::Error::other)
1218 }
1219
1220 fn publish(
1221 &mut self,
1222 transaction: &onestore::Transaction,
1223 ) -> Result<(), onestore::CommitError> {
1224 transaction.commit_file(&self.0)
1225 }
1226
1227 fn confirm(&mut self, base: &onestore::Stamp) -> Result<(), onestore::CommitError> {
1228 onestore::confirm_file(&self.0, base)
1229 }
1230}
1231
1232#[test]
1233fn an_open_section_the_background_holds_checks_its_file_when_the_watch_reports_it() {
1234 let directory = tempfile::tempdir().unwrap();
1235 let notebook = two_sections(directory.path());
1236 let background = notebook.background(true, false, || {}).unwrap();
1237 background.watch(notebook.replicas());
1238 until("both sections were reached", || {
1239 background
1240 .status()
1241 .iter()
1242 .all(|(_, status)| status.synced.is_some())
1243 });
1244 let stamps = Arc::new(AtomicUsize::new(0));
1245 let counter = Arc::clone(&stamps);
1246 let section = notebook
1247 .section_with(
1248 "Second.one",
1249 move |file| Ok(Counted(file.to_owned(), Arc::clone(&counter))),
1250 || {},
1251 )
1252 .unwrap();
1253 background.hold("Second.one", &section);
1254 std::thread::sleep(Duration::from_secs(3));
1255 let before = stamps.load(Ordering::SeqCst);
1256 std::thread::sleep(Duration::from_secs(5));
1257 assert_eq!(
1258 stamps.load(Ordering::SeqCst),
1259 before,
1260 "an idle held section reads nothing"
1261 );
1262
1263 let file = directory.path().join("Shared/Second.one");
1264 let bytes = onestore::read_file(&file).unwrap();
1265 let space = notebook::session::stored_pages(&bytes).unwrap()[0].space;
1266 let native = edited(&model_ops::page_of(&bytes, space), "Native ");
1267 ops::save(&bytes, space, &native)
1268 .unwrap()
1269 .commit_file(&file)
1270 .unwrap();
1271 background.touched(&["Second.one".to_owned()]);
1272 let mut changed = Vec::new();
1273 until("the session heard of the change", || {
1274 changed.extend(
1275 section
1276 .events()
1277 .into_iter()
1278 .filter_map(|event| match event {
1279 Event::Changed(spaces) => Some(spaces),
1280 _ => None,
1281 }),
1282 );
1283 !changed.is_empty()
1284 });
1285 assert!(changed.concat().contains(&space));
1286 assert!(background.changed().is_empty(), "the session reports it");
1287
1288 // Once the session stops, the background takes the section over at once.
1289 section.set_offline(true);
1290 let before = section.page(space).unwrap();
1291 typed(&section, space, &before, 0..0, "Closed ");
1292 section.close().unwrap();
1293 let mut after = edited(&before, "Closed ");
1294 until("the background published the closed section's edit", || {
1295 let stored = stored_page(&file, space);
1296 after.title = stored.title.clone();
1297 stored == after
1298 });
1299}
crates/onestore/README.md+2-2
......@@ -45,8 +45,8 @@ harness also accepts `--client-profile release`.
4545| `Document::active`, `Document::pages_in`, `Revision::parents`, `RevisionIndex::active` | Resolve the active revision, the pages of a space and parent links without repeating the lookups |
4646| `Page::copy` | The page's content under fresh identities for copying into another section, definitions and payloads included; indent levels only an outline group carries refuse |
4747| `page::link::internal_link`, `page::link::parse_internal_link` | Build and read the `onenote:#…` URLs OneNote stores for links to sections, pages and paragraphs, by identity |
48| `page::Recording`, `page::MediaIndex` | A recording attachment's identity and type, and a paragraph's link to a moment in recordings; read from native pages and kept through edits, never authored |
49| `page::Page`, `page::Paragraph`, `page::Ink`, `page::Math` | Build an editable page model (title, outlines, paragraphs with coalesced text spans, tables, images, attachments in paragraphs or on the page, ink drawings and handwriting decoded to stroke polylines in page points, a moved drawing offset by its position) with stored identities; equations parse from their linear text and run data into a tree that renders the MathML OneNote exports; content outside the model is retained as `Unsupported` |
48| `page::Recording`, `page::MediaIndex`, `PageOp::Media` | An audio or video file's recording identity, kind and length, and a paragraph's link to a moment in recordings, as OneNote stores them; the page lists its recordings as they come and go |
49| `page::Page`, `page::Paragraph`, `page::Ink`, `page::Math` | Build an editable page model (title, outlines, paragraphs with coalesced text spans, tables, images, attachments in paragraphs or on the page, ink drawings and handwriting decoded to stroke polylines in page points, a moved drawing offset by its position, a highlighter's raster operation, a drawn shape's kind and anchors) with stored identities; equations parse from their linear text and run data into a tree that renders the MathML OneNote exports; content outside the model is retained as `Unsupported` |
5050| `protected::UnlockedSection` (optional feature) | Own decoded buffers for explicit known-password inspection; clear those buffers on drop; derived document strings/exports remain caller-owned |
5151| `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode |
5252| `PageCreation`, `SectionOp::Create` | Add an empty top-level page and its section entry atomically, under identities the intent retains across retries |
crates/onestore/examples/ink_lab.rs deleted-25
......@@ -1,25 +0,0 @@
1use onestore::{RevisionIndex, Store, document::Document};
2fn main() {
3 let path = std::env::args().nth(1).unwrap();
4 let needle = std::env::args().nth(2).unwrap();
5 let bytes = std::fs::read(path).unwrap();
6 let store = Store::parse(&bytes).unwrap();
7 let index = RevisionIndex::parse(&store).unwrap();
8 let document = Document::parse(&index).unwrap();
9 for (space, _) in document.pages().unwrap() {
10 let page = onestore::page::Page::from_space(&document, space).unwrap();
11 if page.title != needle {
12 continue;
13 }
14 if std::env::var_os("INK_MODEL").is_some() {
15 for object in &page.objects {
16 if let onestore::page::PageObject::Ink(ink) = object {
17 println!("{}", serde_json::to_string(ink).unwrap());
18 }
19 }
20 continue;
21 }
22 let revision = document.spaces[&space].active().unwrap();
23 println!("{}", serde_json::to_string_pretty(revision).unwrap());
24 }
25}
crates/onestore/examples/scratch_dump.rs deleted-29
......@@ -1,29 +0,0 @@
1use onestore::{RevisionIndex, Store, document::Document};
2fn main() {
3 let path = std::env::args().nth(1).unwrap();
4 let needle = std::env::args().nth(2).unwrap();
5 let bytes = std::fs::read(path).unwrap();
6 let store = Store::parse(&bytes).unwrap();
7 let index = RevisionIndex::parse(&store).unwrap();
8 let document = Document::parse(&index).unwrap();
9 for (space, _) in document.pages().unwrap() {
10 let page = onestore::page::Page::from_space(&document, space).unwrap();
11 if page.title != needle { continue; }
12 let revision = document.spaces[&space].active().unwrap();
13 println!("{}", serde_json::to_string_pretty(revision).unwrap());
14 if let Ok(out) = std::env::var("PAYLOAD_OUT") {
15 for object in &page.objects {
16 if let onestore::page::PageObject::Outline(o) = object {
17 for p in &o.paragraphs {
18 if let onestore::page::ParagraphContent::Attachment(a) = &p.content {
19 std::fs::write(format!("{out}/{}", a.filename), a.bytes.as_deref().unwrap()).unwrap();
20 if let Some(i) = &a.preview { std::fs::write(format!("{out}/{}.png", a.filename), i).unwrap(); }
21 }
22 }
23 }
24 }
25 }
26 }
27}
28#[allow(dead_code)]
29fn unused() {}
crates/onestore/src/op/apply.rs+10-1
......@@ -1222,7 +1222,16 @@ impl<'a> Writer<'_, 'a> {
12221222 .iter()
12231223 .map(|stroke| (stroke.id, stroke))
12241224 .collect();
1225 self.write(|page| content::ink_changes(page, ink, ink.id, data, &strokes, holder))
1225 self.write(|page| content::ink_changes(page, ink, ink.id, data, &strokes, holder))?;
1226 // A drawing moved before, as undoing its deletion brings it back, keeps its offset.
1227 match (ink.layout.x, ink.layout.y) {
1228 (None, None) => Ok(()),
1229 (Some(x), Some(y)) => {
1230 let edit = OutlineEdit::Position { x, y };
1231 self.write(|page| edit.changes(page, ink.id))
1232 }
1233 _ => Err(OpError::Unsupported("An ink position needs both coordinates").into()),
1234 }
12261235 }
12271236
12281237 /// Adds a page object on top, then moves it before `before`.
crates/onestore/src/op/content.rs+16-4
......@@ -642,7 +642,11 @@ fn stroke_values(stroke: &InkStroke, index: u32, filetime: u64) -> Result<Values
642642 });
643643 let mut origin = ((left - stroke.width / 2.0) / 36.0).to_le_bytes().to_vec();
644644 origin.extend_from_slice(&((top - stroke.height / 2.0) / 36.0).to_le_bytes());
645 let bias = if stroke.raster_operation == Some(9) { 2 } else { 0 };
645 let bias = if stroke.raster_operation == Some(9) {
646 2
647 } else {
648 0
649 };
646650 values.extend([
647651 (0x14003419, index.to_le_bytes().to_vec()),
648652 (0x1000341b, 0x409_u16.to_le_bytes().to_vec()),
......@@ -671,8 +675,13 @@ pub(crate) fn ink_changes(
671675 if ink.strokes.is_empty() {
672676 return Err(invalid("New ink needs at least one stroke"));
673677 }
674 if ink.layout != Default::default() {
675 return Err(invalid("Ink positions come from its strokes"));
678 let placed = crate::document::Layout {
679 x: None,
680 y: None,
681 ..ink.layout.clone()
682 };
683 if placed != Default::default() {
684 return Err(invalid("Ink sizes come from its strokes"));
676685 }
677686 let (modified, filetime) = crate::create::current_timestamps()?;
678687 let modified = modified.to_le_bytes();
......@@ -700,7 +709,10 @@ pub(crate) fn ink_changes(
700709 let (kind, property, bytes) = shape.stored();
701710 values.extend([(0x0c001d4f, vec![kind]), (property, bytes)]);
702711 }
703 values.push((0x14001d4e, (1 + u32::from(ink.shape.is_some())).to_le_bytes().to_vec()));
712 values.push((
713 0x14001d4e,
714 (1 + u32::from(ink.shape.is_some())).to_le_bytes().to_vec(),
715 ));
704716 let mut object = PropertyObject {
705717 jcid: 0x60014,
706718 bytes: crate::create::properties(&values)?,
crates/onestore/src/page/ink.rs+9-3
......@@ -329,8 +329,10 @@ impl InkShape {
329329 match (kind?, line, anchors) {
330330 (11, Some(line), _) if line.len() == 16 => {
331331 let v = floats(line);
332 Some(Self::Line([[v[0], v[1]], [v[2], v[3]]].map(|end| end.map(|v| v * 36.0))))
333 .filter(Self::finite)
332 Some(Self::Line(
333 [[v[0], v[1]], [v[2], v[3]]].map(|end| end.map(|v| v * 36.0)),
334 ))
335 .filter(Self::finite)
334336 }
335337 (12, _, Some(bytes)) if bytes.len() >= 24 && (bytes.len() - 24) % 8 == 0 => {
336338 let v = floats(bytes);
......@@ -358,7 +360,11 @@ impl InkShape {
358360 pub(crate) fn stored(&self) -> (u8, u32, Vec<u8>) {
359361 let half = |v: &f32| (v / 36.0).to_le_bytes();
360362 match self {
361 Self::Line(ends) => (11, 0x1c001dac, ends.iter().flatten().flat_map(half).collect()),
363 Self::Line(ends) => (
364 11,
365 0x1c001dac,
366 ends.iter().flatten().flat_map(half).collect(),
367 ),
362368 Self::Closed { transform, anchors } => (
363369 12,
364370 0x1c001daa,
crates/onestore/src/page/text.rs+5
......@@ -172,6 +172,11 @@ pub fn new_id() -> Result<ExGuid, EditError> {
172172 Ok(id)
173173}
174174
175/// A GUID of its own, as a recording's identity takes (AudioRecordingGuid).
176pub fn new_guid() -> Result<[u8; 16], EditError> {
177 crate::write::fresh_guid().map_err(|_| EditError::Identity)
178}
179
175180impl Paragraph {
176181 pub fn project(&self) -> Result<TextProjection, EditError> {
177182 let mut runs = Vec::new();
crates/onestore/tests/page_ink.rs+36-8
......@@ -323,7 +323,12 @@ fn native_pens_highlighters_and_shapes_read_as_drawn() {
323323 .all(|(a, b)| (a[0] - b[0]).abs() < 0.05 && (a[1] - b[1]).abs() < 0.05)
324324 };
325325 for (native, kind, from, to) in [
326 (rectangle, ShapeKind::Rectangle, [252.0, 86.4], [324.0, 122.4]),
326 (
327 rectangle,
328 ShapeKind::Rectangle,
329 [252.0, 86.4],
330 [324.0, 122.4],
331 ),
327332 (line, ShapeKind::Line, [342.0, 86.4], [414.0, 122.4]),
328333 (arrow, ShapeKind::Arrow, [252.0, 158.4], [324.0, 194.4]),
329334 (ellipse, ShapeKind::Ellipse, [342.0, 158.4], [414.0, 194.4]),
......@@ -368,8 +373,11 @@ fn native_pens_highlighters_and_shapes_read_as_drawn() {
368373 .map(|ink| (ink.layout.x, ink.layout.y))
369374 .collect();
370375 assert_eq!(moved.len(), 2);
371 assert!(moved.iter().any(|&(x, y)| x == Some(45.0)
372 && y.is_some_and(|y| (y - 15.0).abs() < 1e-4)));
376 assert!(
377 moved
378 .iter()
379 .any(|&(x, y)| x == Some(45.0) && y.is_some_and(|y| (y - 15.0).abs() < 1e-4))
380 );
373381}
374382
375383fn tool_page(source: &[u8]) -> (ExGuid, Page, Vec<Ink>) {
......@@ -418,8 +426,20 @@ fn tool_page(source: &[u8]) -> (ExGuid, Page, Vec<Ink>) {
418426 let inks = vec![
419427 free(&marker, vec![[60.0, 190.0], [220.0, 190.0]]),
420428 free(&accent, wave),
421 Ink::drawn(ShapeKind::Rectangle, [252.0, 140.4], [324.0, 194.4], &shape_pen).unwrap(),
422 Ink::drawn(ShapeKind::Ellipse, [342.0, 140.4], [432.0, 194.4], &shape_pen).unwrap(),
429 Ink::drawn(
430 ShapeKind::Rectangle,
431 [252.0, 140.4],
432 [324.0, 194.4],
433 &shape_pen,
434 )
435 .unwrap(),
436 Ink::drawn(
437 ShapeKind::Ellipse,
438 [342.0, 140.4],
439 [432.0, 194.4],
440 &shape_pen,
441 )
442 .unwrap(),
423443 Ink::drawn(ShapeKind::Arrow, [252.0, 230.4], [360.0, 266.4], &shape_pen).unwrap(),
424444 Ink::drawn(ShapeKind::Line, [378.0, 230.4], [450.0, 230.4], &shape_pen).unwrap(),
425445 ];
......@@ -528,8 +548,14 @@ fn pens_highlighters_and_shapes_are_written_as_onenote_draws_them() {
528548 other => panic!("{other:?}"),
529549 }
530550 }
531 assert_eq!(extra(wave.id, 0x14001d4e), Some(1u32.to_le_bytes().to_vec()));
532 assert_eq!(extra(rectangle.id, 0x14001d4e), Some(2u32.to_le_bytes().to_vec()));
551 assert_eq!(
552 extra(wave.id, 0x14001d4e),
553 Some(1u32.to_le_bytes().to_vec())
554 );
555 assert_eq!(
556 extra(rectangle.id, 0x14001d4e),
557 Some(2u32.to_le_bytes().to_vec())
558 );
533559 match &node(rectangle.id).kind {
534560 Kind::Ink {
535561 shape_kind: Some(12),
......@@ -576,7 +602,9 @@ fn onenote_drawings_take_erasing_moving_and_more_strokes() {
576602 }
577603 if let Some(shape) = &mut ink.shape {
578604 *shape = match shape {
579 InkShape::Line([a, b]) => InkShape::Line([[a[0], a[1] + 200.0], [b[0], b[1] + 200.0]]),
605 InkShape::Line([a, b]) => {
606 InkShape::Line([[a[0], a[1] + 200.0], [b[0], b[1] + 200.0]])
607 }
580608 InkShape::Closed { transform, anchors } => InkShape::Closed {
581609 transform: {
582610 let mut t = *transform;
crates/onestore/tests/page_media.rs+113-1
......@@ -13,6 +13,10 @@ const NATIVE_READ: &str = include_str!("../../../corpus/m6/native-features-01/re
1313const TITLE: &str = "Files and recording";
1414
1515fn page(bytes: &[u8]) -> (ExGuid, Page) {
16 titled(bytes, TITLE)
17}
18
19fn titled(bytes: &[u8], title: &str) -> (ExGuid, Page) {
1620 let store = Store::parse(bytes).unwrap();
1721 let index = RevisionIndex::parse(&store).unwrap();
1822 let document = Document::parse(&index).unwrap();
......@@ -21,7 +25,7 @@ fn page(bytes: &[u8]) -> (ExGuid, Page) {
2125 .unwrap()
2226 .into_iter()
2327 .map(|(space, _)| (space, Page::from_space(&document, space).unwrap()))
24 .find(|(_, page)| page.title == TITLE)
28 .find(|(_, page)| page.title == title)
2529 .unwrap()
2630}
2731
......@@ -158,3 +162,111 @@ fn editing_a_recorded_page_keeps_the_recording_and_its_annotation() {
158162 std::fs::write(directory.join("expected-count.txt"), count.to_string()).unwrap();
159163 }
160164}
165
166/// The recordings page `space` lists (AudioRecordingGuids).
167fn playlist(bytes: &[u8], space: ExGuid) -> Vec<[u8; 16]> {
168 let store = Store::parse(bytes).unwrap();
169 let index = RevisionIndex::parse(&store).unwrap();
170 let document = Document::parse(&index).unwrap();
171 let revision = document.spaces[&space].active().unwrap();
172 revision
173 .nodes
174 .values()
175 .find_map(|node| match &node.kind {
176 onestore::document::Kind::Page { recordings, .. } => Some(recordings.clone()),
177 _ => None,
178 })
179 .unwrap()
180}
181
182/// OneNote 2010's own recording (`corpus/recording/native`): a WMA started after "Before
183/// recording", its line saying when and notes linked while it ran.
184const RECORDED: &[u8] = include_bytes!("../../../corpus/recording/native/notebook/files.one");
185/// The same page after OneNote deleted the recording: the page lists no recording, and the
186/// notes keep their links.
187const DELETED: &[u8] = include_bytes!("../../../corpus/recording/native/deleted/files.one");
188
189/// `ONESTORE_RECORDING_EDIT_EXPORT` names a new directory receiving the edited section for
190/// a cold reopen (`corpus/recording/edit`).
191#[test]
192fn onenote_s_recording_round_trips_through_deleting_restoring_and_linking() {
193 let (space, native) = titled(RECORDED, "Recorded");
194 let (native_recording, _) = recording(&native);
195 assert_eq!(native_recording.kind, 1);
196 assert!(native_recording.duration_ms.is_some_and(|ms| ms > 30_000));
197 assert_eq!(playlist(RECORDED, space), vec![native_recording.id]);
198 let (_, deleted) = titled(DELETED, "Recorded");
199 assert!(playlist(DELETED, space).is_empty());
200 assert!(recording_paragraph(&deleted).is_none());
201
202 // Deleting the recording unlists it, as OneNote does; the notes keep their links.
203 let mut removed = native.clone();
204 let outline = body(&mut removed);
205 let at = outline
206 .paragraphs
207 .iter()
208 .position(|paragraph| matches!(paragraph.content, ParagraphContent::Attachment(_)))
209 .unwrap();
210 let file = outline.paragraphs.remove(at);
211 let written = ops::saved(RECORDED, space, &removed).unwrap();
212 assert!(playlist(&written, space).is_empty());
213 assert_eq!(titled(&written, "Recorded").1, removed);
214
215 // Putting it back, as undo does, stores the same recording, listed again.
216 let written = ops::saved(&written, space, &native).unwrap();
217 let (_, restored) = titled(&written, "Recorded");
218 assert_eq!(restored, native);
219 let bytes = |page: &Page| match &recording_paragraph(page).unwrap().content {
220 ParagraphContent::Attachment(file) => file.bytes.clone(),
221 _ => unreachable!(),
222 };
223 assert_eq!(bytes(&restored), bytes(&native));
224 assert!(bytes(&restored).unwrap().starts_with(b"0&\xb2\x75"));
225 assert_eq!(playlist(&written, space), vec![native_recording.id]);
226 assert_eq!(recording_paragraph(&restored).unwrap(), &file);
227
228 // A note written while playing it back links to a moment, as one taken while recording.
229 let mut linked = restored.clone();
230 let mut added = ops::paragraph("Linked by Snowbound");
231 added.media = MediaIndex {
232 recordings: vec![native_recording.id],
233 time_ms: Some(25_000),
234 };
235 body(&mut linked).paragraphs.push(added);
236 let written = ops::saved(&written, space, &linked).unwrap();
237 assert_eq!(titled(&written, "Recorded").1, linked);
238
239 if let Some(directory) = std::env::var_os("ONESTORE_RECORDING_EDIT_EXPORT") {
240 let directory = std::path::PathBuf::from(directory);
241 std::fs::create_dir_all(&directory).unwrap();
242 std::fs::write(directory.join("files.one"), &written).unwrap();
243 std::fs::write(
244 directory.join("Open Notebook.onetoc2"),
245 include_bytes!("../../../corpus/recording/native/notebook/Open Notebook.onetoc2"),
246 )
247 .unwrap();
248 }
249}
250
251fn body(page: &mut Page) -> &mut onestore::page::Outline {
252 page.objects
253 .iter_mut()
254 .find_map(|object| match object {
255 PageObject::Outline(outline) if !outline.title => Some(outline),
256 _ => None,
257 })
258 .unwrap()
259}
260
261fn recording_paragraph(page: &Page) -> Option<&onestore::page::PageParagraph> {
262 page.objects
263 .iter()
264 .filter_map(|object| match object {
265 PageObject::Outline(outline) => Some(&outline.paragraphs),
266 _ => None,
267 })
268 .flatten()
269 .find(|paragraph| {
270 matches!(&paragraph.content, ParagraphContent::Attachment(file) if file.recording.is_some())
271 })
272}
crates/snowbound/assets/icons/eraser.svg created+11
......@@ -0,0 +1,11 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#ffc2d1"/><stop offset="1" stop-color="#f06a8f"/></linearGradient>
4 <linearGradient id="g1" x2="0" y2="1"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#d7dee7"/></linearGradient>
5 </defs>
6 <path d="M10 2.75L14.25 7L9.5 11.75L5.25 7.5Z" fill="#000000" fill-opacity="0.16"/>
7 <path d="M10 2L14.25 6.25L9.5 11L5.25 6.75Z" fill="url(#g0)" stroke="#b83a60" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
8 <path d="M5.25 6.75L9.5 11L7 13.5H4.5L2.25 11.25A0.9 0.9 0 0 1 2.25 9.75Z" fill="url(#g1)" stroke="#5a6676" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
9 <path d="M9.9 3.2L6.5 6.6" fill="none" stroke="#ffffff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.85"/>
10 <path d="M8 14.5H14" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round"/>
11</svg>
crates/snowbound/assets/icons/lasso.svg created+4
......@@ -0,0 +1,4 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M8 1.5A6.5 4.25 0 1 1 8 10A6.5 4.25 0 1 1 8 1.5Z" fill="none" stroke="currentColor" stroke-width="1"/>
3 <path d="M4.5 9.2C3.2 10.4 3.6 12 5.2 12.2C6.4 12.35 6.9 13.4 6 14.6" fill="none" stroke="currentColor" stroke-width="1"/>
4</svg>
crates/snowbound/assets/icons/pen.svg created+11
......@@ -0,0 +1,11 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#79c2ff"/><stop offset="1" stop-color="#1f7cf0"/></linearGradient>
4 <linearGradient id="g1" x2="0" y2="1"><stop offset="0" stop-color="#d3d9e0"/><stop offset="1" stop-color="#7b8796"/></linearGradient>
5 </defs>
6 <path d="M12.3 1.75L14.25 3.7L6.2 11.75L4.25 9.8Z" fill="#000000" fill-opacity="0.16"/>
7 <path class="accent" d="M12.3 1L14.25 2.95L6.2 11L4.25 9.05Z" fill="url(#g0)" stroke="#1560c4" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
8 <path d="M4.25 9.05L6.2 11L3.1 12.4L1.75 13.75L2.85 11.9Z" fill="url(#g1)" stroke="#5a6676" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
9 <path d="M11.9 2.2L5.6 8.5" fill="none" stroke="#ffffff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.85"/>
10 <path d="M1 15H9" fill="none" class="accent" stroke="#1f7cf0" stroke-width="1" stroke-linecap="round"/>
11</svg>
crates/snowbound/assets/icons/select.svg created+5
......@@ -0,0 +1,5 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M3 1.75L3 13.25L5.9 10.5L8 15.25L10 14.35L7.9 9.75L11.75 9.5Z" fill="#000000" fill-opacity="0.16"/>
3 <path d="M3 1L3 12.5L5.9 9.75L8 14.5L10 13.6L7.9 9L11.75 8.75Z" fill="#ffffff" stroke="currentColor" stroke-width="0.8" stroke-linejoin="round"/>
4 <path d="M12 2.5H15M13.5 2.5V7.5M12 7.5H15" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round"/>
5</svg>
crates/snowbound/assets/icons/shape-arrow.svg created+3
......@@ -0,0 +1,3 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M2.5 13.5L13.5 2.5M8 2.5H13.5V8" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
3</svg>
crates/snowbound/assets/icons/shape-line.svg created+3
......@@ -0,0 +1,3 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M2.5 13.5L13.5 2.5" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
3</svg>
crates/snowbound/assets/icons/shape-oval.svg created+3
......@@ -0,0 +1,3 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M8 3.5A6.5 4.5 0 1 1 8 12.5A6.5 4.5 0 1 1 8 3.5Z" fill="none" stroke="currentColor" stroke-width="1.2"/>
3</svg>
crates/snowbound/assets/icons/shape-rectangle.svg created+3
......@@ -0,0 +1,3 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M1.5 3.5H14.5V12.5H1.5Z" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
3</svg>
crates/snowbound/assets/icons/shapes.svg created+8
......@@ -0,0 +1,8 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#79c2ff"/><stop offset="1" stop-color="#1f7cf0"/></linearGradient>
4 </defs>
5 <path d="M1.5 2.25H9.5V10.25H1.5Z" fill="none" stroke="currentColor" stroke-width="1"/>
6 <path d="M10.5 7A4 4 0 1 1 10.5 15A4 4 0 1 1 10.5 7Z" fill="#000000" fill-opacity="0.16"/>
7 <path class="accent" d="M10.5 6.25A4 4 0 1 1 10.5 14.25A4 4 0 1 1 10.5 6.25Z" fill="url(#g0)" stroke="#1560c4" stroke-width="0.8"/>
8</svg>
crates/snowbound/src/art.rs+10
......@@ -66,3 +66,13 @@ pub const SYNC_BUSY: &[&str] = art!("icons/sync-busy");
6666pub const SYNC_DONE: &[&str] = art!("icons/sync-done");
6767pub const SYNC_ERROR: &[&str] = art!("icons/sync-error");
6868pub const SYNC_OFFLINE: &[&str] = art!("icons/sync-offline");
69
70pub const ERASER: &[&str] = art!("icons/eraser");
71pub const LASSO: &[&str] = art!("icons/lasso");
72pub const PEN: &[&str] = art!("icons/pen");
73pub const SELECT: &[&str] = art!("icons/select");
74pub const SHAPES: &[&str] = art!("icons/shapes");
75pub const SHAPE_ARROW: &[&str] = art!("icons/shape-arrow");
76pub const SHAPE_LINE: &[&str] = art!("icons/shape-line");
77pub const SHAPE_OVAL: &[&str] = art!("icons/shape-oval");
78pub const SHAPE_RECTANGLE: &[&str] = art!("icons/shape-rectangle");
crates/snowbound/src/attachment.rs+25-9
......@@ -5,11 +5,12 @@ use onestore::page::Attachment;
55use std::{error::Error, path::Path};
66
77/// The size OneNote 2010 shows a file's icon at, in points.
8const ICON_SIZE: [f32; 2] = [24.0, 24.0];
8pub(crate) const ICON_SIZE: [f32; 2] = [24.0, 24.0];
99
1010impl State {
1111 /// Attach File, or a file dropped at `at`, a window point: a copy of the file's bytes
12 /// at the caret or at `at`, with the icon the system shows for it.
12 /// at the caret or at `at`, with the icon the system shows for it; audio and video are
13 /// recordings, as OneNote attaches them.
1314 pub(crate) fn attach(
1415 &mut self,
1516 path: &Path,
......@@ -31,9 +32,9 @@ impl State {
3132 source_path: path.to_str().map(str::to_owned),
3233 size: Some(ICON_SIZE),
3334 layout: Default::default(),
34 bytes: Some(bytes.into()),
3535 preview: platform::file_icon(path).map(Into::into),
36 recording: None,
36 recording: crate::recording::attached(name, &bytes),
37 bytes: Some(bytes.into()),
3738 };
3839 let response = match at {
3940 Some(point) => {
......@@ -53,11 +54,27 @@ impl State {
5354 Ok(())
5455 }
5556
56 /// Open: a copy of the file, in a folder of its own, with the system's application for it.
57 pub(crate) fn open_attachment(&self, file: &Attachment) -> Result<(), Box<dyn Error>> {
57 /// Open: a recording plays; another file opens as a copy, in a folder of its own, with
58 /// the system's application for it.
59 pub(crate) fn open_attachment(&mut self, file: &Attachment) -> Result<(), Box<dyn Error>> {
60 if file.recording.is_some() {
61 return self.play(file, 0);
62 }
63 if let Some(path) = self.copy_attachment(file)? {
64 platform::open_file(&path);
65 }
66 Ok(())
67 }
68
69 /// A copy of the file, in a folder of its own; none, once said, where it is not stored
70 /// in the section.
71 pub(crate) fn copy_attachment(
72 &self,
73 file: &Attachment,
74 ) -> Result<Option<std::path::PathBuf>, Box<dyn Error>> {
5875 let Some(bytes) = &file.bytes else {
5976 unstored();
60 return Ok(());
77 return Ok(None);
6178 };
6279 let root = std::env::temp_dir().join("Snowbound Attachments");
6380 std::fs::create_dir_all(&root)?;
......@@ -68,8 +85,7 @@ impl State {
6885 .ok_or("No folder is free for a copy of the file")?;
6986 let path = folder.join(&file.filename);
7087 std::fs::write(&path, bytes)?;
71 platform::open_file(&path);
72 Ok(())
88 Ok(Some(path))
7389 }
7490
7591 /// Save As: the file's bytes where the user chooses, under its name by default.
crates/snowbound/src/commands.rs+85-8
......@@ -2,9 +2,10 @@
22//! chords on each platform, when it applies and what it does.
33
44use crate::{Command as Work, FONTS, HIGHLIGHTS, SIZES, State, page, platform, search};
5use canvas::editor::{Alignment, FormatState, Formatting, ListStyle, Toggle};
6use canvas::interaction::Request;
5use canvas::editor::{Alignment, FAVORITES, FormatState, Formatting, ListStyle, Pen, Toggle};
6use canvas::interaction::{Request, ink::Tool};
77use draw::edit::{Key, Modifiers, NamedKey, Platform};
8use onestore::page::ink::ShapeKind;
89use std::{error::Error, sync::Arc};
910
1011#[derive(Clone, Copy, Debug, PartialEq, Eq)]
......@@ -54,6 +55,12 @@ pub enum Id {
5455 DateTime,
5556 RecordAudio,
5657 RecordVideo,
58 /// The Draw tab's tools.
59 SelectType,
60 Pen,
61 Eraser,
62 Lasso,
63 Shape(ShapeKind),
5764 Toggle(Toggle),
5865 Highlight,
5966 FontColor,
......@@ -90,6 +97,8 @@ pub enum Choice {
9097 RuleLines(Option<usize>),
9198 /// A template whose art becomes the page's background, or `None` for none.
9299 Art(Option<&'static str>),
100 /// A place in the pen gallery, which Pen then draws with.
101 Pen(usize),
93102}
94103
95104#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
......@@ -299,6 +308,14 @@ pub const COMMANDS: &[Command] = &[
299308 ),
300309 row!(Id::RecordAudio, "Record Audio", NONE, NONE),
301310 row!(Id::RecordVideo, "Record Video", NONE, NONE),
311 row!(Id::SelectType, "Select & Type", NONE, NONE),
312 row!(Id::Pen, "Pen", NONE, NONE),
313 row!(Id::Eraser, "Eraser", NONE, NONE),
314 row!(Id::Lasso, "Lasso Select", NONE, NONE),
315 row!(Id::Shape(ShapeKind::Line), "Line", NONE, NONE),
316 row!(Id::Shape(ShapeKind::Arrow), "Arrow", NONE, NONE),
317 row!(Id::Shape(ShapeKind::Rectangle), "Rectangle", NONE, NONE),
318 row!(Id::Shape(ShapeKind::Ellipse), "Oval", NONE, NONE),
302319 row!(Id::Toggle(Toggle::Bold), "Bold", &[cmd('b')], &[cmd('b')]),
303320 row!(
304321 Id::Toggle(Toggle::Italic),
......@@ -438,7 +455,8 @@ pub fn title(choice: Choice) -> String {
438455 | Choice::Table(_)
439456 | Choice::PageColor(_)
440457 | Choice::RuleLines(_)
441 | Choice::Art(_) => {
458 | Choice::Art(_)
459 | Choice::Pen(_) => {
442460 unreachable!("Only the toolbar offers these")
443461 }
444462 }
......@@ -596,6 +614,20 @@ pub fn shortcut(id: Id) -> String {
596614}
597615
598616impl State {
617 /// The pen gallery: a pen in the open section's accent, then OneNote's favourites. The
618 /// accent takes the light theme's shade in either theme, so a stroke stores one colour
619 /// and shows it in both.
620 pub(crate) fn pens(&self) -> [Pen; 15] {
621 let section = crate::section_color(
622 self.session
623 .as_ref()
624 .and_then(|session| session.tabs[session.tab].color),
625 );
626 let [red, green, blue] = draw::srgb_bytes(ui::Theme::light().section(section).accent);
627 let accent = Pen::new(35.0, Some(u32::from_le_bytes([red, green, blue, 0])));
628 std::array::from_fn(|place| place.checked_sub(1).map_or(accent, |at| FAVORITES[at]))
629 }
630
599631 pub(crate) fn format_state(&self) -> FormatState {
600632 self.view.editor.format_state().unwrap_or_default()
601633 }
......@@ -643,7 +675,9 @@ impl State {
643675 Choice::Highlight(_) | Choice::Color(_) | Choice::List(_) => return enabled(text),
644676 Choice::Table(_) => Id::Table,
645677 Choice::PageColor(_) | Choice::RuleLines(_) | Choice::Art(_) => Id::PageColor,
678 Choice::Pen(_) => Id::Pen,
646679 };
680 let tool = self.view.tool();
647681 match id {
648682 Id::Settings
649683 | Id::NewNotebook
......@@ -708,12 +742,31 @@ impl State {
708742 },
709743 Id::PageColor => enabled(writable && session.is_some()),
710744 Id::ScreenClipping => enabled(text && cfg!(target_os = "macos")),
711 // Record Audio and Video await #36.
712 Id::RecordAudio | Id::RecordVideo => enabled(false),
745 Id::RecordAudio => Status {
746 enabled: matches!(self.media, crate::recording::Media::Recording { .. })
747 || text && !modal,
748 checked: matches!(self.media, crate::recording::Media::Recording { .. }),
749 },
750 // Recording video would need a camera pipeline and an encoder OneNote 2010 plays.
751 Id::RecordVideo => enabled(false),
713752 Id::InsertSpace => Status {
714753 enabled: writable,
715754 checked: self.view.inserting_space(),
716755 },
756 Id::SelectType => Status {
757 enabled: page,
758 checked: tool == Tool::Select,
759 },
760 Id::Pen | Id::Eraser | Id::Lasso | Id::Shape(_) => Status {
761 enabled: writable,
762 checked: match (id, tool) {
763 (Id::Pen, Tool::Pen(_))
764 | (Id::Eraser, Tool::Eraser)
765 | (Id::Lasso, Tool::Lasso) => true,
766 (Id::Shape(kind), Tool::Shape(shape, _)) => kind == shape,
767 _ => false,
768 },
769 },
717770 Id::Table
718771 | Id::Picture
719772 | Id::Attachment
......@@ -826,6 +879,11 @@ impl State {
826879 let lines = lines.map(|index| canvas::template::RULE_LINES[index].1);
827880 return self.paper_page(self.view.editor.page_color(), lines, None);
828881 }
882 Choice::Pen(place) => {
883 self.toolbar.pen = place;
884 self.save_settings();
885 Id::Pen
886 }
829887 };
830888 let field = self.ui.focused().filter(|focus| *focus != page());
831889 let response = match id {
......@@ -980,14 +1038,33 @@ impl State {
9801038 self.save_settings();
9811039 return Ok(());
9821040 }
983 Id::RecordAudio | Id::RecordVideo => {
984 return Ok(());
985 }
1041 Id::RecordAudio => return self.record_audio(),
1042 Id::RecordVideo => return Ok(()),
9861043 Id::InsertSpace => {
9871044 let response = self.view.insert_space();
9881045 self.respond(response);
9891046 return Ok(());
9901047 }
1048 Id::SelectType | Id::Pen | Id::Eraser | Id::Lasso | Id::Shape(_) => {
1049 let pens = self.pens();
1050 let tool = match id {
1051 Id::Pen => Tool::Pen(pens[self.toolbar.pen.min(pens.len() - 1)]),
1052 Id::Eraser => Tool::Eraser,
1053 Id::Lasso => Tool::Lasso,
1054 // Shapes draw with a pen a little thicker, as OneNote's do.
1055 Id::Shape(kind) => Tool::Shape(
1056 kind,
1057 Pen {
1058 width: 50.0,
1059 ..pens[0]
1060 },
1061 ),
1062 _ => Tool::Select,
1063 };
1064 let response = self.view.set_tool(tool);
1065 self.respond(response);
1066 return Ok(());
1067 }
9911068 Id::Link => {
9921069 self.open_link_dialog();
9931070 return Ok(());
crates/snowbound/src/library.rs+36-18
......@@ -167,7 +167,7 @@ pub struct Library {
167167 /// Syncs the notebook's sections while no tab shows them, as OneNote syncs every
168168 /// section of an open notebook.
169169 pub background: Option<Arc<Background>>,
170 /// Reports the changes to a folder on this computer to `background`.
170 /// Reports the changes to a mounted notebook's folder to `background`.
171171 watch: Option<Arc<crate::watch::Watch>>,
172172}
173173
......@@ -255,6 +255,16 @@ impl Library {
255255 if let Some(background) = &self.background {
256256 background.watch(notebook.replicas());
257257 }
258 // A network volume's watch covers each folder by name, so a new one needs a new watch.
259 let watch = match (&self.watch, &self.background) {
260 (Some(_), Some(background))
261 if !crate::watch::on_this_computer(Path::new(&self.location)) =>
262 {
263 let reports = OnceLock::from(Arc::downgrade(background));
264 watcher(&self.location, Arc::new(reports)).or_else(|| self.watch.clone())
265 }
266 _ => self.watch.clone(),
267 };
258268 Self {
259269 location: self.location.clone(),
260270 name: self.name.clone(),
......@@ -263,7 +273,7 @@ impl Library {
263273 notice: self.notice.clone(),
264274 cache: self.cache.clone(),
265275 background: self.background.clone(),
266 watch: self.watch.clone(),
276 watch,
267277 }
268278 }
269279
......@@ -355,6 +365,9 @@ impl Library {
355365 }
356366 };
357367 section.set_offline(offline());
368 if let Some(background) = &self.background {
369 background.hold(path, &section);
370 }
358371 Ok(section)
359372 }
360373
......@@ -460,26 +473,17 @@ pub fn recycle_bin(path: &str) -> bool {
460473}
461474
462475/// A mounted notebook's background sync, following Work Offline, and the watch that reports
463/// the folder's changes to it. Only a folder on this computer reports every change; one on a
464/// network volume is checked more often and kept in offline copies, as on a share.
476/// the folder's changes to it. A folder on a network volume is kept in offline copies, as on a
477/// share, and where the system does not report its server's changes, checked more often.
465478fn local_background(
466479 notebook: &Notebook,
467480 location: &str,
468481) -> (Option<Arc<Background>>, Option<Arc<crate::watch::Watch>>) {
469 let reports = Arc::new(OnceLock::<std::sync::Weak<Background>>::new());
470 let report = Arc::clone(&reports);
471 let watch = crate::watch::watch(Path::new(location), move |paths| {
472 if let Some(background) = report.get().and_then(std::sync::Weak::upgrade) {
473 background.touched(&paths);
474 }
475 });
476 let remote = !crate::watch::on_this_computer(Path::new(location));
477 let interval = match watch {
478 Some(_) if !remote => Background::BACKSTOP,
479 _ => Background::UNWATCHED,
480 };
482 let reports = Arc::new(OnceLock::new());
483 let watch = watcher(location, Arc::clone(&reports));
484 let copies = !crate::watch::on_this_computer(Path::new(location));
481485 let Some(background) = notebook
482 .background(interval, remote, notify_background)
486 .background(watch.is_some(), copies, notify_background)
483487 .inspect_err(|error| eprintln!("Background sync did not start: {error}"))
484488 .ok()
485489 else {
......@@ -489,7 +493,21 @@ fn local_background(
489493 background.watch(notebook.replicas());
490494 let background = Arc::new(background);
491495 let _ = reports.set(Arc::downgrade(&background));
492 (Some(background), watch.map(Arc::new))
496 (Some(background), watch)
497}
498
499/// Reports the changes to the folder at `location` to the background `reports` names, once
500/// set.
501fn watcher(
502 location: &str,
503 reports: Arc<OnceLock<std::sync::Weak<Background>>>,
504) -> Option<Arc<crate::watch::Watch>> {
505 crate::watch::watch(Path::new(location), move |paths| {
506 if let Some(background) = reports.get().and_then(std::sync::Weak::upgrade) {
507 background.touched(&paths);
508 }
509 })
510 .map(Arc::new)
493511}
494512
495513pub fn file_name(path: &Path) -> String {
crates/snowbound/src/main.rs+163-22
......@@ -10,6 +10,9 @@ mod history;
1010mod library;
1111mod link;
1212mod manage;
13#[cfg_attr(target_os = "linux", path = "media_linux.rs")]
14#[cfg_attr(target_os = "macos", path = "media_macos.rs")]
15mod media;
1316mod meeting;
1417#[cfg(target_os = "macos")]
1518mod menubar;
......@@ -21,6 +24,7 @@ mod pane;
2124#[cfg_attr(target_os = "linux", path = "linux.rs")]
2225#[cfg_attr(target_os = "macos", path = "macos.rs")]
2326mod platform;
27mod recording;
2428mod rename;
2529mod screenshot;
2630mod search;
......@@ -50,6 +54,7 @@ use library::Library;
5054use onestore::ExGuid;
5155use onestore::document::Format;
5256use onestore::page::Page;
57use onestore::page::ink::ShapeKind;
5358use onestore::page::text::Paragraph;
5459use std::{
5560 collections::{HashMap, HashSet},
......@@ -202,6 +207,7 @@ fn cursor_icon(cursor: Cursor) -> CursorIcon {
202207 Cursor::NeswResize => CursorIcon::NeswResize,
203208 Cursor::RowResize => CursorIcon::RowResize,
204209 Cursor::ColResize => CursorIcon::ColResize,
210 Cursor::Crosshair => CursorIcon::Crosshair,
205211 }
206212}
207213
......@@ -550,6 +556,7 @@ struct State {
550556 title_focus: Option<ExGuid>,
551557 /// What the template strip shows over a blank page.
552558 templates: templates::View,
559 media: recording::Media,
553560 thumbnails: templates::Thumbnails,
554561 search: search::Search,
555562 /// Whether the page list is shown beside the page.
......@@ -835,6 +842,7 @@ impl State {
835842 sectionless,
836843 drag: None,
837844 templates: templates::View::Strip,
845 media: Default::default(),
838846 thumbnails: templates::Thumbnails::default(),
839847 search,
840848 pages_open: true,
......@@ -912,7 +920,7 @@ impl State {
912920 }
913921 self.draw()?;
914922 lap("drawn", start);
915 self.sync_index(false);
923 self.sync_index(false, Vec::new());
916924 let commands = std::mem::take(&mut self.commands);
917925 let follow = !commands.is_empty() || self.ui.wants_frame() || self.opening.is_some();
918926 for command in commands {
......@@ -1260,6 +1268,9 @@ impl State {
12601268 },
12611269 );
12621270 self.template_strip(&theme);
1271 if let Some(rect) = self.ui.rect(page()) {
1272 self.transport(&theme, rect)?;
1273 }
12631274 let scroll = self.view.scroll();
12641275 for (index, axis) in [Axis::X, Axis::Y].into_iter().enumerate() {
12651276 if let Some(offset) = ui::scrollbar(
......@@ -1539,13 +1550,14 @@ impl State {
15391550 let engine = &self.view.engine;
15401551 let session = self.session.as_ref();
15411552 let label = format!("{:.0}%", self.view.zoom() * 100.0);
1553 let drawing_pens = self.pens();
15421554 let ui = &mut self.ui;
15431555 let text = theme.text;
15441556 let tags: Vec<_> = self.tags.iter().enumerate().collect();
15451557 let mut choice = group(
15461558 ui,
15471559 "navigate",
1548 3,
1560 4,
15491561 |ui| {
15501562 let mut choice = None;
15511563 for id in [Cmd::Back, Cmd::Forward, Cmd::Undo, Cmd::Redo] {
......@@ -1561,7 +1573,7 @@ impl State {
15611573 choice = group(
15621574 ui,
15631575 "clipboard",
1564 2,
1576 3,
15651577 |ui| {
15661578 divider(ui, theme);
15671579 let mut choice = None;
......@@ -1721,7 +1733,7 @@ impl State {
17211733 choice = group(
17221734 ui,
17231735 "character",
1724 5,
1736 6,
17251737 |ui| {
17261738 divider(ui, theme);
17271739 let mut choice = None;
......@@ -1816,7 +1828,7 @@ impl State {
18161828 choice = group(
18171829 ui,
18181830 "paragraph",
1819 4,
1831 5,
18201832 |ui| {
18211833 divider(ui, theme);
18221834 let mut choice = None;
......@@ -1946,7 +1958,7 @@ impl State {
19461958 choice = group(
19471959 ui,
19481960 "tags",
1949 6,
1961 7,
19501962 |ui| {
19511963 divider(ui, theme);
19521964 let mut choice = None;
......@@ -1964,6 +1976,23 @@ impl State {
19641976 },
19651977 )
19661978 .or(choice);
1979 // The Draw tab's tools, which Insert's menus list for when they fold, before Insert.
1980 let shapes = [
1981 ShapeKind::Line,
1982 ShapeKind::Arrow,
1983 ShapeKind::Rectangle,
1984 ShapeKind::Ellipse,
1985 ]
1986 .map(|kind| Run(Cmd::Shape(kind)));
1987 let drawing = [
1988 Run(Cmd::SelectType),
1989 Open("pen", Cmd::Pen),
1990 Run(Cmd::Eraser),
1991 Run(Cmd::Lasso),
1992 Rule,
1993 ]
1994 .into_iter()
1995 .chain(shapes);
19671996 let inserted = [Cmd::Picture, Cmd::Link, Cmd::Date, Cmd::Equation];
19681997 let more = [
19691998 Cmd::ScreenClipping,
......@@ -1979,7 +2008,7 @@ impl State {
19792008 choice = group(
19802009 ui,
19812010 "insert",
1982 1,
2011 2,
19832012 |ui| {
19842013 divider(ui, theme);
19852014 let mut choice = None;
......@@ -1998,17 +2027,64 @@ impl State {
19982027 for id in inserted {
19992028 choice = tool(ui, id, status_of(id)).or(choice);
20002029 }
2001 dropdown(ui, "more", Head::More, &more, status_of).or(choice)
2030 let mut entries = more.to_vec();
2031 entries.push(Rule);
2032 entries.extend(drawing.clone());
2033 dropdown(ui, "more", Head::More, &entries, status_of).or(choice)
20022034 },
20032035 |ui| {
20042036 let mut entries = vec![Open("table", Cmd::Table)];
20052037 entries.extend(inserted.map(Run));
20062038 entries.push(Rule);
20072039 entries.extend(more);
2040 entries.push(Rule);
2041 entries.extend(drawing.clone());
20082042 dropdown(ui, "menu", Head::Menu(art::PLUS), &entries, status_of)
20092043 },
20102044 )
20112045 .or(choice);
2046 // Pen draws with the gallery's last pick. Folded, the group leaves its tools to
2047 // Insert's menus, so the narrowest row keeps its width.
2048 choice = group(
2049 ui,
2050 "draw",
2051 1,
2052 |ui| {
2053 divider(ui, theme);
2054 let mut choice = tool(ui, Cmd::SelectType, status_of(Cmd::SelectType));
2055 let status = status_of(Cmd::Pen);
2056 if status.enabled {
2057 let pen = drawing_pens[pens.pen.min(drawing_pens.len() - 1)];
2058 let bar = Some(pen.color.map_or(ui.theme.paper_ink, colorref));
2059 let split = ui.id("pen");
2060 if ui::shell::split_button(
2061 ui,
2062 "pen",
2063 art::PEN,
2064 bar,
2065 status.checked,
2066 toolbar_popup("pen"),
2067 )
2068 .clicked
2069 {
2070 choice = Some(Choice::Command(Cmd::Pen));
2071 }
2072 tip(ui, Cmd::Pen);
2073 let anchor = ui::Anchor::Below(ui.rect(split).unwrap_or_default());
2074 if let Some(place) = pen_gallery(ui, anchor, &drawing_pens, pens.pen) {
2075 choice = Some(Choice::Pen(place));
2076 }
2077 } else {
2078 ui::shell::unavailable(ui, "pen", art::PEN, text, true);
2079 }
2080 for id in [Cmd::Eraser, Cmd::Lasso] {
2081 choice = tool(ui, id, status_of(id)).or(choice);
2082 }
2083 dropdown(ui, "shapes", Head::Menu(art::SHAPES), &shapes, status_of).or(choice)
2084 },
2085 |_| None,
2086 )
2087 .or(choice);
20122088 ui.leaf(
20132089 "space",
20142090 Spec {
......@@ -2393,6 +2469,7 @@ impl State {
23932469 }
23942470 Command::Page(Request::OpenLink(address)) => self.open_link(&address)?,
23952471 Command::Page(Request::OpenAttachment(file)) => self.open_attachment(&file)?,
2472 Command::Page(Request::Play { file, at_ms }) => self.play(&file, at_ms)?,
23962473 Command::Choose(choice) => self.run(choice)?,
23972474 }
23982475 Ok(())
......@@ -2459,6 +2536,8 @@ impl State {
24592536 self.opening = Some(opening);
24602537 return Ok(());
24612538 }
2539 // A recording goes on the page it started on.
2540 self.stop_recording()?;
24622541 // The page shown so far keeps what was typed while the next one loaded.
24632542 self.persist()?;
24642543 if let Some(session) = &self.session {
......@@ -2486,15 +2565,11 @@ impl State {
24862565 }
24872566 // The notebook's background sync takes the section over once it is closed.
24882567 if let Some(previous) = self.session.replace(*session) {
2489 let background = previous.library.background.clone();
24902568 let section = previous.section;
24912569 std::thread::spawn(move || {
24922570 if let Err(error) = section.close() {
24932571 eprintln!("Synchronization stopped: {error}");
24942572 }
2495 if let Some(background) = background {
2496 background.wake();
2497 }
24982573 });
24992574 }
25002575 self.sectionless = None;
......@@ -2840,14 +2915,19 @@ impl State {
28402915 /// last poll.
28412916 fn synced(&mut self) -> Result<(), Box<dyn Error>> {
28422917 // Every notebook's changes are taken, so none is reported again.
2843 let noticed = self
2918 let changed: Vec<String> = self
28442919 .notebooks
28452920 .iter()
2846 .filter_map(|library| library.background.as_ref())
2847 .filter(|background| !background.changed().is_empty())
2848 .count();
2849 if noticed > 0 {
2850 self.sync_index(true);
2921 .filter_map(|library| Some((library, library.background.as_ref()?)))
2922 .flat_map(|(library, background)| {
2923 background
2924 .changed()
2925 .into_iter()
2926 .map(|path| library.key(&path))
2927 })
2928 .collect();
2929 if !changed.is_empty() {
2930 self.sync_index(false, changed);
28512931 }
28522932 // Reports are rare: a status changed, or a section did.
28532933 self.window.request_redraw();
......@@ -3811,6 +3891,50 @@ fn list_gallery(
38113891 Some(place(chosen))
38123892}
38133893
3894/// The pen gallery below `anchor` while open: each pen's stroke in its colour, as thick as it
3895/// draws, `current` outlined. Returns the place chosen.
3896fn pen_gallery(
3897 ui: &mut Ui,
3898 anchor: ui::Anchor,
3899 pens: &[canvas::editor::Pen],
3900 current: usize,
3901) -> Option<usize> {
3902 const CELL: [f32; 2] = [40.0, 24.0];
3903 let groups = [ui::popup::Group {
3904 heading: "Pens",
3905 cells: pens.len(),
3906 columns: 5,
3907 size: CELL,
3908 }];
3909 let ink = ui.theme.paper_ink;
3910 ui::popup::gallery(
3911 ui,
3912 toolbar_popup("pen"),
3913 anchor,
3914 &groups,
3915 &[current],
3916 |ui, place| {
3917 let pen = pens[place];
3918 let tall = if pen.highlighter {
3919 10.0
3920 } else {
3921 (pen.width / 25.0).clamp(1.5, 4.0)
3922 };
3923 let across = (CELL[1] - tall) / 2.0;
3924 ui.leaf(
3925 "stroke",
3926 Spec {
3927 size: [fill(), fill()],
3928 fill: Some(pen.color.map_or(ink, colorref)),
3929 inset: [6.0, across, 6.0, across],
3930 radius: tall / 2.0,
3931 ..Spec::default()
3932 },
3933 );
3934 },
3935 )
3936}
3937
38143938/// A toolbar group that folds by `priority`: `full` builds its full form, `folded` the form
38153939/// it folds to. Returns what either chose.
38163940fn group(
......@@ -4057,6 +4181,14 @@ fn artwork(id: commands::Id) -> Option<&'static [&'static str]> {
40574181 Cmd::Symbol => art::SYMBOL,
40584182 Cmd::RecordAudio => art::RECORD_AUDIO,
40594183 Cmd::RecordVideo => art::RECORD_VIDEO,
4184 Cmd::SelectType => art::SELECT,
4185 Cmd::Pen => art::PEN,
4186 Cmd::Eraser => art::ERASER,
4187 Cmd::Lasso => art::LASSO,
4188 Cmd::Shape(ShapeKind::Line) => art::SHAPE_LINE,
4189 Cmd::Shape(ShapeKind::Arrow) => art::SHAPE_ARROW,
4190 Cmd::Shape(ShapeKind::Rectangle) => art::SHAPE_RECTANGLE,
4191 Cmd::Shape(ShapeKind::Ellipse) => art::SHAPE_OVAL,
40604192 Cmd::PageColor => art::PAGE_COLOR,
40614193 Cmd::ZoomIn => art::ZOOM_IN,
40624194 Cmd::ZoomOut => art::ZOOM_OUT,
......@@ -4388,6 +4520,11 @@ impl ApplicationHandler<UserEvent> for App {
43884520 state.access_adapter.process_event(&state.window, &event);
43894521 }
43904522 if matches!(event, WindowEvent::CloseRequested) {
4523 if let Some(state) = &mut self.state
4524 && let Err(error) = state.stop_recording().and_then(|()| state.persist())
4525 {
4526 eprintln!("{error}");
4527 }
43914528 self.close(event_loop);
43924529 return;
43934530 }
......@@ -4502,10 +4639,14 @@ impl ApplicationHandler<UserEvent> for App {
45024639 if repaint || wake.is_some_and(|wake| wake <= now) {
45034640 state.window.request_redraw();
45044641 }
4505 let next = [blink, wake.filter(|wake| *wake > now)]
4506 .into_iter()
4507 .flatten()
4508 .min();
4642 let next = [
4643 blink,
4644 wake.filter(|wake| *wake > now),
4645 state.media_wake(now),
4646 ]
4647 .into_iter()
4648 .flatten()
4649 .min();
45094650 event_loop.set_control_flow(next.map_or(ControlFlow::Wait, ControlFlow::WaitUntil));
45104651 }
45114652}
crates/snowbound/src/media_linux.rs created+270
......@@ -0,0 +1,270 @@
1//! Audio through GStreamer, loaded at run time so builds need neither its headers nor its
2//! library: `playbin` plays whatever the installed plugins decode (Windows Media with
3//! gst-libav), and the default source records as PCM WAV.
4
5use std::{
6 cell::Cell,
7 ffi::{CStr, CString, c_char, c_int, c_void},
8 path::Path,
9 sync::OnceLock,
10};
11
12type Element = *mut c_void;
13
14/// GST_STATE_NULL, GST_STATE_PAUSED and GST_STATE_PLAYING.
15const NULL: c_int = 1;
16const PAUSED: c_int = 3;
17const PLAYING: c_int = 4;
18/// GST_FORMAT_TIME, in nanoseconds.
19const TIME: c_int = 3;
20/// GST_SEEK_FLAG_FLUSH | GST_SEEK_FLAG_ACCURATE.
21const SEEK: c_int = 1 | 2;
22/// GST_MESSAGE_EOS, GST_MESSAGE_ERROR and GST_MESSAGE_ASYNC_DONE.
23const EOS: c_int = 1;
24const ERROR: c_int = 2;
25const ASYNC_DONE: c_int = 1 << 21;
26/// Nanoseconds a pipeline has to get ready or to finish.
27const PATIENCE: u64 = 5_000_000_000;
28/// GST_STATE_CHANGE_FAILURE.
29const FAILURE: c_int = 0;
30
31/// The GStreamer entry points used, from `libgstreamer-1.0.so.0`.
32struct Gst {
33 parse_launch: unsafe extern "C" fn(*const c_char, *mut *mut c_void) -> Element,
34 set_state: unsafe extern "C" fn(Element, c_int) -> c_int,
35 query_position: unsafe extern "C" fn(Element, c_int, *mut i64) -> c_int,
36 query_duration: unsafe extern "C" fn(Element, c_int, *mut i64) -> c_int,
37 seek_simple: unsafe extern "C" fn(Element, c_int, c_int, i64) -> c_int,
38 send_event: unsafe extern "C" fn(Element, *mut c_void) -> c_int,
39 new_eos: unsafe extern "C" fn() -> *mut c_void,
40 get_bus: unsafe extern "C" fn(Element) -> *mut c_void,
41 pop: unsafe extern "C" fn(*mut c_void, u64, c_int) -> *mut c_void,
42 unref_message: unsafe extern "C" fn(*mut c_void),
43 unref: unsafe extern "C" fn(*mut c_void),
44}
45
46// SAFETY: GStreamer's entry points are thread-safe once initialized.
47unsafe impl Send for Gst {}
48unsafe impl Sync for Gst {}
49
50/// The function `name` in `library`, of C signature `F`.
51unsafe fn symbol<F: Copy>(library: *mut c_void, name: &CStr) -> Option<F> {
52 let symbol = unsafe { libc::dlsym(library, name.as_ptr()) };
53 (!symbol.is_null()).then(|| unsafe { std::mem::transmute_copy::<*mut c_void, F>(&symbol) })
54}
55
56/// GStreamer, initialized; none where it is not installed.
57fn gst() -> Option<&'static Gst> {
58 static GST: OnceLock<Option<Gst>> = OnceLock::new();
59 GST.get_or_init(|| unsafe {
60 let library = libc::dlopen(c"libgstreamer-1.0.so.0".as_ptr(), libc::RTLD_NOW);
61 if library.is_null() {
62 return None;
63 }
64 let init: unsafe extern "C" fn(*mut c_int, *mut c_void) = symbol(library, c"gst_init")?;
65 init(std::ptr::null_mut(), std::ptr::null_mut());
66 Some(Gst {
67 parse_launch: symbol(library, c"gst_parse_launch")?,
68 set_state: symbol(library, c"gst_element_set_state")?,
69 query_position: symbol(library, c"gst_element_query_position")?,
70 query_duration: symbol(library, c"gst_element_query_duration")?,
71 seek_simple: symbol(library, c"gst_element_seek_simple")?,
72 send_event: symbol(library, c"gst_element_send_event")?,
73 new_eos: symbol(library, c"gst_event_new_eos")?,
74 get_bus: symbol(library, c"gst_element_get_bus")?,
75 pop: symbol(library, c"gst_bus_timed_pop_filtered")?,
76 unref_message: symbol(library, c"gst_mini_object_unref")?,
77 unref: symbol(library, c"gst_object_unref")?,
78 })
79 })
80 .as_ref()
81}
82
83/// `path` as a `file://` URI, every byte outside the unreserved set escaped.
84fn uri(path: &Path) -> String {
85 let mut uri = String::from("file://");
86 for byte in path.as_os_str().as_encoded_bytes() {
87 match byte {
88 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => {
89 uri.push(char::from(*byte));
90 }
91 _ => uri.push_str(&format!("%{byte:02X}")),
92 }
93 }
94 uri
95}
96
97/// A pipeline described as `gst-launch` takes one, paused.
98struct Pipeline {
99 gst: &'static Gst,
100 element: Element,
101}
102
103impl Pipeline {
104 fn launch(description: &str) -> Option<Self> {
105 let gst = gst()?;
106 let description = CString::new(description).ok()?;
107 let element = unsafe { (gst.parse_launch)(description.as_ptr(), std::ptr::null_mut()) };
108 if element.is_null() {
109 return None;
110 }
111 let pipeline = Self { gst, element };
112 pipeline.set(PAUSED).then_some(pipeline)
113 }
114
115 fn set(&self, state: c_int) -> bool {
116 unsafe { (self.gst.set_state)(self.element, state) != FAILURE }
117 }
118
119 /// The type of the next message of `types` the pipeline posts within `timeout`
120 /// nanoseconds.
121 fn wait(&self, timeout: u64, types: c_int) -> Option<c_int> {
122 unsafe {
123 let bus = (self.gst.get_bus)(self.element);
124 let message = (self.gst.pop)(bus, timeout, types);
125 (self.gst.unref)(bus);
126 if message.is_null() {
127 return None;
128 }
129 // GstMessage's type follows its GstMiniObject header.
130 let kind = *message
131 .cast::<u8>()
132 .add(std::mem::size_of::<MiniObject>())
133 .cast::<c_int>();
134 (self.gst.unref_message)(message);
135 Some(kind)
136 }
137 }
138
139 fn query(&self, query: unsafe extern "C" fn(Element, c_int, *mut i64) -> c_int) -> u32 {
140 let mut nanoseconds = 0_i64;
141 let known = unsafe { query(self.element, TIME, &mut nanoseconds) } != 0;
142 if known {
143 (nanoseconds.max(0) / 1_000_000) as u32
144 } else {
145 0
146 }
147 }
148}
149
150impl Drop for Pipeline {
151 fn drop(&mut self) {
152 self.set(NULL);
153 unsafe { (self.gst.unref)(self.element) };
154 }
155}
156
157/// GstMiniObject's layout, which a message's type follows.
158#[repr(C)]
159struct MiniObject {
160 kind: usize,
161 refcount: c_int,
162 lockstate: c_int,
163 flags: u32,
164 copy: *mut c_void,
165 dispose: *mut c_void,
166 free: *mut c_void,
167 priv_uint: u32,
168 priv_pointer: *mut c_void,
169}
170
171/// A recording or other audio file playing, paused or ended.
172pub struct Player {
173 pipeline: Pipeline,
174 playing: bool,
175 /// Whether the file played to its end, which the pipeline reports once.
176 ended: Cell<bool>,
177}
178
179impl Player {
180 /// The file at `path`, ready to play; none when GStreamer is missing or cannot decode it.
181 pub fn open(path: &Path) -> Option<Self> {
182 let pipeline = Pipeline::launch(&format!(
183 "playbin uri=\"{}\" video-sink=fakesink",
184 uri(path)
185 ))?;
186 // Prerolling reports a file no plugin decodes as an error.
187 if pipeline.wait(PATIENCE, ASYNC_DONE | ERROR) != Some(ASYNC_DONE) {
188 return None;
189 }
190 Some(Self {
191 pipeline,
192 playing: false,
193 ended: Cell::new(false),
194 })
195 }
196
197 pub fn play(&mut self) {
198 self.ended.set(false);
199 self.playing = self.pipeline.set(PLAYING);
200 }
201
202 pub fn pause(&mut self) {
203 self.pipeline.set(PAUSED);
204 self.playing = false;
205 }
206
207 pub fn playing(&self) -> bool {
208 if self.playing && !self.ended.get() && self.pipeline.wait(0, EOS).is_some() {
209 self.ended.set(true);
210 }
211 self.playing && !self.ended.get()
212 }
213
214 pub fn position_ms(&self) -> u32 {
215 if self.ended.get() {
216 return self.duration_ms();
217 }
218 self.pipeline.query(self.pipeline.gst.query_position)
219 }
220
221 pub fn duration_ms(&self) -> u32 {
222 self.pipeline.query(self.pipeline.gst.query_duration)
223 }
224
225 pub fn seek(&mut self, ms: u32) {
226 self.ended.set(false);
227 unsafe {
228 (self.pipeline.gst.seek_simple)(
229 self.pipeline.element,
230 TIME,
231 SEEK,
232 i64::from(ms) * 1_000_000,
233 );
234 }
235 }
236}
237
238/// The default microphone recording to a file.
239pub struct Microphone(Pipeline);
240
241impl Microphone {
242 /// Records mono 16-bit PCM at `rate` into a WAV file at `path`.
243 pub fn start(path: &Path, rate: u32) -> Result<Self, String> {
244 let location = path
245 .to_str()
246 .filter(|location| !location.contains('"'))
247 .ok_or("Try recording again.")?;
248 let pipeline = Pipeline::launch(&format!(
249 "autoaudiosrc ! audioconvert ! audioresample ! \
250 audio/x-raw,format=S16LE,rate={rate},channels=1 ! wavenc ! \
251 filesink location=\"{location}\""
252 ))
253 .ok_or("Install GStreamer and its base plugins to record audio.")?;
254 if !pipeline.set(PLAYING) {
255 return Err("Connect a microphone and try again.".into());
256 }
257 Ok(Self(pipeline))
258 }
259
260 /// Ends the recording, its file complete: the end travels down the pipeline so the WAV
261 /// header is written.
262 pub fn stop(self) -> Result<(), String> {
263 let pipeline = &self.0;
264 unsafe { (pipeline.gst.send_event)(pipeline.element, (pipeline.gst.new_eos)()) };
265 match pipeline.wait(PATIENCE, EOS | ERROR) {
266 Some(EOS) => Ok(()),
267 _ => Err("The recording stopped early. Try recording again.".into()),
268 }
269 }
270}
crates/snowbound/src/media_macos.rs created+182
......@@ -0,0 +1,182 @@
1//! Audio through AVFoundation: `AVAudioPlayer` plays what Core Audio decodes (WAV, MP3, AAC;
2//! not Windows Media), and `AVAudioRecorder` records the default microphone as PCM WAV.
3
4use objc2::{
5 msg_send, msg_send_id,
6 rc::{Allocated, Retained},
7 runtime::{AnyClass, AnyObject},
8};
9use objc2_foundation::{NSString, NSURL};
10use std::path::Path;
11
12#[link(name = "AVFoundation", kind = "framework")]
13unsafe extern "C" {
14 static AVFormatIDKey: &'static NSString;
15 static AVSampleRateKey: &'static NSString;
16 static AVNumberOfChannelsKey: &'static NSString;
17 static AVLinearPCMBitDepthKey: &'static NSString;
18 static AVLinearPCMIsFloatKey: &'static NSString;
19 static AVLinearPCMIsBigEndianKey: &'static NSString;
20 static AVMediaTypeAudio: &'static NSString;
21}
22
23/// kAudioFormatLinearPCM.
24const LINEAR_PCM: u32 = u32::from_be_bytes(*b"lpcm");
25
26fn class(name: &str) -> &'static AnyClass {
27 AnyClass::get(name).expect("AVFoundation is linked")
28}
29
30fn url(path: &Path) -> Option<Retained<NSURL>> {
31 Some(unsafe { NSURL::fileURLWithPath(&NSString::from_str(path.to_str()?)) })
32}
33
34/// A recording or other audio file playing, paused or ended.
35pub struct Player(Retained<AnyObject>);
36
37impl Player {
38 /// The file at `path`, ready to play; none when Core Audio cannot decode it.
39 pub fn open(path: &Path) -> Option<Self> {
40 let url = url(path)?;
41 unsafe {
42 let player: Allocated<AnyObject> = msg_send_id![class("AVAudioPlayer"), alloc];
43 let player: Option<Retained<AnyObject>> = msg_send_id![
44 player,
45 initWithContentsOfURL: &*url,
46 error: std::ptr::null_mut::<*mut AnyObject>()
47 ];
48 let player = player?;
49 let ready: bool = msg_send![&player, prepareToPlay];
50 ready.then_some(Self(player))
51 }
52 }
53
54 pub fn play(&mut self) {
55 let _: bool = unsafe { msg_send![&self.0, play] };
56 }
57
58 pub fn pause(&mut self) {
59 let _: () = unsafe { msg_send![&self.0, pause] };
60 }
61
62 pub fn playing(&self) -> bool {
63 unsafe { msg_send![&self.0, isPlaying] }
64 }
65
66 pub fn position_ms(&self) -> u32 {
67 let seconds: f64 = unsafe { msg_send![&self.0, currentTime] };
68 (seconds * 1000.0) as u32
69 }
70
71 pub fn duration_ms(&self) -> u32 {
72 let seconds: f64 = unsafe { msg_send![&self.0, duration] };
73 (seconds * 1000.0) as u32
74 }
75
76 pub fn seek(&mut self, ms: u32) {
77 let _: () = unsafe { msg_send![&self.0, setCurrentTime: f64::from(ms) / 1000.0] };
78 }
79}
80
81impl Drop for Player {
82 fn drop(&mut self) {
83 let _: () = unsafe { msg_send![&self.0, stop] };
84 }
85}
86
87/// The default microphone recording to a file.
88pub struct Microphone(Retained<AnyObject>);
89
90impl Microphone {
91 /// Records mono 16-bit PCM at `rate` into a WAV file at `path`. The system asks for the
92 /// microphone the first time; refused, recording fails with what to do about it.
93 pub fn start(path: &Path, rate: u32) -> Result<Self, String> {
94 let refused = || {
95 "Allow Snowbound to use the microphone in System Settings, Privacy & Security, \
96 Microphone."
97 .to_owned()
98 };
99 unsafe {
100 // AVAuthorizationStatusRestricted and AVAuthorizationStatusDenied.
101 let status: isize = msg_send![
102 class("AVCaptureDevice"),
103 authorizationStatusForMediaType: AVMediaTypeAudio
104 ];
105 if matches!(status, 1 | 2) {
106 return Err(refused());
107 }
108 let number = |value: u32| -> Retained<AnyObject> {
109 msg_send_id![class("NSNumber"), numberWithUnsignedInt: value]
110 };
111 let settings: Retained<AnyObject> =
112 msg_send_id![class("NSMutableDictionary"), dictionary];
113 for (key, value) in [
114 (AVFormatIDKey, number(LINEAR_PCM)),
115 (AVSampleRateKey, number(rate)),
116 (AVNumberOfChannelsKey, number(1)),
117 (AVLinearPCMBitDepthKey, number(16)),
118 (AVLinearPCMIsFloatKey, number(0)),
119 (AVLinearPCMIsBigEndianKey, number(0)),
120 ] {
121 let _: () = msg_send![&settings, setObject: &*value, forKey: key];
122 }
123 let url = url(path).ok_or("Try recording again.")?;
124 let recorder: Allocated<AnyObject> = msg_send_id![class("AVAudioRecorder"), alloc];
125 let recorder: Option<Retained<AnyObject>> = msg_send_id![
126 recorder,
127 initWithURL: &*url,
128 settings: &*settings,
129 error: std::ptr::null_mut::<*mut AnyObject>()
130 ];
131 let recorder = recorder.ok_or("Connect a microphone and try again.")?;
132 let recording: bool = msg_send![&recorder, record];
133 if !recording {
134 return Err(refused());
135 }
136 Ok(Self(recorder))
137 }
138 }
139
140 /// Ends the recording, its file complete.
141 pub fn stop(self) -> Result<(), String> {
142 let _: () = unsafe { msg_send![&self.0, stop] };
143 Ok(())
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 /// What Snowbound records, compressed, plays back through Core Audio at its length.
152 #[test]
153 fn core_audio_plays_the_compressed_recordings() {
154 let pcm =
155 crate::recording::tests::pcm(crate::recording::RATE, &crate::recording::tests::tone());
156 let (bytes, duration) = crate::recording::compress(&pcm).unwrap();
157 let folder = std::env::temp_dir().join(format!("snowbound-player-{}", std::process::id()));
158 std::fs::create_dir_all(&folder).unwrap();
159 let path = folder.join("tone.wav");
160 std::fs::write(&path, bytes).unwrap();
161 let mut player = Player::open(&path).unwrap();
162 assert!(
163 player.duration_ms().abs_diff(duration) < 50,
164 "{}",
165 player.duration_ms()
166 );
167 player.seek(1500);
168 assert!(player.position_ms().abs_diff(1500) < 50);
169 drop(player);
170 // OneNote's own recording, Windows Media Audio, stays with the system's player.
171 let wma = folder.join("Recorded.wma");
172 std::fs::write(
173 &wma,
174 include_bytes!(
175 "../../../corpus/recording/edit/cold/read/c41bd6bd61277bdc4bf3557b5572be5c08451ba5b4665aba662ac1280d907ca3.attachment"
176 ),
177 )
178 .unwrap();
179 assert!(Player::open(&wma).is_none());
180 std::fs::remove_dir_all(&folder).unwrap();
181 }
182}
crates/snowbound/src/pane.rs+2-2
......@@ -391,7 +391,7 @@ impl State {
391391 self.search.finding = false;
392392 self.search.claim = true;
393393 self.ui.close_popup(crate::search::results());
394 self.sync_index(true);
394 self.sync_index(true, Vec::new());
395395 }
396396
397397 /// Opens or closes a pane, as its command does.
......@@ -406,7 +406,7 @@ impl State {
406406 unchecked: false,
407407 scope: TagScope::Notebook,
408408 });
409 self.sync_index(true);
409 self.sync_index(true, Vec::new());
410410 }
411411 _ => self.open_search_pane(),
412412 }
crates/snowbound/src/recording.rs created+687
......@@ -0,0 +1,687 @@
1//! Record Audio and playback, as OneNote 2010 records and plays (`corpus/recording`): a
2//! recording starts at the caret and goes on the page when stopped, as a WAV file OneNote
3//! plays; a recording, or a note linked to a moment in one, plays in a small transport over
4//! the page. What the platform cannot decode opens in the system's player.
5
6use crate::{State, media, platform};
7use onestore::page::{Attachment, Recording};
8use std::{
9 error::Error,
10 path::{Path, PathBuf},
11 time::{Duration, Instant},
12};
13use ui::{Axis, Flags, Spec, Theme, fit, px};
14
15/// The rate recordings are made at: wideband speech, as OneNote's own 8 kHz WMA is narrowband.
16pub(crate) const RATE: u32 = 16_000;
17
18/// What records or plays now.
19#[derive(Default)]
20pub(crate) enum Media {
21 #[default]
22 Idle,
23 Recording {
24 microphone: media::Microphone,
25 id: [u8; 16],
26 path: PathBuf,
27 started: Instant,
28 },
29 Playing {
30 player: media::Player,
31 name: String,
32 },
33}
34
35impl State {
36 /// Record Audio: starts recording at the caret, or stops the recording under way.
37 pub(crate) fn record_audio(&mut self) -> Result<(), Box<dyn Error>> {
38 if matches!(self.media, Media::Recording { .. }) {
39 return self.stop_recording();
40 }
41 self.media = Media::Idle;
42 let folder = std::env::temp_dir().join("Snowbound Recordings");
43 std::fs::create_dir_all(&folder)?;
44 let path = folder.join(format!("{}.wav", std::process::id()));
45 let microphone = match media::Microphone::start(&path, RATE) {
46 Ok(microphone) => microphone,
47 Err(detail) => {
48 platform::alert("Couldn't record audio", &detail);
49 return Ok(());
50 }
51 };
52 let [date, time] = platform::date_text(crate::filetime());
53 let label = format!("Audio recording started: {time} {date}");
54 let (id, response) = self.view.start_recording(&label)?;
55 self.respond(response);
56 let Some(id) = id else {
57 microphone.stop()?;
58 return Ok(());
59 };
60 self.media = Media::Recording {
61 microphone,
62 id,
63 path,
64 started: Instant::now(),
65 };
66 Ok(())
67 }
68
69 /// Stop: the recording under way goes on the page where it started.
70 pub(crate) fn stop_recording(&mut self) -> Result<(), Box<dyn Error>> {
71 let Media::Recording {
72 microphone,
73 id,
74 path,
75 ..
76 } = std::mem::take(&mut self.media)
77 else {
78 return Ok(());
79 };
80 if let Err(detail) = microphone.stop() {
81 platform::alert("Couldn't finish the recording", &detail);
82 return Ok(());
83 }
84 let recorded = std::fs::read(&path)?;
85 let (bytes, duration_ms) = match compress(&recorded) {
86 Some((bytes, duration)) => (bytes, Some(duration)),
87 None => (recorded, None),
88 };
89 let preview = platform::file_icon(&path);
90 std::fs::remove_file(&path)?;
91 let title = self.view.editor.page()?.title;
92 let name: String = title
93 .chars()
94 .map(|c| {
95 if matches!(c, '/' | '\\' | '\0') {
96 '_'
97 } else {
98 c
99 }
100 })
101 .collect();
102 let name = if name.trim().is_empty() {
103 "Audio recording".to_owned()
104 } else {
105 name
106 };
107 let file = Attachment {
108 id: onestore::page::text::new_id()?,
109 filename: format!("{name}.wav"),
110 source_path: None,
111 size: Some(crate::attachment::ICON_SIZE),
112 layout: Default::default(),
113 bytes: Some(bytes.into()),
114 preview: preview.map(Into::into),
115 recording: Some(Recording {
116 id,
117 kind: 1,
118 duration_ms,
119 }),
120 };
121 let response = self.view.finish_recording(file)?;
122 self.respond(response);
123 Ok(())
124 }
125
126 /// Plays recording `file` from `at_ms` in the transport, or opens it in the system's
127 /// player where the platform cannot decode it.
128 pub(crate) fn play(&mut self, file: &Attachment, at_ms: u32) -> Result<(), Box<dyn Error>> {
129 if matches!(self.media, Media::Recording { .. }) {
130 return Ok(());
131 }
132 self.media = Media::Idle;
133 let Some(path) = self.copy_attachment(file)? else {
134 return Ok(());
135 };
136 let Some(mut player) = media::Player::open(&path) else {
137 platform::open_file(&path);
138 return Ok(());
139 };
140 player.seek(at_ms);
141 player.play();
142 let name = Path::new(&file.filename)
143 .file_stem()
144 .map_or_else(String::new, |stem| stem.to_string_lossy().into_owned());
145 self.media = Media::Playing { player, name };
146 Ok(())
147 }
148
149 /// When the transport's clock next changes what it shows.
150 pub(crate) fn media_wake(&self, now: Instant) -> Option<Instant> {
151 match &self.media {
152 Media::Idle => None,
153 Media::Playing { player, .. } if !player.playing() => None,
154 _ => Some(now + Duration::from_millis(250)),
155 }
156 }
157
158 /// The transport over the page's foot while something records or plays: OneNote's
159 /// Recording and Playback tabs, cut to what a note taker reaches for.
160 pub(crate) fn transport(
161 &mut self,
162 theme: &Theme,
163 page: [f32; 4],
164 ) -> Result<(), Box<dyn Error>> {
165 let (clock, playing) = match &self.media {
166 Media::Idle => return Ok(()),
167 Media::Recording { started, .. } => (
168 format!("Recording {}", clock(started.elapsed().as_millis() as u32)),
169 None,
170 ),
171 Media::Playing { player, name } => (
172 format!(
173 "{name} {} / {}",
174 clock(player.position_ms()),
175 clock(player.duration_ms())
176 ),
177 Some(player.playing()),
178 ),
179 };
180 let height = theme.font_size * 2.0 + 16.0;
181 self.ui.open(
182 "transport",
183 Spec {
184 flags: Flags::FLOAT | Flags::CLICKABLE,
185 axis: Axis::X,
186 size: [ui::children(), px(height)],
187 position: [16.0, page[3] - page[1] - height - 16.0],
188 fill: Some(theme.popup),
189 shadow: Some(theme.shadow),
190 radius: 8.0,
191 pad: [8.0, 8.0],
192 gap: 6.0,
193 ..Spec::default()
194 },
195 );
196 let mut chosen = None;
197 if let Some(playing) = playing {
198 if ui::button(&mut self.ui, "play", if playing { "Pause" } else { "Play" }).clicked {
199 chosen = Some(Control::Toggle);
200 }
201 if ui::button(&mut self.ui, "back", "−10 s").clicked {
202 chosen = Some(Control::Skip(-10_000));
203 }
204 if ui::button(&mut self.ui, "forward", "+10 s").clicked {
205 chosen = Some(Control::Skip(10_000));
206 }
207 }
208 if ui::button(&mut self.ui, "stop", "Stop").clicked {
209 chosen = Some(Control::Stop);
210 }
211 self.ui.leaf(
212 "clock",
213 Spec {
214 size: [fit(), px(theme.font_size * 2.0)],
215 text: Some(&clock),
216 pad: [6.0, 0.0],
217 center: true,
218 ..Spec::default()
219 },
220 );
221 self.ui.close();
222 match (chosen, &mut self.media) {
223 (Some(Control::Stop), Media::Recording { .. }) => self.stop_recording()?,
224 (Some(Control::Stop), _) => self.media = Media::Idle,
225 (Some(Control::Toggle), Media::Playing { player, .. }) => {
226 if player.playing() {
227 player.pause();
228 } else {
229 if player.position_ms() >= player.duration_ms() {
230 player.seek(0);
231 }
232 player.play();
233 }
234 }
235 (Some(Control::Skip(delta)), Media::Playing { player, .. }) => {
236 let at = i64::from(player.position_ms()) + delta;
237 player.seek(at.clamp(0, i64::from(player.duration_ms())) as u32);
238 }
239 _ => {}
240 }
241 Ok(())
242 }
243}
244
245enum Control {
246 Toggle,
247 Stop,
248 Skip(i64),
249}
250
251/// `ms` as the transport shows a moment: minutes and seconds, hours when there are some.
252fn clock(ms: u32) -> String {
253 let seconds = ms / 1000;
254 match seconds / 3600 {
255 0 => format!("{}:{:02}", seconds / 60, seconds % 60),
256 hours => format!("{hours}:{:02}:{:02}", seconds / 60 % 60, seconds % 60),
257 }
258}
259
260/// A recording for a file attached under `filename`, as OneNote 2010 makes one of every
261/// audio and video file it attaches, with the length of a WAV file's `bytes`.
262pub(crate) fn attached(filename: &str, bytes: &[u8]) -> Option<Recording> {
263 Some(Recording {
264 id: onestore::page::text::new_guid().ok()?,
265 kind: Recording::kind_of(filename)?,
266 duration_ms: Wave::parse(bytes).map(|wave| wave.duration_ms()),
267 })
268}
269
270/// The chunks of a RIFF WAVE file this module reads.
271struct Wave<'a> {
272 format: u16,
273 channels: u16,
274 rate: u32,
275 bytes_per_second: u32,
276 bits: u16,
277 /// The sample count a compressed file's `fact` chunk states.
278 samples: Option<u32>,
279 data: &'a [u8],
280}
281
282impl<'a> Wave<'a> {
283 fn parse(bytes: &'a [u8]) -> Option<Self> {
284 if bytes.get(..4)? != b"RIFF" || bytes.get(8..12)? != b"WAVE" {
285 return None;
286 }
287 let u16_at = |data: &[u8], at: usize| {
288 Some(u16::from_le_bytes(data.get(at..at + 2)?.try_into().ok()?))
289 };
290 let u32_at = |data: &[u8], at: usize| {
291 Some(u32::from_le_bytes(data.get(at..at + 4)?.try_into().ok()?))
292 };
293 let (mut fmt, mut samples, mut data) = (None, None, None);
294 let mut at = 12;
295 while at + 8 <= bytes.len() {
296 let size = u32_at(bytes, at + 4)? as usize;
297 let body = &bytes[at + 8..(at + 8).saturating_add(size).min(bytes.len())];
298 match &bytes[at..at + 4] {
299 b"fmt " => fmt = Some(body),
300 b"fact" => samples = u32_at(body, 0),
301 b"data" => data = Some(body),
302 _ => {}
303 }
304 at = at + 8 + size + size % 2;
305 }
306 let fmt = fmt?;
307 Some(Self {
308 format: u16_at(fmt, 0)?,
309 channels: u16_at(fmt, 2)?,
310 rate: u32_at(fmt, 4)?,
311 bytes_per_second: u32_at(fmt, 8)?,
312 bits: u16_at(fmt, 14)?,
313 samples,
314 data: data?,
315 })
316 }
317
318 fn duration_ms(&self) -> u32 {
319 let ms = match self.samples.filter(|_| self.format != 1) {
320 Some(samples) => u64::from(samples) * 1000 / u64::from(self.rate.max(1)),
321 None => self.data.len() as u64 * 1000 / u64::from(self.bytes_per_second.max(1)),
322 };
323 u32::try_from(ms).unwrap_or(u32::MAX)
324 }
325}
326
327/// IMA ADPCM's step sizes and how each code moves along them.
328const STEPS: [i32; 89] = [
329 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 19, 21, 23, 25, 28, 31, 34, 37, 41, 45, 50, 55, 60, 66,
330 73, 80, 88, 97, 107, 118, 130, 143, 157, 173, 190, 209, 230, 253, 279, 307, 337, 371, 408, 449,
331 494, 544, 598, 658, 724, 796, 876, 963, 1060, 1166, 1282, 1411, 1552, 1707, 1878, 2066, 2272,
332 2499, 2749, 3024, 3327, 3660, 4026, 4428, 4871, 5358, 5894, 6484, 7132, 7845, 8630, 9493,
333 10442, 11487, 12635, 13899, 15289, 16818, 18500, 20350, 22385, 24623, 27086, 29794, 32767,
334];
335const INDEX_STEPS: [i32; 8] = [-1, -1, -1, -1, 2, 4, 6, 8];
336
337/// Bytes per block: Windows' IMA ADPCM codec's choice for 16 kHz.
338const BLOCK: usize = 512;
339/// Samples per block: the header's, then two per remaining byte.
340const BLOCK_SAMPLES: usize = (BLOCK - 4) * 2 + 1;
341
342/// A mono 16-bit PCM WAV file as 4-bit IMA ADPCM (WAVE_FORMAT_DVI_ADPCM), which Windows
343/// and macOS decode, a quarter of the size; and its length. None for other files.
344pub(crate) fn compress(wav: &[u8]) -> Option<(Vec<u8>, u32)> {
345 let wave = Wave::parse(wav)?;
346 if wave.format != 1 || wave.channels != 1 || wave.bits != 16 {
347 return None;
348 }
349 let samples: Vec<i16> = wave
350 .data
351 .chunks_exact(2)
352 .map(|pair| i16::from_le_bytes([pair[0], pair[1]]))
353 .collect();
354 let mut data = Vec::with_capacity(samples.len() / 2 + BLOCK);
355 let mut index = 0_i32;
356 for block in samples.chunks(BLOCK_SAMPLES) {
357 let mut predicted = i32::from(block[0]);
358 data.extend_from_slice(&block[0].to_le_bytes());
359 data.extend_from_slice(&[index as u8, 0]);
360 // A short last block is padded with its last sample; `fact` gives the true length.
361 let last = *block.last().unwrap();
362 let rest = block[1..]
363 .iter()
364 .copied()
365 .chain(std::iter::repeat(last))
366 .take(BLOCK_SAMPLES - 1)
367 .collect::<Vec<_>>();
368 for pair in rest.chunks_exact(2) {
369 let mut byte = 0;
370 for (shift, sample) in [0, 4].into_iter().zip(pair) {
371 let code = encode(i32::from(*sample), &mut predicted, &mut index);
372 byte |= code << shift;
373 }
374 data.push(byte);
375 }
376 }
377 let count = u32::try_from(samples.len()).ok()?;
378 let bytes_per_second = wave.rate * BLOCK as u32 / BLOCK_SAMPLES as u32;
379 let mut out = Vec::with_capacity(data.len() + 60);
380 let fmt: Vec<u8> = [
381 &0x11_u16.to_le_bytes()[..],
382 &1_u16.to_le_bytes(),
383 &wave.rate.to_le_bytes(),
384 &bytes_per_second.to_le_bytes(),
385 &(BLOCK as u16).to_le_bytes(),
386 &4_u16.to_le_bytes(),
387 &2_u16.to_le_bytes(),
388 &(BLOCK_SAMPLES as u16).to_le_bytes(),
389 ]
390 .concat();
391 let riff = 4 + (8 + fmt.len()) + (8 + 4) + (8 + data.len());
392 out.extend_from_slice(b"RIFF");
393 out.extend_from_slice(&u32::try_from(riff).ok()?.to_le_bytes());
394 out.extend_from_slice(b"WAVEfmt ");
395 out.extend_from_slice(&(fmt.len() as u32).to_le_bytes());
396 out.extend_from_slice(&fmt);
397 out.extend_from_slice(b"fact");
398 out.extend_from_slice(&4_u32.to_le_bytes());
399 out.extend_from_slice(&count.to_le_bytes());
400 out.extend_from_slice(b"data");
401 out.extend_from_slice(&u32::try_from(data.len()).ok()?.to_le_bytes());
402 out.extend_from_slice(&data);
403 let duration = u32::try_from(u64::from(count) * 1000 / u64::from(wave.rate.max(1))).ok()?;
404 Some((out, duration))
405}
406
407/// The 4-bit code nearest `sample` from `predicted`, which it and `index` then follow as a
408/// decoder does.
409fn encode(sample: i32, predicted: &mut i32, index: &mut i32) -> u8 {
410 let step = STEPS[*index as usize];
411 let mut difference = sample - *predicted;
412 let mut code = 0;
413 if difference < 0 {
414 code = 8;
415 difference = -difference;
416 }
417 let mut delta = step >> 3;
418 for (bit, part) in [(4, step), (2, step >> 1), (1, step >> 2)] {
419 if difference >= part {
420 code |= bit;
421 difference -= part;
422 delta += part;
423 }
424 }
425 *predicted = if code & 8 != 0 {
426 *predicted - delta
427 } else {
428 *predicted + delta
429 }
430 .clamp(-32768, 32767);
431 *index = (*index + INDEX_STEPS[usize::from(code & 7)]).clamp(0, 88);
432 code
433}
434
435#[cfg(test)]
436pub(crate) mod tests {
437 use super::*;
438
439 /// A mono 16-bit PCM WAV file of `samples` at `rate`, as the platform recorder writes.
440 pub(crate) fn pcm(rate: u32, samples: &[i16]) -> Vec<u8> {
441 let data: Vec<u8> = samples.iter().flat_map(|s| s.to_le_bytes()).collect();
442 [
443 &b"RIFF"[..],
444 &(36 + data.len() as u32).to_le_bytes(),
445 b"WAVEfmt ",
446 &16_u32.to_le_bytes(),
447 &1_u16.to_le_bytes(),
448 &1_u16.to_le_bytes(),
449 &rate.to_le_bytes(),
450 &(rate * 2).to_le_bytes(),
451 &2_u16.to_le_bytes(),
452 &16_u16.to_le_bytes(),
453 b"data",
454 &(data.len() as u32).to_le_bytes(),
455 &data,
456 ]
457 .concat()
458 }
459
460 /// Two seconds of a spoken-pitch chord.
461 pub(crate) fn tone() -> Vec<i16> {
462 (0..2 * RATE)
463 .map(|n| {
464 let t = n as f32 / RATE as f32;
465 let wave = (t * 220.0 * std::f32::consts::TAU).sin() * 0.5
466 + (t * 330.0 * std::f32::consts::TAU).sin() * 0.3;
467 (wave * 12_000.0) as i16
468 })
469 .collect()
470 }
471
472 /// The reference decoder: each block's header sample and index, then its codes.
473 fn decode(wave: &Wave<'_>) -> Vec<i16> {
474 let mut out = Vec::new();
475 for block in wave.data.chunks(BLOCK) {
476 let mut predicted = i32::from(i16::from_le_bytes([block[0], block[1]]));
477 let mut index = i32::from(block[2]);
478 out.push(predicted as i16);
479 for byte in &block[4..] {
480 for code in [byte & 15, byte >> 4] {
481 let step = STEPS[index as usize];
482 let mut delta = step >> 3;
483 for (bit, part) in [(4, step), (2, step >> 1), (1, step >> 2)] {
484 if code & bit != 0 {
485 delta += part;
486 }
487 }
488 predicted = if code & 8 != 0 {
489 predicted - delta
490 } else {
491 predicted + delta
492 }
493 .clamp(-32768, 32767);
494 index = (index + INDEX_STEPS[usize::from(code & 7)]).clamp(0, 88);
495 out.push(predicted as i16);
496 }
497 }
498 }
499 out.truncate(wave.samples.unwrap() as usize);
500 out
501 }
502
503 #[test]
504 fn a_recording_compresses_to_ima_adpcm_that_decodes_close_to_what_was_heard() {
505 let samples = tone();
506 let (bytes, duration) = compress(&pcm(RATE, &samples)).unwrap();
507 assert_eq!(duration, 2000);
508 let wave = Wave::parse(&bytes).unwrap();
509 assert_eq!(
510 (wave.format, wave.channels, wave.rate, wave.bits),
511 (0x11, 1, RATE, 4)
512 );
513 assert_eq!(wave.data.len() % BLOCK, 0);
514 assert_eq!(wave.duration_ms(), 2000);
515 assert!(bytes.len() * 3 < samples.len() * 2);
516 let decoded = decode(&wave);
517 assert_eq!(decoded.len(), samples.len());
518 let noise: f64 = samples
519 .iter()
520 .zip(&decoded)
521 .map(|(a, b)| (f64::from(*a) - f64::from(*b)).powi(2))
522 .sum();
523 let signal: f64 = samples.iter().map(|a| f64::from(*a).powi(2)).sum();
524 let snr = 10.0 * (signal / noise).log10();
525 assert!(snr > 20.0, "{snr} dB");
526 }
527
528 #[test]
529 fn only_mono_16_bit_pcm_compresses_and_attached_wav_files_know_their_length() {
530 let stereo = {
531 let mut bytes = pcm(RATE, &[0; 64]);
532 bytes[22] = 2;
533 bytes
534 };
535 assert!(compress(&stereo).is_none());
536 assert!(compress(b"not a wave").is_none());
537 let silence = include_bytes!(
538 "../../../corpus/m6/native-features-01/notebook/fixture-data/silence.wav"
539 );
540 // OneNote 2010 stored 1000 ms for this file when attached (`corpus/recording`).
541 let recording = attached("silence.WAV", silence).unwrap();
542 assert_eq!((recording.kind, recording.duration_ms), (1, Some(1000)));
543 assert_eq!(attached("clip.wmv", b"").unwrap().kind, 2);
544 assert!(attached("notes.txt", b"").is_none());
545 }
546
547 /// A recording made through the editor, stored as the host stores it:
548 /// `SNOWBOUND_RECORDING_EXPORT` names a directory receiving the notebook for a cold
549 /// read in OneNote 2010 (`corpus/recording/candidate`).
550 #[test]
551 fn a_recording_stores_its_file_line_and_linked_notes_for_onenote() {
552 use canvas::{editor::CanvasEditor, layout::TextEngine};
553 use onestore::{
554 Arena, Section, Store,
555 op::{Edit, Op},
556 page::{MediaIndex, PageObject, ParagraphContent},
557 };
558 let source =
559 onestore::create_section("Recorded.one", "Before recording", "Snowbound").unwrap();
560 let arena = Arena::default();
561 let mut section = Section::open(&arena, source.clone()).unwrap();
562 let (space, ..) = section.pages().unwrap()[0].clone();
563 let mut engine = TextEngine::default();
564 let mut editor =
565 CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap();
566 let body = editor
567 .outlines()
568 .iter()
569 .find(|outline| !outline.title)
570 .unwrap()
571 .id;
572 editor.focus_outline(body).unwrap();
573 editor
574 .move_selection(&mut engine, draw::edit::Movement::DocumentEnd, false)
575 .unwrap();
576 let mut at = 134_000_000_000_000_000;
577 let mut store = |editor: &mut CanvasEditor| {
578 at += 10_000_000;
579 let ops = editor.take_ops().unwrap();
580 let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect();
581 section.apply("Snowbound", &Edit { at, ops }).unwrap();
582 };
583 let id = editor
584 .start_recording(
585 &mut engine,
586 "Audio recording started: 5:18 PM Tuesday, September 29, 2026",
587 )
588 .unwrap();
589 store(&mut editor);
590 std::thread::sleep(Duration::from_millis(40));
591 editor.insert(&mut engine, "First linked note").unwrap();
592 store(&mut editor);
593 editor.insert(&mut engine, "\n").unwrap();
594 std::thread::sleep(Duration::from_millis(40));
595 editor.insert(&mut engine, "Second linked note").unwrap();
596 store(&mut editor);
597 let (bytes, duration) = compress(&pcm(RATE, &tone())).unwrap();
598 let file = Attachment {
599 id: onestore::page::text::new_id().unwrap(),
600 filename: "Recorded.wav".into(),
601 source_path: None,
602 size: Some(crate::attachment::ICON_SIZE),
603 layout: Default::default(),
604 bytes: Some(bytes.clone().into()),
605 preview: Some(canvas::gpu::page::file_icon().into()),
606 recording: Some(Recording {
607 id,
608 kind: 1,
609 duration_ms: Some(duration),
610 }),
611 };
612 editor.finish_recording(&mut engine, file).unwrap();
613 store(&mut editor);
614 let mut image = source;
615 section.seal().unwrap().unwrap().apply(&mut image).unwrap();
616
617 let arena = Arena::default();
618 let page = Section::open(&arena, image.clone())
619 .unwrap()
620 .page(space)
621 .unwrap();
622 let outline = page
623 .objects
624 .iter()
625 .find_map(|object| match object {
626 PageObject::Outline(outline) if !outline.title => Some(outline),
627 _ => None,
628 })
629 .unwrap();
630 let shown: Vec<(String, Option<u32>)> = outline
631 .paragraphs
632 .iter()
633 .map(|paragraph| {
634 let text = match &paragraph.content {
635 ParagraphContent::Text(text) => text.text.text().to_owned(),
636 ParagraphContent::Attachment(file) => {
637 assert_eq!(file.bytes.as_deref(), Some(bytes.as_slice()));
638 format!("[{}]", file.filename)
639 }
640 _ => unreachable!(),
641 };
642 if paragraph.media != MediaIndex::default() {
643 assert_eq!(paragraph.media.recordings, [id]);
644 }
645 (text, paragraph.media.time_ms)
646 })
647 .collect();
648 let texts: Vec<&str> = shown.iter().map(|(text, _)| text.as_str()).collect();
649 assert_eq!(
650 texts,
651 [
652 "Before recording",
653 "[Recorded.wav]",
654 "",
655 "Audio recording started: 5:18 PM Tuesday, September 29, 2026",
656 "First linked note",
657 "Second linked note",
658 ]
659 );
660 assert_eq!(shown[1].1, Some(0));
661 assert_eq!(shown[3].1, Some(0));
662 assert!(shown[4].1.unwrap() >= 40 && shown[5].1.unwrap() > shown[4].1.unwrap());
663
664 if let Some(directory) = std::env::var_os("SNOWBOUND_RECORDING_EXPORT") {
665 let directory = PathBuf::from(directory);
666 std::fs::create_dir_all(&directory).unwrap();
667 std::fs::write(directory.join("Recorded.one"), &image).unwrap();
668 let file_id = Store::parse(&image).unwrap().header.file_id;
669 std::fs::write(
670 directory.join("Open Notebook.onetoc2"),
671 onestore::create_table_of_contents(
672 "Open Notebook.onetoc2",
673 &[("Recorded.one", file_id)],
674 )
675 .unwrap(),
676 )
677 .unwrap();
678 }
679 }
680
681 #[test]
682 fn the_clock_shows_minutes_and_seconds() {
683 assert_eq!(clock(8_400), "0:08");
684 assert_eq!(clock(754_000), "12:34");
685 assert_eq!(clock(3_723_000), "1:02:03");
686 }
687}
crates/snowbound/src/search.rs+39-15
......@@ -82,11 +82,14 @@ pub fn takes_text(focus: Option<Id>) -> bool {
8282
8383/// Work for the index thread.
8484enum Job {
85 /// Brings the index to these notebooks: sections whose files changed are read again and
86 /// those no longer listed dropped. The open section, by key, is read through its replica.
85 /// Brings the index to these notebooks: sections not read yet are read, and those no
86 /// longer listed dropped. Of those read before, a notebook's background sync names the
87 /// changed ones, by key, in `changed`; a section on its own is read again when its file
88 /// changed. The open section, by key, is read through its replica.
8789 Notebooks {
8890 libraries: Vec<Arc<Library>>,
8991 open: Option<(String, Weak<Replica>)>,
92 changed: Vec<String>,
9093 },
9194 /// Reads pages of the open section again, with its page list.
9295 Pages {
......@@ -217,10 +220,18 @@ fn run(
217220 std::thread::sleep(SETTLE);
218221 }
219222 let mut notebooks = None;
223 let mut changed = HashSet::new();
220224 let mut pages: HashMap<String, (Weak<Replica>, HashSet<ExGuid>)> = HashMap::new();
221225 for job in std::iter::once(first).chain(jobs.try_iter()) {
222226 match job {
223 Job::Notebooks { libraries, open } => notebooks = Some((libraries, open)),
227 Job::Notebooks {
228 libraries,
229 open,
230 changed: keys,
231 } => {
232 notebooks = Some((libraries, open));
233 changed.extend(keys);
234 }
224235 Job::Pages {
225236 key,
226237 replica,
......@@ -234,7 +245,14 @@ fn run(
234245 }
235246 let start = Instant::now();
236247 if let Some((libraries, open)) = notebooks {
237 sync(&index, &version, &mut stamps, &libraries, open.as_ref());
248 sync(
249 &index,
250 &version,
251 &mut stamps,
252 &libraries,
253 open.as_ref(),
254 &changed,
255 );
238256 lap("index notebooks", start);
239257 }
240258 for (key, (replica, spaces)) in pages {
......@@ -288,6 +306,7 @@ fn sync(
288306 stamps: &mut HashMap<String, Stamp>,
289307 libraries: &[Arc<Library>],
290308 open: Option<&(String, Weak<Replica>)>,
309 changed: &HashSet<String>,
291310) {
292311 let mut listed = HashSet::new();
293312 for library in libraries {
......@@ -299,12 +318,16 @@ fn sync(
299318 Some(_) => Path::new(&library.location).join(&path),
300319 None => PathBuf::from(&library.location),
301320 };
302 let stamp = stamp(&file);
303 // A file with no stamp, as on a share not mounted, is read once.
304 if stamps
305 .get(&key)
306 .is_some_and(|known| known == &stamp || stamp.is_none())
307 {
321 // A notebook's background sync names its changed sections, so none is checked here.
322 let reported = library.background.is_some();
323 let stamp = if reported { None } else { stamp(&file) };
324 let fresh = match stamps.get(&key) {
325 None => false,
326 Some(_) if reported => !changed.contains(&key),
327 // A file with no stamp, as on a share not mounted, is read once.
328 Some(known) => known == &stamp || stamp.is_none(),
329 };
330 if fresh {
308331 continue;
309332 }
310333 let replica = open
......@@ -458,9 +481,9 @@ fn place(notebooks: &[Arc<Library>], key: &str) -> String {
458481}
459482
460483impl State {
461 /// Brings the index to the open notebooks and section when they changed, and to
462 /// section files changed since, when `always`.
463 pub(crate) fn sync_index(&mut self, always: bool) {
484 /// Brings the index to the open notebooks and section when they changed, and when
485 /// `always` or sections `changed` names, by key, to the section files changed since.
486 pub(crate) fn sync_index(&mut self, always: bool, changed: Vec<String>) {
464487 let open = self.session.as_ref().map(|session| {
465488 (
466489 session.library.key(&session.tabs[session.tab].path),
......@@ -473,13 +496,14 @@ impl State {
473496 .map(|library| Arc::as_ptr(library) as usize)
474497 .chain(open.as_ref().map(|(_, replica)| replica.as_ptr() as usize))
475498 .collect();
476 if !always && identity == self.search.synced {
499 if !always && changed.is_empty() && identity == self.search.synced {
477500 return;
478501 }
479502 self.search.synced = identity;
480503 let _ = self.search.jobs.send(Job::Notebooks {
481504 libraries: self.notebooks.clone(),
482505 open,
506 changed,
483507 });
484508 }
485509
......@@ -596,7 +620,7 @@ impl State {
596620 pub(crate) fn start_search(&mut self) {
597621 self.search.finding = false;
598622 if !self.ui.popup_open(results()) {
599 self.sync_index(true);
623 self.sync_index(true, Vec::new());
600624 self.ui.open_popup(results());
601625 self.search.open = true;
602626 }
crates/snowbound/src/settings.rs+4
......@@ -39,6 +39,8 @@ pub struct Toolbar {
3939 /// Places in OneNote's bullet and numbering libraries picked lately, latest first.
4040 pub bullets: Vec<usize>,
4141 pub numbering: Vec<usize>,
42 /// The place in the pen gallery Pen draws with: the accent pen first.
43 pub pen: usize,
4244}
4345
4446impl Default for Toolbar {
......@@ -49,6 +51,7 @@ impl Default for Toolbar {
4951 font_color: Some(0x0000ff),
5052 bullets: Vec::new(),
5153 numbering: Vec::new(),
54 pen: 0,
5255 }
5356 }
5457}
......@@ -175,6 +178,7 @@ mod tests {
175178 font_color: Some(0x00ff00),
176179 bullets: vec![3, 0],
177180 numbering: vec![16],
181 pen: 6,
178182 },
179183 user_name: Some("Snowbound Test".into()),
180184 color_scheme: ColorScheme::Dark,
crates/snowbound/src/watch.rs+86-35
......@@ -1,8 +1,8 @@
1//! Changes below a notebook folder on this computer, as the system reports them: FSEvents on
2//! macOS, inotify on Linux. A notebook on a share learns of its changes from the server
3//! instead (`notebook::session::Background::smb`).
1//! Changes below a notebook folder, as the system reports them: FSEvents on macOS, inotify on
2//! Linux. A notebook on a share learns of its changes from the server instead
3//! (`notebook::session::Background::smb`).
44
5use std::path::Path;
5use std::path::{Path, PathBuf};
66
77/// Reports until dropped.
88pub struct Watch {
......@@ -10,12 +10,33 @@ pub struct Watch {
1010}
1111
1212/// Watches the folder at `root`: `changed` hears the paths that changed below it, relative to
13/// it and `/`-separated, or `""` when the system can only say something did. `None` where the
14/// system cannot watch it.
13/// it and `/`-separated, or a folder's path when the system can only say something in it did.
14/// `None` where the system does not report every change: on a network volume other than a
15/// Mac's SMB mount, another computer's changes go unreported.
1516pub fn watch(root: &Path, changed: impl Fn(Vec<String>) + Send + Sync + 'static) -> Option<Watch> {
1617 let root = root.canonicalize().ok()?;
18 let volume = statfs(&root)?;
19 let folders = if local(&volume) {
20 vec![root.clone()]
21 } else if smbfs(&volume) {
22 // smbfs reports another client's change only in a folder watched for itself, and
23 // names just that folder.
24 let mut folders = vec![root.clone()];
25 let mut at = 0;
26 while let Some(folder) = folders.get(at).cloned() {
27 at += 1;
28 for entry in std::fs::read_dir(folder).into_iter().flatten().flatten() {
29 if entry.file_type().is_ok_and(|kind| kind.is_dir()) {
30 folders.push(entry.path());
31 }
32 }
33 }
34 folders
35 } else {
36 return None;
37 };
1738 let folder = root.clone();
18 let relative = move |paths: Vec<std::path::PathBuf>| {
39 let relative = move |paths: Vec<PathBuf>| {
1940 changed(
2041 paths
2142 .iter()
......@@ -26,28 +47,34 @@ pub fn watch(root: &Path, changed: impl Fn(Vec<String>) + Send + Sync + 'static)
2647 .collect(),
2748 )
2849 };
29 platform::Stream::start(&root, Box::new(relative))
50 platform::Stream::start(&folders, Box::new(relative))
3051 .inspect_err(|error| eprintln!("{}: changes go unwatched: {error}", root.display()))
3152 .ok()
3253 .map(|stream| Watch { _stream: stream })
3354}
3455
35/// Whether the folder at `path` is on this computer's own disks, where every change to it is
36/// one the system reports, rather than on a network volume.
56/// Whether the folder at `path` is on this computer's own disks rather than on a network
57/// volume.
3758pub fn on_this_computer(path: &Path) -> bool {
59 statfs(path).is_none_or(|volume| local(&volume))
60}
61
62fn statfs(path: &Path) -> Option<libc::statfs> {
3863 use std::os::unix::ffi::OsStrExt;
39 let Ok(name) = std::ffi::CString::new(path.as_os_str().as_bytes()) else {
40 return true;
41 };
64 let name = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
4265 let mut volume: libc::statfs = unsafe { std::mem::zeroed() };
43 if unsafe { libc::statfs(name.as_ptr(), &mut volume) } != 0 {
44 return true;
45 }
46 #[cfg(target_os = "macos")]
47 return volume.f_flags & libc::MNT_LOCAL as u32 != 0;
48 // NFS, SMB, CIFS, SMB2, FUSE (sshfs and the like), 9P, Ceph, AFS.
49 #[cfg(target_os = "linux")]
50 return ![
66 (unsafe { libc::statfs(name.as_ptr(), &mut volume) } == 0).then_some(volume)
67}
68
69#[cfg(target_os = "macos")]
70fn local(volume: &libc::statfs) -> bool {
71 volume.f_flags & libc::MNT_LOCAL as u32 != 0
72}
73
74// NFS, SMB, CIFS, SMB2, FUSE (sshfs and the like), 9P, Ceph, AFS.
75#[cfg(target_os = "linux")]
76fn local(volume: &libc::statfs) -> bool {
77 ![
5178 0x6969,
5279 0x517b,
5380 0xff53_4d42,
......@@ -57,7 +84,19 @@ pub fn on_this_computer(path: &Path) -> bool {
5784 0x00c3_6400,
5885 0x5346_414f,
5986 ]
60 .contains(&(volume.f_type as u64));
87 .contains(&(volume.f_type as u64))
88}
89
90#[cfg(target_os = "macos")]
91fn smbfs(volume: &libc::statfs) -> bool {
92 let name = unsafe { std::ffi::CStr::from_ptr(volume.f_fstypename.as_ptr()) };
93 name.to_bytes() == b"smbfs"
94}
95
96/// A Linux SMB mount reports only this computer's own changes.
97#[cfg(target_os = "linux")]
98fn smbfs(_: &libc::statfs) -> bool {
99 false
61100}
62101
63102type Changed = Box<dyn Fn(Vec<std::path::PathBuf>) + Send + Sync>;
......@@ -69,7 +108,7 @@ mod platform {
69108 ffi::{CStr, c_char, c_void},
70109 io,
71110 os::unix::ffi::OsStrExt,
72 path::{Path, PathBuf},
111 path::PathBuf,
73112 };
74113
75114 type Stream_ = *mut c_void;
......@@ -188,19 +227,31 @@ mod platform {
188227 }
189228
190229 impl Stream {
191 pub fn start(root: &Path, changed: Changed) -> io::Result<Self> {
192 let root = root.as_os_str().as_bytes();
230 pub fn start(folders: &[PathBuf], changed: Changed) -> io::Result<Self> {
193231 let flags = NO_DEFER | WATCH_ROOT | if file_events() { FILE_EVENTS } else { 0 };
194232 unsafe {
195 let path = CFStringCreateWithBytes(
233 let folders: Vec<_> = folders
234 .iter()
235 .map(|folder| {
236 let folder = folder.as_os_str().as_bytes();
237 CFStringCreateWithBytes(
238 std::ptr::null(),
239 folder.as_ptr(),
240 folder.len() as isize,
241 UTF8,
242 0,
243 )
244 })
245 .collect();
246 let paths = CFArrayCreate(
196247 std::ptr::null(),
197 root.as_ptr(),
198 root.len() as isize,
199 UTF8,
200 0,
248 folders.as_ptr(),
249 folders.len() as isize,
250 &kCFTypeArrayCallBacks,
201251 );
202 let paths = CFArrayCreate(std::ptr::null(), &path, 1, &kCFTypeArrayCallBacks);
203 CFRelease(path);
252 for folder in folders {
253 CFRelease(folder);
254 }
204255 let context = Context {
205256 version: 0,
206257 info: Box::into_raw(Box::new(changed)).cast(),
......@@ -299,7 +350,7 @@ mod platform {
299350 }
300351
301352 impl Stream {
302 pub fn start(root: &Path, changed: Changed) -> io::Result<Self> {
353 pub fn start(folders: &[PathBuf], changed: Changed) -> io::Result<Self> {
303354 let inotify = check(unsafe { libc::inotify_init1(libc::IN_CLOEXEC) })?;
304355 let stop = match check(unsafe { libc::eventfd(0, libc::EFD_CLOEXEC) }) {
305356 Ok(stop) => stop,
......@@ -308,9 +359,9 @@ mod platform {
308359 return Err(error);
309360 }
310361 };
362 let root = folders[0].clone();
311363 let mut folders = HashMap::new();
312 add(inotify, root, &mut folders);
313 let root = root.to_owned();
364 add(inotify, &root, &mut folders);
314365 let thread = std::thread::Builder::new()
315366 .name("snowbound-watch".into())
316367 .spawn(move || {
tools/canvas/build_macos.py+1
......@@ -64,6 +64,7 @@ else:
6464 'NSHighResolutionCapable': True,
6565 'NSPrincipalClass': 'NSApplication',
6666 'CFBundleIconFile': 'Snowbound',
67 'NSMicrophoneUsageDescription': 'Snowbound records audio into your notes when you choose Record Audio.',
6768} | ({'LSMinimumSystemVersion': '10.6'} if args.snow_leopard else {})))
6869# 10.6 runs the bundle unsigned.
6970if not args.snow_leopard:
tools/test_ink_tools.py created+82
......@@ -0,0 +1,82 @@
1from pathlib import Path
2import runpy
3import shutil
4from tempfile import TemporaryDirectory
5import unittest
6import xml.etree.ElementTree as ET
7
8from native_xml import ns
9
10ROOT = Path(__file__).resolve().parent.parent
11FIXTURE = ROOT / 'corpus/ink-tools'
12compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
13
14
15def cold_pages(variant):
16 """The cold read of a Rust candidate, after checking it holds what the candidate stored."""
17 with TemporaryDirectory() as temporary:
18 read = Path(temporary) / 'read'
19 shutil.copytree(FIXTURE / variant / 'cold/read', read)
20 compare(FIXTURE / variant / 'candidate', read)
21 return pages(FIXTURE / variant / 'cold/read')
22
23
24def pages(read):
25 return {page.get('name'): page for page in (ET.parse(path).getroot() for path in sorted(read.glob('page-*.xml')))}
26
27
28def drawings(page):
29 return page.findall('one:InkDrawing', ns)
30
31
32def place(drawing):
33 position, size = drawing.find('one:Position', ns), drawing.find('one:Size', ns)
34 return [float(position.get('x')), float(position.get('y')), float(size.get('width')), float(size.get('height'))]
35
36
37class InkToolsTest(unittest.TestCase):
38 def assertPlaced(self, drawing, expected, delta=0.06):
39 for actual, wanted in zip(place(drawing), expected, strict=True):
40 self.assertAlmostEqual(actual, wanted, delta=delta)
41
42 def test_onenote_keeps_each_stroke_and_shape_a_drawing_of_its_own(self):
43 read = pages(FIXTURE / 'native-ui/read')
44 self.assertEqual(len(drawings(read['Gallery'])), 14)
45 shapes = [d.find('one:ShapeInfo', ns) for d in drawings(read['Pens'])]
46 self.assertEqual(sorted(shape.get('isLine') for shape in shapes if shape is not None), ['false', 'false', 'true', 'true'])
47 self.assertTrue((FIXTURE / 'native-ui/read/page-001.png').exists())
48
49 def test_snowbound_pens_highlighters_and_shapes_open_in_onenote(self):
50 page, = cold_pages('drawn').values()
51 found = drawings(page)
52 self.assertEqual(len(found), 6)
53 shapes = {d.get('objectID'): d.find('one:ShapeInfo', ns) for d in found}
54 lines = [s for s in shapes.values() if s is not None and s.get('isLine') == 'true']
55 closed = [s for s in shapes.values() if s is not None and s.get('isLine') == 'false']
56 self.assertEqual((len(lines), len(closed)), (2, 2))
57 rectangle = next(s for s in closed if len(s.findall('one:AnchorPoint', ns)) == 8)
58 corners = [(float(a.get('x')), float(a.get('y'))) for a in rectangle.findall('one:AnchorPoint', ns)]
59 self.assertAlmostEqual(corners[0][0], 252.0, delta=0.01)
60 self.assertAlmostEqual(corners[4][1], 194.4, delta=0.01)
61 # The highlighter's stroke spans its points, one HIMETRIC unit larger, as OneNote reports ink.
62 highlighter = next(d for d in found if abs(place(d)[3]) < 0.1)
63 self.assertPlaced(highlighter, [60.0, 190.0, 160.0, 0.03])
64 self.assertTrue((FIXTURE / 'drawn/cold/read/page-000.png').exists())
65
66 def test_onenote_drawings_keep_their_ink_after_snowbound_erases_moves_and_draws(self):
67 # Object identities differ between clones' reads, so drawings match by where they lie.
68 before = [place(d) for d in drawings(pages(FIXTURE / 'native-ui/read')['Pens'])]
69 after = [place(d) for d in drawings(cold_pages('edited')['Pens'])]
70 self.assertEqual(len(after), len(before) - 1 + 6)
71 near = lambda a, b: all(abs(x - y) < 0.001 for x, y in zip(a, b))
72 zigzag = next(p for p in before if abs(p[2] - 90.03) < 0.01 and abs(p[3] - 22.51) < 0.01)
73 line = next(p for p in before if p[2] < 0.1 and abs(p[3] - 60.04) < 0.01)
74 self.assertFalse(any(near(p, zigzag) for p in after))
75 self.assertTrue(any(near(p, [line[0] + 36.0, line[1] + 18.0, line[2], line[3]]) for p in after))
76 for kept in before:
77 if kept not in (zigzag, line):
78 self.assertTrue(any(near(p, kept) for p in after), kept)
79
80
81if __name__ == '__main__':
82 unittest.main()
tools/test_recording.py created+88
......@@ -0,0 +1,88 @@
1from pathlib import Path
2import hashlib
3import json
4import runpy
5import shutil
6from tempfile import TemporaryDirectory
7import unittest
8import xml.etree.ElementTree as ET
9
10from native_xml import ns, texts
11
12ROOT = Path(__file__).resolve().parent.parent
13FIXTURE = ROOT / 'corpus/recording'
14compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
15
16
17def recorded(read):
18 """The page holding a recording, its playlist, and each paragraph's text or file with
19 the moment it links to and the recording it holds."""
20 page = next(page for page in (ET.parse(path).getroot() for path in sorted(read.glob('page-*.xml')))
21 if page.find('one:MediaPlaylist', ns) is not None)
22 playlist = [reference.get('mediaID') for reference in
23 page.findall('one:MediaPlaylist/one:MediaReference', ns)]
24 links = []
25 for element in page.findall('.//one:OE', ns):
26 index = element.find('one:MediaIndex', ns)
27 media = element.find('one:MediaFile', ns)
28 text = element.find('one:T', ns)
29 shown = '[%s]' % media.get('preferredName') if media is not None else texts(element)[0] if text is not None else ''
30 link = None
31 if index is not None:
32 link = (index.find('one:MediaReference', ns).get('mediaID'), int(index.get('timeIndex')))
33 links.append((shown, link, media.find('one:MediaReference', ns).get('mediaID') if media is not None else None))
34 return page, playlist, links
35
36
37def payload(read, name):
38 payloads = json.loads((read / 'payloads.json').read_text(encoding='utf-8-sig'))
39 entry, = (entry for entry in payloads if entry['name'] == name)
40 data = (read / (entry['sha256'] + '.attachment')).read_bytes()
41 assert hashlib.sha256(data).hexdigest() == entry['sha256']
42 return entry, data
43
44
45class RecordingTest(unittest.TestCase):
46 def cold(self, row):
47 read = FIXTURE / row / 'cold/read'
48 with TemporaryDirectory() as temporary:
49 copy = Path(temporary) / 'read'
50 shutil.copytree(read, copy)
51 compare((FIXTURE / row / 'candidate').resolve(), copy)
52 return read
53
54 def test_snowbound_s_recording_opens_in_onenote_with_its_line_links_and_audio(self):
55 read = self.cold('.')
56 _, playlist, links = recorded(read)
57 recording, = playlist
58 shown = [text for text, _, _ in links]
59 self.assertEqual(shown, ['Before recording', '[Recorded.wav]', '',
60 'Audio recording started: 5:18 PM Tuesday, September 29, 2026',
61 'First linked note', 'Second linked note'])
62 self.assertEqual(links[1][2], recording)
63 self.assertEqual([link for _, link, _ in links[1:4]], [(recording, 0), None, (recording, 0)])
64 first, second = (link[1] for _, link, _ in links[4:])
65 self.assertTrue(40 <= first < second)
66 _, data = payload(read, 'Recorded.wav')
67 # 4-bit IMA ADPCM (WAVE_FORMAT_DVI_ADPCM), mono 16 kHz, which OneNote plays
68 # (`played-from-note.png`).
69 self.assertEqual(data[:4] + data[8:16], b'RIFFWAVEfmt ')
70 self.assertEqual(int.from_bytes(data[20:22], 'little'), 0x11)
71 self.assertEqual(int.from_bytes(data[24:28], 'little'), 16000)
72
73 def test_onenote_s_recording_opens_as_recorded_after_snowbound_deletes_restores_and_links(self):
74 read = self.cold('edit')
75 _, playlist, links = recorded(read)
76 native = FIXTURE / 'native/read'
77 _, native_playlist, native_links = recorded(native)
78 self.assertEqual(playlist, native_playlist)
79 self.assertEqual(links[:len(native_links)], native_links)
80 recording, = playlist
81 self.assertEqual(links[len(native_links):], [('Linked by Snowbound', (recording, 25000), None)])
82 entry, data = payload(read, 'Recorded.wma')
83 self.assertEqual(entry['kind'], 'MediaFile')
84 self.assertTrue(data.startswith(bytes.fromhex('3026b2758e66cf11')))
85
86
87if __name__ == '__main__':
88 unittest.main()
tools/verify-document.py+3
......@@ -369,6 +369,9 @@ def compare(notebook, native, versions=None, password_file=None):
369369 # Stroke coordinates are absolute; left-to-right pages report them
370370 # relative to the canonical margin origin, right-to-left pages as stored.
371371 extent = ink_extent(space, source)
372 # A moved drawing keeps its strokes and takes an offset.
373 extent[0] += source['layout']['x'] or 0.0
374 extent[1] += source['layout']['y'] or 0.0
372375 if not source_page['kind']['rtl']:
373376 for index, (axis, canonical_origin) in enumerate((('x', 36.0), ('y', 14.4))):
374377 origin = source_page['kind']['margin_origin_' + axis]