authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-12 17:37:36-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-25 20:26:20-07:00
log237d4668ad21d8c6275da60e59bc4acb066fb853
tree2c7c5a5f2c444fb6c0209e6631af6c549df97459
parent7f30bf34648c9ac7a69e74956b04546b9894a380
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: write ink drawings and handwriting through the page model

A page::Ink object in the model is written the way OneNote 2010 stores a mouse drawing: an ink container (jcid 0x60014) the page lists as a child or a paragraph holds as content, its data node (0x2003b) listing stroke objects (0x20047) whose coordinates are ISF multi-byte first differences in HIMETRIC with index, language, identity, time and half-inch origin, and one drawing-attribute object (0x120048) per distinct pen carrying the dimension table OneNote wrote for its own ink. Points are rounded to the HIMETRIC grid (page::ink::snap gives callers the same rounding), so a written stroke reads back equal. Editing rewrites the stroke list: stored strokes keep their paths and pens, removed ones leave the list, new ones are created; removing the object goes through the tree editor, which now accepts ink as a page child. corpus/ink-edit holds cold reads of a Rust square-and-diagonal drawing on the page and inside an outline paragraph, with screenshots of OneNote 2010 rendering both; tools/test_ink_edit.py checks them without a VM. Assisted-by: claude-fable-5.1

42 files changed, 1128 insertions(+), 28 deletions(-)

corpus/ink-edit/README.md created+26
......@@ -0,0 +1,26 @@
1# Rust ink authoring
2
3`drawing/candidate` is the page writer's output for
4`an_ink_drawing_is_written_erased_stroke_by_stroke_and_removed` in
5`crates/onestore/tests/page_ink.rs`: on a section created in Rust, a page-level
6ink object with two strokes, a 60 pt square at (300, 120) and a red diagonal,
7stored the way OneNote 2010 stores a mouse drawing
8(`corpus/native-ink/cold-ui-ink`): an ink container listed as a page child, its
9data node listing stroke objects, each stroke's coordinates as ISF multi-byte
10first differences in HIMETRIC with its index, language, identity, time and
11half-inch origin, and one drawing-attribute object per distinct pen carrying
12the dimension table OneNote wrote. `drawing/cold` is a fresh OneNote 2010
13read: the `InkDrawing` reports the stroke extent as its position and size (one
14HIMETRIC unit larger, as OneNote does) and `page-000.png` shows the square and
15diagonal rendered.
16
17`handwriting/candidate` is the same drawing held by a new paragraph of the body
18outline (`handwriting_is_written_as_paragraph_content`); `handwriting/cold`
19shows OneNote accepting it as an `InkDrawing` inside the outline element, the
20outline grown to contain the strokes' absolute page coordinates.
21
22`tools/test_ink_edit.py` checks both without a VM. Regenerate with
23`ONESTORE_INK_EXPORT` and `ONESTORE_INK_PARAGRAPH_EXPORT` set to new absolute
24directories while running the tests, then cold-open each with
25`tools/native_runner.py OUTPUT COLD --expected-pages 1 --collect-notebook
26--screenshots`.
corpus/ink-edit/drawing/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/ink-edit/drawing/candidate/Open Notebook.onetoc2 differ
corpus/ink-edit/drawing/candidate/ink.one created
Binary files /dev/null and b/corpus/ink-edit/drawing/candidate/ink.one differ
corpus/ink-edit/drawing/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-57607115 -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-edit/drawing/cold/machine.json created+1
......@@ -0,0 +1 @@
1{"name": "m6-57607115", "hostname": "ONE-M6-57607115"}
corpus/ink-edit/drawing/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/ink-edit/drawing/cold/notebook/Open Notebook.onetoc2 differ
corpus/ink-edit/drawing/cold/notebook/ink.one created
Binary files /dev/null and b/corpus/ink-edit/drawing/cold/notebook/ink.one differ
corpus/ink-edit/drawing/cold/read/environment.json created+7
......@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-57607115",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/ink-edit/drawing/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="{2A9CE567-C79A-4ED0-9E76-D70D47305012}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-13T00:29:24.000Z" color="#B49EDE"><one:Section name="ink" ID="{C60623D1-7083-091D-345E-5EE0BB23238A}{1}{B0}" path="C:\one-tests\runs\capture\notebook\ink.one" lastModifiedTime="2026-09-13T00:29:24.000Z" color="#8AA8E4"><one:Page ID="{2C71557B-367D-0DBE-3D60-41D75B6F0782}{1}{B0}" name="Beside the drawing" dateTime="2026-09-13T00:25:49.000Z" lastModifiedTime="2026-09-13T00:29:14.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/ink-edit/drawing/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": "Beside the drawing - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/ink-edit/drawing/cold/read/page-000.png created
Binary files /dev/null and b/corpus/ink-edit/drawing/cold/read/page-000.png differ
corpus/ink-edit/drawing/cold/read/page-000.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="{2C71557B-367D-0DBE-3D60-41D75B6F0782}{1}{B0}" name="Beside the drawing" dateTime="2026-09-13T00:25:49.000Z" lastModifiedTime="2026-09-13T00:29:14.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="{C60623D1-7083-091D-345E-5EE0BB23238A}{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="{C60623D1-7083-091D-345E-5EE0BB23238A}{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="{302C58F5-3C27-0AD7-1FCC-592C5BAF008A}{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-edit/drawing/cold/read/payloads.json created+3
......@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/ink-edit/drawing/cold/run.json created+18
......@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/m4/ink",
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": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
17 }
18}
corpus/ink-edit/drawing/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-edit/drawing/cold/scripts/read.ps1 created+142
......@@ -0,0 +1,142 @@
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 $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
103 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
104 sha256 = $hash; bytes = $bytes.Length }
105 }
106 $index++
107 }
108 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
109 $all = ''
110 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
111 [xml]$finalTree = $all
112 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
113 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
114 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
115 }
116 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
117 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
118} catch {
119 $failure = $_
120 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
121 throw
122} finally {
123 try {
124 try {
125 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
126 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
127 $app.CloseNotebook($notebookId, $false)
128 }
129 } catch {
130 if ($null -eq $failure) { throw }
131 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
132 }
133 } finally {
134 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
135 }
136 $app = $null
137 [GC]::Collect()
138 [GC]::WaitForPendingFinalizers()
139 if (-not $UseCurrentCache -and -not $CloneHost) {
140 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
141 }
142}
corpus/ink-edit/drawing/cold/source.json created+14
......@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1596,
5 "sha256": "0c5c0a1f79fddde5a4dca355b718618a7ad7fc6f24e1a41900f08083adf2e657",
6 "mtime_ns": 1789259149540453267
7 },
8 {
9 "path": "ink.one",
10 "bytes": 6664,
11 "sha256": "9b662a8f4beb2bad0d547c251101887c245b8ac49a80a00efe49c70ab60c1236",
12 "mtime_ns": 1789259149539962603
13 }
14]
corpus/ink-edit/drawing/cold/teardown.json created+1
......@@ -0,0 +1 @@
1{"absent": true}
corpus/ink-edit/handwriting/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/ink-edit/handwriting/candidate/Open Notebook.onetoc2 differ
corpus/ink-edit/handwriting/candidate/ink.one created
Binary files /dev/null and b/corpus/ink-edit/handwriting/candidate/ink.one differ
corpus/ink-edit/handwriting/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-9E674848 -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-edit/handwriting/cold/machine.json created+1
......@@ -0,0 +1 @@
1{"name": "m6-9e674848", "hostname": "ONE-M6-9E674848"}
corpus/ink-edit/handwriting/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/ink-edit/handwriting/cold/notebook/Open Notebook.onetoc2 differ
corpus/ink-edit/handwriting/cold/notebook/ink.one created
Binary files /dev/null and b/corpus/ink-edit/handwriting/cold/notebook/ink.one differ
corpus/ink-edit/handwriting/cold/read/environment.json created+7
......@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-9E674848",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/ink-edit/handwriting/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="{3695DCD4-CE30-415C-9318-2A2F825DA91B}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-13T00:29:38.000Z" color="#B49EDE"><one:Section name="ink" ID="{EE035093-7323-0061-2297-A5043D1412BA}{1}{B0}" path="C:\one-tests\runs\capture\notebook\ink.one" lastModifiedTime="2026-09-13T00:29:38.000Z" color="#8AA8E4"><one:Page ID="{345AB2E6-81B1-001A-0178-91B6A9983BAA}{1}{B0}" name="Above the handwriting" dateTime="2026-09-13T00:25:49.000Z" lastModifiedTime="2026-09-13T00:29:38.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/ink-edit/handwriting/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": "Above the handwriting - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/ink-edit/handwriting/cold/read/page-000.png created
Binary files /dev/null and b/corpus/ink-edit/handwriting/cold/read/page-000.png differ
corpus/ink-edit/handwriting/cold/read/page-000.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="{345AB2E6-81B1-001A-0178-91B6A9983BAA}{1}{B0}" name="Above the handwriting" dateTime="2026-09-13T00:25:49.000Z" lastModifiedTime="2026-09-13T00:29:38.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-13T00:25:49.000Z" objectID="{EE035093-7323-0061-2297-A5043D1412BA}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="387.0283508300781" height="193.4560546875"/><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="{EE035093-7323-0061-2297-A5043D1412BA}{24}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:T><![CDATA[Above the handwriting]]></one:T></one:OE><one:OE author="Ink author" lastModifiedBy="Ink author" creationTime="2026-09-13T00:25:49.000Z" lastModifiedTime="2026-09-13T00:25:49.000Z" objectID="{5F2C3D0C-0C6D-09FB-1C0A-C55A5F3D4392}{0}{B0}" alignment="left"><one:InkDrawing inkOriginX="27.0" inkOriginY="13.4277172088623"><one:Data>AM8CHQSiAaIBAWAqABofi0JwRYR1gL7QRdkE+oeVMvLsF0G4wBa/8yv0et67E4vlXrNInJuhJVq3
3URK/7HHMzCKxQYkOuxMs7nqhWM9UiuaXxU+PBvi60uGbIsRmbacNslhJmh1OQDQGbBACEQZIEEUj
4RiMJSBBE/wFFI0YjBQgLZU9mZ2hkaRkUMggAgB4CIeLiQTMIAPAQAovF4kFkAxVGAEAKXAWH8gln
5jCj7wnvCe8IAh/B8h4Pke8I7wnvCQAUURgBAAAAJBAUURgAAAAASABEgoX0Rk76oCkqcf4WNBm3y
6SgoAESCA5B1tFkPdAQMVRgBACj9AIm9GCC70WAqACQEKTAIPUq7GcA5CJYzgBRRGAIAAAAkEBRRG
7AAAAABIAESCehGefqfnSRo1JDkRPsA5FCgARIIDkHW0WQ90BAxVGAEAKP0Aib0YILvRYCoA=</one:Data></one:InkDrawing></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/ink-edit/handwriting/cold/read/payloads.json created+3
......@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/ink-edit/handwriting/cold/run.json created+18
......@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/m4/ink-para",
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": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
17 }
18}
corpus/ink-edit/handwriting/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-edit/handwriting/cold/scripts/read.ps1 created+142
......@@ -0,0 +1,142 @@
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 $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
103 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
104 sha256 = $hash; bytes = $bytes.Length }
105 }
106 $index++
107 }
108 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
109 $all = ''
110 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
111 [xml]$finalTree = $all
112 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
113 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
114 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
115 }
116 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
117 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
118} catch {
119 $failure = $_
120 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
121 throw
122} finally {
123 try {
124 try {
125 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
126 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
127 $app.CloseNotebook($notebookId, $false)
128 }
129 } catch {
130 if ($null -eq $failure) { throw }
131 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
132 }
133 } finally {
134 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
135 }
136 $app = $null
137 [GC]::Collect()
138 [GC]::WaitForPendingFinalizers()
139 if (-not $UseCurrentCache -and -not $CloneHost) {
140 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
141 }
142}
corpus/ink-edit/handwriting/cold/source.json created+14
......@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1596,
5 "sha256": "fc5a7b9e81d5d3e5e4328faa25445795335490f9ef35c0abc047880f90c24699",
6 "mtime_ns": 1789259149540474767
7 },
8 {
9 "path": "ink.one",
10 "bytes": 7240,
11 "sha256": "62ab52907aaef6066817c2598a9e6ca399813f02ab8004971ee1462360eeb555",
12 "mtime_ns": 1789259149540106435
13 }
14]
corpus/ink-edit/handwriting/cold/teardown.json created+1
......@@ -0,0 +1 @@
1{"absent": true}
crates/notebook/README.md+1-1
......@@ -14,7 +14,7 @@ diffs the supplied model against the page stored in the supplied local snapshot,
1414writes the difference into the working image and queues one
1515`Operation::Page(PageIntent { before, after, author })`; `before` is the page the
1616edit started from and is the precondition reconciliation checks. Text, styles,
17paragraph formatting, hyperlinks (external and internal), bullets, numbering, note tags, table rows, columns, cell shading, nested tables, pictures (insertion in paragraphs or on the page, position, size, description) and attachments, paragraph structure,
17paragraph formatting, hyperlinks (external and internal), bullets, numbering, note tags, table rows, columns, cell shading, nested tables, pictures (insertion in paragraphs or on the page, position, size, description), attachments, ink strokes, paragraph structure,
1818outline layout, insertions and deletions are all differences between `before` and
1919`after`; the library never sees editor operations.
2020
crates/onestore/README.md+1-1
......@@ -57,7 +57,7 @@ harness also accepts `--client-profile release`.
5757| `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules |
5858| `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs |
5959| `OutlineEdit`, `PreparedEdit::outline` | Change ordinary outline position/width or a paragraph's saved expansion default, preserving identities and content |
60| `PreparedEdit::page` | Publish an edited `page::Page` as one revision per changed space: text, character and paragraph formatting, hyperlinks (external and, via `page::link`, to pages, paragraphs and sections), bullets and numbering, note tags, table rows, columns, cell shading and indents, nested tables, inserted pictures (in paragraphs or on the page) and attachments, picture position, size and description, paragraph insertion/split/join/move/deletion, outline insertion/deletion/position/width and saved collapse state, lowered onto the typed writers with the model's identities |
60| `PreparedEdit::page` | Publish an edited `page::Page` as one revision per changed space: text, character and paragraph formatting, hyperlinks (external and, via `page::link`, to pages, paragraphs and sections), bullets and numbering, note tags, table rows, columns, cell shading and indents, nested tables, inserted pictures (in paragraphs or on the page) and attachments, picture position, size and description, ink drawings and handwriting (strokes added and erased), paragraph insertion/split/join/move/deletion, outline insertion/deletion/position/width and saved collapse state, lowered onto the typed writers with the model's identities |
6161| `TreeEdit`, `PreparedEdit::tree` | Move or delete a subtree on one page, normalize surviving containers, and replace an emptied table cell's paragraph atomically |
6262| `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter |
6363| `read_file` | Read a snapshot under whole-file exclusion |
crates/onestore/src/page/ink.rs+79-4
......@@ -7,7 +7,16 @@ use crate::{
77 document::{Kind, Layout, Revision},
88};
99
10const HIMETRIC_PER_POINT: f32 = 2540.0 / 72.0;
10/// Points from a HIMETRIC coordinate, the one conversion every stroke coordinate goes through.
11fn points(himetric: i64, scale: f32) -> f32 {
12 himetric as f32 * scale * 72.0 / 2540.0
13}
14
15/// Rounds a page point to the HIMETRIC grid OneNote stores, so a stroke written from it reads
16/// back equal.
17pub fn snap(value: f32) -> f32 {
18 points((value * 2540.0 / 72.0).round() as i64, 1.0)
19}
1120const DIMENSION_X: [u8; 16] = [
1221 0x8f, 0x6a, 0x8a, 0x59, 0xc0, 0x52, 0xa0, 0x4b, 0x93, 0xaf, 0xaf, 0x35, 0x74, 0x11, 0xa5, 0x61,
1322];
......@@ -165,7 +174,7 @@ impl InkStroke {
165174 .iter()
166175 .map(move |delta| {
167176 position += delta;
168 position as f32 * factor / HIMETRIC_PER_POINT
177 points(position, factor)
169178 })
170179 };
171180 let points = coordinates(x, scale[0])
......@@ -175,8 +184,8 @@ impl InkStroke {
175184 Ok(Self {
176185 id,
177186 points,
178 width: width.unwrap_or(0.0) / HIMETRIC_PER_POINT,
179 height: height.unwrap_or(0.0) / HIMETRIC_PER_POINT,
187 width: width.unwrap_or(0.0) * 72.0 / 2540.0,
188 height: height.unwrap_or(0.0) * 72.0 / 2540.0,
180189 color: *color,
181190 transparency: *transparency,
182191 pen_tip: *pen_tip,
......@@ -213,3 +222,69 @@ fn multi_byte(bytes: &[u8]) -> Option<Vec<i64>> {
213222 }
214223 (cursor == bytes.len()).then_some(values)
215224}
225
226impl InkStroke {
227 /// The stroke packet OneNote stores: X then Y as ISF multi-byte first differences of the
228 /// points rounded to HIMETRIC.
229 pub(crate) fn packet(&self) -> Vec<u8> {
230 let axis = |index: usize| {
231 let mut previous = 0i64;
232 self.points.iter().map(move |point| {
233 let value = (point[index] * 2540.0 / 72.0).round() as i64;
234 let delta = value - previous;
235 previous = value;
236 delta
237 })
238 };
239 let values: Vec<i64> = axis(0).chain(axis(1)).collect();
240 let mut out = Vec::new();
241 let mut push = |raw: u64| {
242 let mut raw = raw;
243 loop {
244 let byte = (raw & 0x7f) as u8;
245 raw >>= 7;
246 if raw == 0 {
247 out.push(byte);
248 break;
249 }
250 out.push(byte | 0x80);
251 }
252 };
253 push((values.len() as u64) << 1);
254 for value in values {
255 push(((value.unsigned_abs()) << 1) | u64::from(value < 0));
256 }
257 out
258 }
259}
260
261/// The dimension table OneNote 2010 writes for mouse ink: X and Y in HIMETRIC with the limits
262/// and resolution of the authoring screen.
263pub(crate) const DIMENSIONS: [u8; 64] = [
264 0x8f, 0x6a, 0x8a, 0x59, 0xc0, 0x52, 0xa0, 0x4b, 0x93, 0xaf, 0xaf, 0x35, 0x74, 0x11, 0xa5, 0x61,
265 0x00, 0x00, 0x00, 0x00, 0x80, 0x07, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x21, 0xe2, 0xe2, 0x41,
266 0x75, 0x9f, 0x3f, 0xb5, 0xe0, 0x04, 0x98, 0x44, 0xa7, 0xee, 0xc3, 0x0d, 0xbb, 0x5a, 0x90, 0x11,
267 0x00, 0x00, 0x00, 0x00, 0x38, 0x04, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x8b, 0xc5, 0xe2, 0x41,
268];
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 #[test]
275 fn packets_round_trip_through_the_multi_byte_coding() {
276 let stroke = InkStroke {
277 id: ExGuid::default(),
278 points: vec![[100.0, 50.0], [100.5, 49.0], [99.0, 49.0]],
279 width: 1.0,
280 height: 1.0,
281 color: None,
282 transparency: None,
283 pen_tip: None,
284 };
285 let values = multi_byte(&stroke.packet()).unwrap();
286 assert_eq!(values.len(), 6);
287 assert_eq!(&values[..3], &[3528, 17, -52]);
288 assert_eq!(&values[3..], &[1764, -35, 0]);
289 }
290}
crates/onestore/src/page/write.rs+343-19
......@@ -1,7 +1,7 @@
11//! Publishes an edited page model by lowering the difference from the stored page onto
22//! the typed writers, then squashing their transactions into one revision per space.
33
4use super::{Image, Outline, Page, PageObject, PageParagraph, ParagraphContent, Table};
4use super::{Image, Ink, Outline, Page, PageObject, PageParagraph, ParagraphContent, Table};
55use crate::{
66 Error, ExGuid, Insertion, ObjectData, OutlineEdit, ParagraphJoin, ParagraphSplit, PropertySets,
77 RevisionIndex, Store, TextAttribute, TreeEdit, Value,
......@@ -35,6 +35,155 @@ fn page_images(page: &Page) -> impl Iterator<Item = (ExGuid, &Image)> {
3535 })
3636}
3737
38fn page_ink(page: &Page) -> impl Iterator<Item = (ExGuid, &Ink)> {
39 page.objects.iter().filter_map(|object| match object {
40 PageObject::Ink(ink) => Some((ink.id, ink)),
41 _ => None,
42 })
43}
44
45/// The compact child references a page object holds.
46fn page_children(object: &PropertyObject) -> Result<Vec<u8>, Error> {
47 let properties = crate::PropertySets::parse(&object.bytes)?;
48 match properties.sets[0]
49 .iter()
50 .find(|p| p.id == 0x24001c20)
51 .map(|p| &p.value)
52 {
53 Some(crate::Value::References { compact_ids, .. }) => Ok(compact_ids.to_vec()),
54 None => Ok(Vec::new()),
55 _ => Err(invalid("The page has an invalid child list")),
56 }
57}
58
59/// Creates stroke objects (numbered from `first`) and one drawing-attribute object per
60/// distinct pen; returns the compact references for the data node's stroke list.
61fn write_strokes(
62 changed: &mut BTreeMap<ExGuid, PropertyObject>,
63 data_object: &mut PropertyObject,
64 strokes: &[(ExGuid, &super::InkStroke)],
65 first: usize,
66 filetime: u64,
67) -> Result<Vec<u8>, Error> {
68 let mut styles: Vec<(InkPen, ExGuid)> = Vec::new();
69 let mut references = Vec::new();
70 for (offset, (stroke_id, stroke)) in strokes.iter().enumerate() {
71 let pen = InkPen::of(stroke);
72 let style = match styles.iter().find(|(known, _)| *known == pen) {
73 Some((_, id)) => *id,
74 None => {
75 let id = ExGuid {
76 guid: crate::write::fresh_guid()?,
77 n: 1,
78 };
79 let mut style = PropertyObject {
80 jcid: 0x120048,
81 bytes: crate::create::properties(&pen.values())?,
82 global_ids: std::sync::Arc::new(BTreeMap::from([(0, id.guid)])),
83 };
84 style.reference(id)?;
85 changed.insert(id, style);
86 styles.push((pen, id));
87 id
88 }
89 };
90 let mut object = PropertyObject {
91 jcid: 0x20047,
92 bytes: crate::create::properties(&stroke_values(
93 stroke,
94 (first + offset) as u32 + 1,
95 filetime,
96 )?)?,
97 global_ids: std::sync::Arc::new(BTreeMap::from([(0, stroke_id.guid)])),
98 };
99 object.reference(*stroke_id)?;
100 let style_reference = object.reference(style)?;
101 object.set(&[(0x20003409, &style_reference)])?;
102 changed.insert(*stroke_id, object);
103 references.extend(data_object.reference(*stroke_id)?);
104 }
105 Ok(references)
106}
107
108/// The drawing attributes OneNote shares between strokes drawn with the same pen.
109#[derive(PartialEq)]
110struct InkPen {
111 width: u32,
112 height: u32,
113 color: Option<u32>,
114 transparency: Option<u8>,
115 pen_tip: Option<u8>,
116}
117
118impl InkPen {
119 fn of(stroke: &super::InkStroke) -> Self {
120 Self {
121 width: (stroke.width * 2540.0 / 72.0).to_bits(),
122 height: (stroke.height * 2540.0 / 72.0).to_bits(),
123 color: stroke.color,
124 transparency: stroke.transparency,
125 pen_tip: stroke.pen_tip,
126 }
127 }
128
129 fn values(&self) -> Values {
130 let mut values: Values = vec![
131 (0x1c00340a, super::ink::DIMENSIONS.to_vec()),
132 (0x1400340c, self.height.to_le_bytes().to_vec()),
133 (0x1400340d, self.width.to_le_bytes().to_vec()),
134 ];
135 if let Some(color) = self.color {
136 values.push((0x1400340f, color.to_le_bytes().to_vec()));
137 }
138 if let Some(transparency) = self.transparency {
139 values.push((0x0c003414, vec![transparency]));
140 }
141 if let Some(tip) = self.pen_tip {
142 values.push((0x0c003412, vec![tip]));
143 }
144 values
145 }
146}
147
148fn stroke_values(stroke: &super::InkStroke, index: u32, filetime: u64) -> Result<Values, Error> {
149 if stroke.points.is_empty() {
150 return Err(invalid("A stroke needs at least one point"));
151 }
152 if stroke
153 .points
154 .iter()
155 .any(|p| !p[0].is_finite() || !p[1].is_finite())
156 || !(stroke.width.is_finite() && stroke.height.is_finite())
157 || stroke.width <= 0.0
158 || stroke.height <= 0.0
159 {
160 return Err(invalid(
161 "Stroke points and pen size must be finite and positive",
162 ));
163 }
164 let left = stroke
165 .points
166 .iter()
167 .map(|p| p[0])
168 .fold(f32::INFINITY, f32::min);
169 let top = stroke
170 .points
171 .iter()
172 .map(|p| p[1])
173 .fold(f32::INFINITY, f32::min);
174 let mut origin = ((left - stroke.width / 2.0) / 36.0).to_le_bytes().to_vec();
175 origin.extend_from_slice(&((top - stroke.height / 2.0) / 36.0).to_le_bytes());
176 Ok(vec![
177 (0x1c00340b, stroke.packet()),
178 (0x14003419, index.to_le_bytes().to_vec()),
179 (0x1000341b, 0x409_u16.to_le_bytes().to_vec()),
180 (0x0c00341c, vec![0]),
181 (0x1c00341a, crate::write::fresh_guid()?.to_vec()),
182 (0x1c00341d, filetime.to_le_bytes().to_vec()),
183 (0x1c00345b, origin),
184 ])
185}
186
38187fn picture_fixed_fields(stored: &Image, image: &Image) -> Result<(), Error> {
39188 if stored.id != image.id
40189 || stored.bytes != image.bytes
......@@ -366,7 +515,6 @@ impl Lowering<'_> {
366515 .iter()
367516 .filter_map(|object| match object {
368517 PageObject::Unsupported(unsupported) => Some(format!("{unsupported:?}")),
369 PageObject::Ink(ink) => Some(format!("{ink:?}")),
370518 PageObject::Title(title) => Some(format!(
371519 "{:?} {:?} {:?} {:?}",
372520 title.id,
......@@ -374,7 +522,7 @@ impl Lowering<'_> {
374522 title.layout,
375523 title.outlines.iter().map(|o| o.id).collect::<Vec<_>>()
376524 )),
377 PageObject::Outline(_) | PageObject::Image(_) => None,
525 PageObject::Outline(_) | PageObject::Image(_) | PageObject::Ink(_) => None,
378526 })
379527 .collect()
380528 };
......@@ -384,7 +532,7 @@ impl Lowering<'_> {
384532 after_fixed.sort();
385533 if before_fixed != after_fixed {
386534 return Err(invalid(
387 "Titles, ink and unsupported objects cannot be edited through the page model",
535 "Titles and unsupported objects cannot be edited through the page model",
388536 ));
389537 }
390538 for (id, outline) in &new.outlines {
......@@ -447,6 +595,11 @@ impl Lowering<'_> {
447595 }
448596 }
449597 (ParagraphContent::Unsupported(a), ParagraphContent::Unsupported(b)) if a == b => {}
598 (ParagraphContent::Ink(a), ParagraphContent::Ink(b)) => {
599 if a.id != b.id {
600 return Err(invalid("Ink identity cannot change"));
601 }
602 }
450603 (ParagraphContent::Image(a), ParagraphContent::Image(b)) => {
451604 picture_fixed_fields(a, b)?;
452605 if (a.layout.x, a.layout.y) != (b.layout.x, b.layout.y) {
......@@ -637,6 +790,7 @@ impl Lowering<'_> {
637790 .any(|object| matches!(object, PageObject::Title(title) if title.id == id))
638791 };
639792 self.edit_page_images(old, new)?;
793 self.edit_page_ink(old, new)?;
640794 let survivors: Vec<ExGuid> = page_order
641795 .iter()
642796 .copied()
......@@ -693,6 +847,7 @@ impl Lowering<'_> {
693847 self.edit_table_structure(old, new)?;
694848 self.edit_cells(old, new)?;
695849 self.edit_images(old, new)?;
850 self.edit_ink_paragraphs(old, new)?;
696851 self.edit_attachments(old, new)?;
697852 self.place_containers(old, new, placed, &deferred)?;
698853 Ok(())
......@@ -860,6 +1015,179 @@ impl Lowering<'_> {
8601015 Ok(())
8611016 }
8621017
1018 /// Ink placed on the page: new drawings are appended for the placement pass to order,
1019 /// changed ones have their stroke list rewritten, removed ones go in the delete pass.
1020 fn edit_page_ink(&mut self, old: &View<'_>, new: &View<'_>) -> Result<(), Error> {
1021 let stored: BTreeMap<ExGuid, &Ink> = page_ink(old.page).collect();
1022 for (id, ink) in page_ink(new.page) {
1023 match stored.get(&id) {
1024 Some(previous) => {
1025 if *previous != ink {
1026 self.edit_ink(previous, ink)?;
1027 }
1028 }
1029 None => self.insert_ink(ink, None)?,
1030 }
1031 }
1032 Ok(())
1033 }
1034
1035 fn edit_ink_paragraphs(&mut self, old: &View<'_>, new: &View<'_>) -> Result<(), Error> {
1036 for (paragraph_id, paragraph) in &new.paragraphs {
1037 let ParagraphContent::Ink(ink) = &paragraph.content else {
1038 continue;
1039 };
1040 match old.paragraphs.get(paragraph_id) {
1041 Some(previous) => {
1042 let ParagraphContent::Ink(stored) = &previous.content else {
1043 return Err(invalid("Paragraph content type cannot change"));
1044 };
1045 if stored != ink {
1046 self.edit_ink(stored, ink)?;
1047 }
1048 }
1049 None => {
1050 let holder = self.id(*paragraph_id);
1051 self.insert_ink(ink, Some(holder))?;
1052 }
1053 }
1054 }
1055 Ok(())
1056 }
1057
1058 /// Writes new ink the way OneNote 2010 stores a drawing: a container the page lists as a
1059 /// child (or a paragraph holds as content), its data node listing stroke objects, each
1060 /// stroke's packet and half-inch origin, and one drawing-attribute object per distinct pen.
1061 fn insert_ink(&mut self, ink: &Ink, holder: Option<ExGuid>) -> Result<(), Error> {
1062 if !ink.groups.is_empty() {
1063 return Err(invalid("New ink holds strokes, not nested groups"));
1064 }
1065 if ink.strokes.is_empty() {
1066 return Err(invalid("New ink needs at least one stroke"));
1067 }
1068 if ink.layout != Default::default() {
1069 return Err(invalid("Ink positions come from its strokes"));
1070 }
1071 let container = self.allocate(ink.id)?;
1072 let data = ExGuid {
1073 guid: crate::write::fresh_guid()?,
1074 n: 1,
1075 };
1076 let mut strokes = Vec::new();
1077 for stroke in &ink.strokes {
1078 strokes.push((self.allocate(stroke.id)?, stroke));
1079 }
1080 let (modified, filetime) = crate::create::current_timestamps()?;
1081 let modified = modified.to_le_bytes();
1082 let (space, page) = (self.space, self.page);
1083 self.apply(|current| {
1084 crate::write::write_revision(current, space, |raw| {
1085 let mut changed = BTreeMap::new();
1086 let mut data_object = PropertyObject {
1087 jcid: 0x2003b,
1088 bytes: crate::create::properties(&[])?,
1089 global_ids: std::sync::Arc::new(BTreeMap::from([(0, data.guid)])),
1090 };
1091 data_object.reference(data)?;
1092 let references =
1093 write_strokes(&mut changed, &mut data_object, &strokes, 0, filetime)?;
1094 data_object.set(&[(0x24003416, &references)])?;
1095 changed.insert(data, data_object);
1096 let mut object = PropertyObject {
1097 jcid: 0x60014,
1098 bytes: crate::create::properties(&[
1099 (0x14001d7a, modified.to_vec()),
1100 (0x14001d4e, 1u32.to_le_bytes().to_vec()),
1101 ])?,
1102 global_ids: std::sync::Arc::new(BTreeMap::from([(0, container.guid)])),
1103 };
1104 object.reference(container)?;
1105 let data_reference = object.reference(data)?;
1106 object.set(&[(0x20003415, &data_reference)])?;
1107 changed.insert(container, object);
1108 match holder {
1109 Some(holder) => {
1110 let mut object = PropertyObject::from_object(&raw.objects[&holder])?;
1111 let content = object.reference(container)?;
1112 object.set(&[(0x24001c1f, &content), (0x14001d7a, &modified)])?;
1113 changed.insert(holder, object);
1114 }
1115 None => {
1116 let mut object = PropertyObject::from_object(&raw.objects[&page])?;
1117 let mut children = page_children(&object)?;
1118 children.extend(object.reference(container)?);
1119 object.set(&[(0x24001c20, &children), (0x14001d7a, &modified)])?;
1120 changed.insert(page, object);
1121 }
1122 }
1123 Ok(changed)
1124 })
1125 })
1126 }
1127
1128 /// Rewrites a drawing's stroke list: stored strokes stay as they are (OneNote erases whole
1129 /// strokes rather than editing them), removed ones leave the list, new ones are created.
1130 fn edit_ink(&mut self, stored: &Ink, ink: &Ink) -> Result<(), Error> {
1131 if stored.id != ink.id {
1132 return Err(invalid("Ink identity cannot change"));
1133 }
1134 if stored.layout != ink.layout || stored.groups != ink.groups {
1135 return Err(invalid("Ink position and groups stay as stored"));
1136 }
1137 let mut kept = Vec::new();
1138 let mut added = Vec::new();
1139 for stroke in &ink.strokes {
1140 match stored.strokes.iter().find(|s| s.id == stroke.id) {
1141 Some(previous) if previous == stroke => kept.push(self.id(stroke.id)),
1142 Some(_) => return Err(invalid("A stored stroke keeps its path and pen")),
1143 None => added.push((self.allocate(stroke.id)?, stroke)),
1144 }
1145 }
1146 let container = self.id(ink.id);
1147 let data = {
1148 let store = Store::parse(&self.image)?;
1149 let index = RevisionIndex::parse(&store)?;
1150 let document = Document::parse(&index)?;
1151 let revision = document
1152 .spaces
1153 .get(&self.space)
1154 .and_then(crate::document::Space::active)
1155 .ok_or_else(|| invalid("The page space is unavailable"))?;
1156 match revision.nodes.get(&container).map(|node| &node.kind) {
1157 Some(crate::document::Kind::Ink {
1158 data: Some(data), ..
1159 }) => *data,
1160 _ => return Err(invalid("Stored ink has no stroke data to rewrite")),
1161 }
1162 };
1163 let (modified, filetime) = crate::create::current_timestamps()?;
1164 let modified = modified.to_le_bytes();
1165 let space = self.space;
1166 self.apply(|current| {
1167 crate::write::write_revision(current, space, |raw| {
1168 let mut changed = BTreeMap::new();
1169 let mut data_object = PropertyObject::from_object(&raw.objects[&data])?;
1170 let mut references = Vec::new();
1171 for id in &kept {
1172 references.extend(data_object.reference(*id)?);
1173 }
1174 references.extend(write_strokes(
1175 &mut changed,
1176 &mut data_object,
1177 &added,
1178 kept.len(),
1179 filetime,
1180 )?);
1181 data_object.set(&[(0x24003416, &references)])?;
1182 changed.insert(data, data_object);
1183 let mut object = PropertyObject::from_object(&raw.objects[&container])?;
1184 object.set(&[(0x14001d7a, &modified)])?;
1185 changed.insert(container, object);
1186 Ok(changed)
1187 })
1188 })
1189 }
1190
8631191 /// Gives a new picture what OneNote stores for an inserted one: the payload embedded
8641192 /// in the section's file-data store, a file-data object declaring it by identity and
8651193 /// extension, and a picture object that a paragraph holds as content or the page
......@@ -928,18 +1256,7 @@ impl Lowering<'_> {
9281256 }
9291257 None => {
9301258 let mut object = PropertyObject::from_object(&raw.objects[&page])?;
931 let properties = crate::PropertySets::parse(&object.bytes)?;
932 let mut children = match properties.sets[0]
933 .iter()
934 .find(|p| p.id == 0x24001c20)
935 .map(|p| &p.value)
936 {
937 Some(crate::Value::References { compact_ids, .. }) => {
938 compact_ids.to_vec()
939 }
940 None => Vec::new(),
941 _ => return Err(invalid("The page has an invalid child list")),
942 };
1259 let mut children = page_children(&object)?;
9431260 children.extend(object.reference(image_id)?);
9441261 object.set(&[(0x24001c20, &children), (0x14001d7a, &modified)])?;
9451262 changed.insert(page, object);
......@@ -1023,7 +1340,8 @@ impl Lowering<'_> {
10231340 }
10241341 ParagraphContent::Table(_)
10251342 | ParagraphContent::Image(_)
1026 | ParagraphContent::Attachment(_) => ("", None),
1343 | ParagraphContent::Attachment(_)
1344 | ParagraphContent::Ink(_) => ("", None),
10271345 _ => {
10281346 return Err(invalid(
10291347 "New paragraphs contain plain text without fields or styles",
......@@ -1334,8 +1652,14 @@ impl Lowering<'_> {
13341652 let space = self.space;
13351653 self.apply(|image| edit.apply(image, space))?;
13361654 }
1337 let kept: BTreeSet<ExGuid> = page_images(new.page).map(|(id, _)| id).collect();
1338 for (id, _) in page_images(old.page) {
1655 let kept: BTreeSet<ExGuid> = page_images(new.page)
1656 .map(|(id, _)| id)
1657 .chain(page_ink(new.page).map(|(id, _)| id))
1658 .collect();
1659 for id in page_images(old.page)
1660 .map(|(id, _)| id)
1661 .chain(page_ink(old.page).map(|(id, _)| id))
1662 {
13391663 if !kept.contains(&id) {
13401664 let edit = TreeEdit::delete(id, self.author)?;
13411665 let space = self.space;
crates/onestore/src/tree.rs+1-1
......@@ -115,7 +115,7 @@ impl TreeEdit {
115115 .get(1)
116116 .ok_or_else(|| invalid("Select a paragraph or ordinary page outline"))?;
117117 let compatible = |parent: ExGuid| match view.nodes[&self.object].kind {
118 Kind::Outline { .. } | Kind::Image { .. } => parent == *page,
118 Kind::Outline { .. } | Kind::Image { .. } | Kind::Ink { .. } => parent == *page,
119119 Kind::Paragraph { .. } => matches!(
120120 view.nodes[&parent].kind,
121121 Kind::Outline { .. }
crates/onestore/tests/page_ink.rs+160-2
......@@ -1,9 +1,11 @@
11use onestore::{
2 ExGuid, RevisionIndex, Store,
2 ExGuid, PreparedEdit, RevisionIndex, Store,
33 document::Document,
4 page::{Ink, Page, PageObject},
4 page::{Ink, InkStroke, Page, PageObject, text::new_id},
55};
66
7const AUTHOR: &str = "Ink author";
8
79/// Two drawings made with the mouse in OneNote 2010 (`tools/native/ink.ahk`): a diamond and a
810/// diagonal line, whose native read reports their positions and sizes.
911const NATIVE: &[u8] =
......@@ -74,3 +76,159 @@ fn native_ink_drawings_read_as_strokes_in_page_coordinates() {
7476 }));
7577 }
7678}
79
80use onestore::page::ink::snap;
81
82fn stroke(points: &[[f32; 2]], color: Option<u32>) -> InkStroke {
83 InkStroke {
84 id: new_id().unwrap(),
85 points: points.iter().map(|p| [snap(p[0]), snap(p[1])]).collect(),
86 width: snap(1.0),
87 height: snap(1.0),
88 color,
89 transparency: None,
90 pen_tip: None,
91 }
92}
93
94fn drawing() -> Ink {
95 Ink {
96 id: new_id().unwrap(),
97 layout: Default::default(),
98 strokes: vec![
99 stroke(
100 &[
101 [300.0, 120.0],
102 [360.0, 120.0],
103 [360.0, 180.0],
104 [300.0, 180.0],
105 [300.0, 120.0],
106 ],
107 None,
108 ),
109 stroke(&[[300.0, 120.0], [360.0, 180.0]], Some(0x0000ff)),
110 ],
111 groups: Vec::new(),
112 }
113}
114
115fn page_in(bytes: &[u8], space: ExGuid) -> Page {
116 let store = Store::parse(bytes).unwrap();
117 assert!(store.checksum_mismatches.is_empty());
118 let index = RevisionIndex::parse(&store).unwrap();
119 Page::from_space(&Document::parse(&index).unwrap(), space).unwrap()
120}
121
122fn export(variable: &str, name: &str, written: &[u8]) {
123 if let Some(directory) = std::env::var_os(variable) {
124 let directory = std::path::PathBuf::from(directory);
125 std::fs::create_dir(&directory).unwrap();
126 std::fs::write(directory.join(name), written).unwrap();
127 let file_id = Store::parse(written).unwrap().header.file_id;
128 std::fs::write(
129 directory.join("Open Notebook.onetoc2"),
130 onestore::create_table_of_contents("Open Notebook.onetoc2", &[(name, file_id)])
131 .unwrap(),
132 )
133 .unwrap();
134 }
135}
136
137/// `ONESTORE_INK_EXPORT` names a new directory receiving the candidate for a cold reopen.
138#[test]
139fn an_ink_drawing_is_written_erased_stroke_by_stroke_and_removed() {
140 let source = onestore::create_section("ink.one", "Beside the drawing", "Author").unwrap();
141 let (space, before) = first_page(&source);
142 let mut after = before.clone();
143 let ink = drawing();
144 after.objects.push(PageObject::Ink(ink.clone()));
145 let written = PreparedEdit::page(&source, space, &after, AUTHOR).unwrap();
146 let stored = page_in(written.as_bytes(), space);
147 let mut expected = after.clone();
148 expected.title = stored.title.clone();
149 assert_eq!(stored, expected);
150 assert!(close(
151 drawings(&stored)[0].bounds().unwrap(),
152 [
153 snap(300.0),
154 snap(120.0),
155 snap(360.0) - snap(300.0),
156 snap(180.0) - snap(120.0)
157 ]
158 ));
159 assert_eq!(
160 PreparedEdit::page(written.as_bytes(), space, &stored, AUTHOR)
161 .unwrap()
162 .as_bytes(),
163 written.as_bytes()
164 );
165 export("ONESTORE_INK_EXPORT", "ink.one", written.as_bytes());
166 let mut erased = stored.clone();
167 for object in &mut erased.objects {
168 if let PageObject::Ink(ink) = object {
169 ink.strokes.remove(1);
170 ink.strokes
171 .push(stroke(&[[330.0, 100.0], [330.0, 200.0]], Some(0xff0000)));
172 }
173 }
174 let again = PreparedEdit::page(written.as_bytes(), space, &erased, AUTHOR).unwrap();
175 let stored = page_in(again.as_bytes(), space);
176 assert_eq!(stored, erased);
177 let mut removed = stored.clone();
178 removed.objects.retain(|object| object.id() != ink.id);
179 let last = PreparedEdit::page(again.as_bytes(), space, &removed, AUTHOR).unwrap();
180 let stored = page_in(last.as_bytes(), space);
181 assert!(drawings(&stored).is_empty());
182 assert_eq!(stored, removed);
183}
184
185/// `ONESTORE_INK_PARAGRAPH_EXPORT` names a new directory receiving the candidate for a cold
186/// reopen.
187#[test]
188fn handwriting_is_written_as_paragraph_content() {
189 use onestore::page::{PageParagraph, ParagraphContent};
190 let source = onestore::create_section("ink.one", "Above the handwriting", "Author").unwrap();
191 let (space, before) = first_page(&source);
192 let mut after = before.clone();
193 let outline = after
194 .objects
195 .iter_mut()
196 .find_map(|object| match object {
197 PageObject::Outline(outline) if !outline.title => Some(outline),
198 _ => None,
199 })
200 .unwrap();
201 let mut paragraph: PageParagraph = outline.paragraphs[0].clone();
202 paragraph.id = new_id().unwrap();
203 paragraph.parent = None;
204 paragraph.level = 1;
205 paragraph.lists.clear();
206 paragraph.tags.clear();
207 paragraph.style = None;
208 paragraph.collapsed = false;
209 paragraph.format = Default::default();
210 paragraph.content = ParagraphContent::Ink(drawing());
211 outline.paragraphs.push(paragraph);
212 let written = PreparedEdit::page(&source, space, &after, AUTHOR).unwrap();
213 let stored = page_in(written.as_bytes(), space);
214 let mut expected = after.clone();
215 expected.title = stored.title.clone();
216 assert_eq!(stored, expected);
217 export(
218 "ONESTORE_INK_PARAGRAPH_EXPORT",
219 "ink.one",
220 written.as_bytes(),
221 );
222}
223
224#[test]
225fn stored_strokes_keep_their_paths() {
226 let (space, page) = first_page(NATIVE);
227 let mut moved = page.clone();
228 for object in &mut moved.objects {
229 if let PageObject::Ink(ink) = object {
230 ink.strokes[0].points[0][0] += 1.0;
231 }
232 }
233 assert!(PreparedEdit::page(NATIVE, space, &moved, AUTHOR).is_err());
234}
tools/test_ink_edit.py created+41
......@@ -0,0 +1,41 @@
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-edit'
12compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
13
14
15def native_page(variant):
16 with TemporaryDirectory() as temporary:
17 read = Path(temporary) / 'read'
18 shutil.copytree(FIXTURE / variant / 'cold/read', read)
19 compare(FIXTURE / variant / 'candidate', read)
20 page, = (ET.parse(path).getroot() for path in sorted((FIXTURE / variant / 'cold/read').glob('page-*.xml')))
21 return page
22
23
24class InkEditTest(unittest.TestCase):
25 def test_a_rust_drawing_renders_natively_at_its_stroke_extent(self):
26 page = native_page('drawing')
27 drawing, = page.findall('one:InkDrawing', ns)
28 position, size = drawing.find('one:Position', ns), drawing.find('one:Size', ns)
29 self.assertAlmostEqual(float(position.get('x')), 300.0, delta=0.03)
30 self.assertAlmostEqual(float(position.get('y')), 120.0, delta=0.03)
31 self.assertAlmostEqual(float(size.get('width')), 60.0, delta=0.06)
32 self.assertAlmostEqual(float(size.get('height')), 60.0, delta=0.06)
33 self.assertTrue((FIXTURE / 'drawing/cold/read/page-000.png').exists())
34
35 def test_rust_handwriting_renders_natively_as_an_ink_paragraph(self):
36 page = native_page('handwriting')
37 self.assertEqual(len(list(page.iter('{%s}InkParagraph' % ns['one']))) + len(list(page.iter('{%s}InkDrawing' % ns['one']))), 1)
38
39
40if __name__ == '__main__':
41 unittest.main()