diff --git a/corpus/ink-edit/README.md b/corpus/ink-edit/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..6c197490dcf039a5f94109e14922dd906bd360c8
--- /dev/null
+++ b/corpus/ink-edit/README.md
@@ -0,0 +1,26 @@
+# Rust ink authoring
+
+`drawing/candidate` is the page writer's output for
+`an_ink_drawing_is_written_erased_stroke_by_stroke_and_removed` in
+`crates/onestore/tests/page_ink.rs`: on a section created in Rust, a page-level
+ink object with two strokes, a 60 pt square at (300, 120) and a red diagonal,
+stored the way OneNote 2010 stores a mouse drawing
+(`corpus/native-ink/cold-ui-ink`): an ink container listed as a page child, its
+data node listing stroke objects, each stroke's coordinates as ISF multi-byte
+first differences in HIMETRIC with its index, language, identity, time and
+half-inch origin, and one drawing-attribute object per distinct pen carrying
+the dimension table OneNote wrote. `drawing/cold` is a fresh OneNote 2010
+read: the `InkDrawing` reports the stroke extent as its position and size (one
+HIMETRIC unit larger, as OneNote does) and `page-000.png` shows the square and
+diagonal rendered.
+
+`handwriting/candidate` is the same drawing held by a new paragraph of the body
+outline (`handwriting_is_written_as_paragraph_content`); `handwriting/cold`
+shows OneNote accepting it as an `InkDrawing` inside the outline element, the
+outline grown to contain the strokes' absolute page coordinates.
+
+`tools/test_ink_edit.py` checks both without a VM. Regenerate with
+`ONESTORE_INK_EXPORT` and `ONESTORE_INK_PARAGRAPH_EXPORT` set to new absolute
+directories while running the tests, then cold-open each with
+`tools/native_runner.py OUTPUT COLD --expected-pages 1 --collect-notebook
+--screenshots`.
diff --git a/corpus/ink-edit/drawing/candidate/Open Notebook.onetoc2 b/corpus/ink-edit/drawing/candidate/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..4c88d541f9ef0d102d422398ad28b98221fa87f9
Binary files /dev/null and b/corpus/ink-edit/drawing/candidate/Open Notebook.onetoc2 differ
diff --git a/corpus/ink-edit/drawing/candidate/ink.one b/corpus/ink-edit/drawing/candidate/ink.one
new file mode 100644
index 0000000000000000000000000000000000000000..3f091445c573b00d4d36068af8702b61b5c28f8b
Binary files /dev/null and b/corpus/ink-edit/drawing/candidate/ink.one differ
diff --git a/corpus/ink-edit/drawing/cold/commands.jsonl b/corpus/ink-edit/drawing/cold/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..6f539dbc7fc2c9194652f93285b8f18d9f1634bc
--- /dev/null
+++ b/corpus/ink-edit/drawing/cold/commands.jsonl
@@ -0,0 +1,3 @@
+{"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}
+{"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}
+{"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}
diff --git a/corpus/ink-edit/drawing/cold/machine.json b/corpus/ink-edit/drawing/cold/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..60a3f8ff65ba8b0f17c7d9d88539882209d81ad6
--- /dev/null
+++ b/corpus/ink-edit/drawing/cold/machine.json
@@ -0,0 +1 @@
+{"name": "m6-57607115", "hostname": "ONE-M6-57607115"}
diff --git a/corpus/ink-edit/drawing/cold/notebook/Open Notebook.onetoc2 b/corpus/ink-edit/drawing/cold/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..188af44a99a7a91f5df5c223c563bd0cddb3bb0c
Binary files /dev/null and b/corpus/ink-edit/drawing/cold/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/ink-edit/drawing/cold/notebook/ink.one b/corpus/ink-edit/drawing/cold/notebook/ink.one
new file mode 100644
index 0000000000000000000000000000000000000000..1decb42ab31514130a2e667a704e5e9e550fa7a4
Binary files /dev/null and b/corpus/ink-edit/drawing/cold/notebook/ink.one differ
diff --git a/corpus/ink-edit/drawing/cold/read/environment.json b/corpus/ink-edit/drawing/cold/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..b8ba1cd2be5eca6e9749c523da00afbf87c69d83
--- /dev/null
+++ b/corpus/ink-edit/drawing/cold/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-57607115",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/ink-edit/drawing/cold/read/hierarchy.xml b/corpus/ink-edit/drawing/cold/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..83e9f120862661fe16e67ac94b33e360970a7008
--- /dev/null
+++ b/corpus/ink-edit/drawing/cold/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/ink-edit/drawing/cold/read/page-000.navigation.json b/corpus/ink-edit/drawing/cold/read/page-000.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..957beaafccbcba166fba87cf91fc907f33c74e81
--- /dev/null
+++ b/corpus/ink-edit/drawing/cold/read/page-000.navigation.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Beside the drawing - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/ink-edit/drawing/cold/read/page-000.png b/corpus/ink-edit/drawing/cold/read/page-000.png
new file mode 100644
index 0000000000000000000000000000000000000000..ca1ae460dfe067fb6efe3217010295ff3b95584e
Binary files /dev/null and b/corpus/ink-edit/drawing/cold/read/page-000.png differ
diff --git a/corpus/ink-edit/drawing/cold/read/page-000.xml b/corpus/ink-edit/drawing/cold/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..9a477951969db3dff4cfd2245dc416c7289818ae
--- /dev/null
+++ b/corpus/ink-edit/drawing/cold/read/page-000.xml
@@ -0,0 +1,7 @@
+
+AM8CHQSiAaIBAWAqABofi0JwRYR1gL7QRdkE+oeVMvLsF0G4wBa/8yv0et67E4vlXrNInJuhJVq3
+URK/7HHMzCKxQYkOuxMs7nqhWM9UiuaXxU+PBvi60uGbIsRmbacNslhJmh1OQDQGbBACEQZIEEUj
+RiMJSBBE/wFFI0YjBQgLZU9mZ2hkaRkUMggAgB4CIeLiQTMIAPAQAovF4kFkAxVGAEAKXAWH8gln
+jCj7wnvCe8IAh/B8h4Pke8I7wnvCQAUURgBAAAAJBAUURgAAAAASABEg7Ks5v90DzE+YKNcBjx6b
+FwoAESCA5B1tFkPdAQMVRgBACj9AIm9GCC70WAqACQEKTAIPUq7GcA5CJYzgBRRGAIAAAAkEBRRG
+AAAAABIAESBSpMe3m/jlRpcE5ouTMwiuCgARIIDkHW0WQ90BAxVGAEAKP0Aib0YILvRYCoA=
diff --git a/corpus/ink-edit/drawing/cold/read/payloads.json b/corpus/ink-edit/drawing/cold/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843
--- /dev/null
+++ b/corpus/ink-edit/drawing/cold/read/payloads.json
@@ -0,0 +1,3 @@
+[
+
+]
\ No newline at end of file
diff --git a/corpus/ink-edit/drawing/cold/run.json b/corpus/ink-edit/drawing/cold/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..cfbc7c81c4e591015915bf49041e1f218ec27586
--- /dev/null
+++ b/corpus/ink-edit/drawing/cold/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/private/tmp/m4/ink",
+ "expected_pages": 1,
+ "author": null,
+ "author_timeout_seconds": 600,
+ "inspect": false,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
+ }
+}
diff --git a/corpus/ink-edit/drawing/cold/scripts/cold.ps1 b/corpus/ink-edit/drawing/cold/scripts/cold.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f
--- /dev/null
+++ b/corpus/ink-edit/drawing/cold/scripts/cold.ps1
@@ -0,0 +1,27 @@
+param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
+$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
+if ($CloneHost) {
+ if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
+ throw 'The disposable clone hostname does not match this machine.'
+ }
+ New-Item "$key\Options\Paths" -Force | Out-Null
+ New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
+} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
+ -not (Test-Path 'C:\one-tests\profile-original-cache')) {
+ throw 'Park the personal OneNote profile before resetting the test cache.'
+}
+$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
+$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
+if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
+New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
+if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
+if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
+New-Item "$key\OpenNotebooks" | Out-Null
+New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
diff --git a/corpus/ink-edit/drawing/cold/scripts/read.ps1 b/corpus/ink-edit/drawing/cold/scripts/read.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b
--- /dev/null
+++ b/corpus/ink-edit/drawing/cold/scripts/read.ps1
@@ -0,0 +1,142 @@
+param(
+ [Parameter(Mandatory=$true)][string]$Root,
+ [int]$ExpectedPages = -1,
+ [switch]$UseCurrentCache,
+ [switch]$Pdf,
+ [switch]$KeepOpen,
+ [string]$CloneHost = ''
+)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+$notebook = Join-Path $root 'notebook'
+$output = Join-Path $root 'read'
+if (Test-Path $output) { throw 'Choose a new read destination.' }
+if ($UseCurrentCache) {
+ if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
+ throw 'Park the personal OneNote profile before reading test notebooks.'
+ }
+} else {
+ & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
+}
+New-Item -ItemType Directory -Path $output | Out-Null
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+$failure = $null
+try {
+ $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
+ $process = Get-Process ONENOTE
+ @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
+ powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
+ ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
+ $sections = @()
+ foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
+ $id = ''
+ $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
+ $sections += $id
+ }
+ $deadline = [DateTime]::UtcNow.AddSeconds(300)
+ $previous = ''
+ $lastChange = ''
+ $stableSince = [DateTime]::UtcNow
+ $settled = $false
+ do {
+ $pages = @{}
+ foreach ($section in $sections) {
+ $hierarchy = ''
+ $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
+ [xml]$xml = $hierarchy
+ foreach ($node in $xml.SelectNodes('//*[@path]')) {
+ if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'OneNote opened a section outside the copied notebook.'
+ }
+ }
+ foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
+ $id = $node.GetAttribute('ID')
+ $content = ''
+ $app.GetPageContent($id, [ref]$content, 1, 1)
+ $pages[$id] = $content
+ }
+ }
+ $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
+ if ($signature -ne $previous) {
+ $lastChange = $previous
+ $previous = $signature
+ $stableSince = [DateTime]::UtcNow
+ }
+ if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
+ ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
+ ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
+ Start-Sleep -Milliseconds 250
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
+ [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
+ $index = 0
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ $index++
+ }
+ $hierarchy = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
+ [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
+ throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
+ }
+ $index = 0
+ $payloads = @()
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ if ($Pdf) {
+ $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
+ $app.NavigateTo($id, '', $false)
+ $app.Publish($id, $pdfPath, 3, '')
+ if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
+ }
+ [xml]$page = $pages[$id]
+ foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
+ $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
+ $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
+ [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
+ $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
+ kind = $file.LocalName; name = $file.GetAttribute('preferredName');
+ sha256 = $hash; bytes = $bytes.Length }
+ }
+ $index++
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
+ $all = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
+ [xml]$finalTree = $all
+ $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
+ if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
+ throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
+ Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
+} catch {
+ $failure = $_
+ [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ throw
+} finally {
+ try {
+ try {
+ if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
+ if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
+ $app.CloseNotebook($notebookId, $false)
+ }
+ } catch {
+ if ($null -eq $failure) { throw }
+ [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ }
+ } finally {
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ }
+ $app = $null
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+ if (-not $UseCurrentCache -and -not $CloneHost) {
+ Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
+ }
+}
diff --git a/corpus/ink-edit/drawing/cold/source.json b/corpus/ink-edit/drawing/cold/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..45c00a79533d54a675d39df7c75d77d0f868dc25
--- /dev/null
+++ b/corpus/ink-edit/drawing/cold/source.json
@@ -0,0 +1,14 @@
+[
+ {
+ "path": "Open Notebook.onetoc2",
+ "bytes": 1596,
+ "sha256": "0c5c0a1f79fddde5a4dca355b718618a7ad7fc6f24e1a41900f08083adf2e657",
+ "mtime_ns": 1789259149540453267
+ },
+ {
+ "path": "ink.one",
+ "bytes": 6664,
+ "sha256": "9b662a8f4beb2bad0d547c251101887c245b8ac49a80a00efe49c70ab60c1236",
+ "mtime_ns": 1789259149539962603
+ }
+]
diff --git a/corpus/ink-edit/drawing/cold/teardown.json b/corpus/ink-edit/drawing/cold/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/ink-edit/drawing/cold/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/corpus/ink-edit/handwriting/candidate/Open Notebook.onetoc2 b/corpus/ink-edit/handwriting/candidate/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..5a34c6ad7c3ee394c8a88f2be219f766b78ea39c
Binary files /dev/null and b/corpus/ink-edit/handwriting/candidate/Open Notebook.onetoc2 differ
diff --git a/corpus/ink-edit/handwriting/candidate/ink.one b/corpus/ink-edit/handwriting/candidate/ink.one
new file mode 100644
index 0000000000000000000000000000000000000000..17995aa5ffa2b84eca114d31a61529f89e6612ec
Binary files /dev/null and b/corpus/ink-edit/handwriting/candidate/ink.one differ
diff --git a/corpus/ink-edit/handwriting/cold/commands.jsonl b/corpus/ink-edit/handwriting/cold/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..7dc72498256ad298ee6307198e095fa04d1945f1
--- /dev/null
+++ b/corpus/ink-edit/handwriting/cold/commands.jsonl
@@ -0,0 +1,3 @@
+{"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}
+{"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}
+{"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}
diff --git a/corpus/ink-edit/handwriting/cold/machine.json b/corpus/ink-edit/handwriting/cold/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..89e41bbd3141e690b970fb6575da62a450a171ea
--- /dev/null
+++ b/corpus/ink-edit/handwriting/cold/machine.json
@@ -0,0 +1 @@
+{"name": "m6-9e674848", "hostname": "ONE-M6-9E674848"}
diff --git a/corpus/ink-edit/handwriting/cold/notebook/Open Notebook.onetoc2 b/corpus/ink-edit/handwriting/cold/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..8155eda12682a33b9d1661269c32be43602e4860
Binary files /dev/null and b/corpus/ink-edit/handwriting/cold/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/ink-edit/handwriting/cold/notebook/ink.one b/corpus/ink-edit/handwriting/cold/notebook/ink.one
new file mode 100644
index 0000000000000000000000000000000000000000..ee15a8a652a5570ffe1cf389e8eead6de8179d9a
Binary files /dev/null and b/corpus/ink-edit/handwriting/cold/notebook/ink.one differ
diff --git a/corpus/ink-edit/handwriting/cold/read/environment.json b/corpus/ink-edit/handwriting/cold/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..73f73ea5c28e2c1d5e0aeadbc5e4548b46177bff
--- /dev/null
+++ b/corpus/ink-edit/handwriting/cold/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-9E674848",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/ink-edit/handwriting/cold/read/hierarchy.xml b/corpus/ink-edit/handwriting/cold/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..e43874a460a76679f03af7bf782023d4d9e27491
--- /dev/null
+++ b/corpus/ink-edit/handwriting/cold/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/ink-edit/handwriting/cold/read/page-000.navigation.json b/corpus/ink-edit/handwriting/cold/read/page-000.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..647b22d81dfa9db6e998b9b274523af9ca783a71
--- /dev/null
+++ b/corpus/ink-edit/handwriting/cold/read/page-000.navigation.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Above the handwriting - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/ink-edit/handwriting/cold/read/page-000.png b/corpus/ink-edit/handwriting/cold/read/page-000.png
new file mode 100644
index 0000000000000000000000000000000000000000..a70b8884261ec35c022f9a14c287fcd483b82406
Binary files /dev/null and b/corpus/ink-edit/handwriting/cold/read/page-000.png differ
diff --git a/corpus/ink-edit/handwriting/cold/read/page-000.xml b/corpus/ink-edit/handwriting/cold/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..b4de39ec263055f6ca6bbe382328b4761bb7b0df
--- /dev/null
+++ b/corpus/ink-edit/handwriting/cold/read/page-000.xml
@@ -0,0 +1,7 @@
+
+AM8CHQSiAaIBAWAqABofi0JwRYR1gL7QRdkE+oeVMvLsF0G4wBa/8yv0et67E4vlXrNInJuhJVq3
+URK/7HHMzCKxQYkOuxMs7nqhWM9UiuaXxU+PBvi60uGbIsRmbacNslhJmh1OQDQGbBACEQZIEEUj
+RiMJSBBE/wFFI0YjBQgLZU9mZ2hkaRkUMggAgB4CIeLiQTMIAPAQAovF4kFkAxVGAEAKXAWH8gln
+jCj7wnvCe8IAh/B8h4Pke8I7wnvCQAUURgBAAAAJBAUURgAAAAASABEgoX0Rk76oCkqcf4WNBm3y
+SgoAESCA5B1tFkPdAQMVRgBACj9AIm9GCC70WAqACQEKTAIPUq7GcA5CJYzgBRRGAIAAAAkEBRRG
+AAAAABIAESCehGefqfnSRo1JDkRPsA5FCgARIIDkHW0WQ90BAxVGAEAKP0Aib0YILvRYCoA=
diff --git a/corpus/ink-edit/handwriting/cold/read/payloads.json b/corpus/ink-edit/handwriting/cold/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843
--- /dev/null
+++ b/corpus/ink-edit/handwriting/cold/read/payloads.json
@@ -0,0 +1,3 @@
+[
+
+]
\ No newline at end of file
diff --git a/corpus/ink-edit/handwriting/cold/run.json b/corpus/ink-edit/handwriting/cold/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..538b773d85f83281076d7bb4d42d505314123c59
--- /dev/null
+++ b/corpus/ink-edit/handwriting/cold/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/private/tmp/m4/ink-para",
+ "expected_pages": 1,
+ "author": null,
+ "author_timeout_seconds": 600,
+ "inspect": false,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
+ }
+}
diff --git a/corpus/ink-edit/handwriting/cold/scripts/cold.ps1 b/corpus/ink-edit/handwriting/cold/scripts/cold.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f
--- /dev/null
+++ b/corpus/ink-edit/handwriting/cold/scripts/cold.ps1
@@ -0,0 +1,27 @@
+param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
+$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
+if ($CloneHost) {
+ if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
+ throw 'The disposable clone hostname does not match this machine.'
+ }
+ New-Item "$key\Options\Paths" -Force | Out-Null
+ New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
+} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
+ -not (Test-Path 'C:\one-tests\profile-original-cache')) {
+ throw 'Park the personal OneNote profile before resetting the test cache.'
+}
+$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
+$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
+if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
+New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
+if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
+if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
+New-Item "$key\OpenNotebooks" | Out-Null
+New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
diff --git a/corpus/ink-edit/handwriting/cold/scripts/read.ps1 b/corpus/ink-edit/handwriting/cold/scripts/read.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b
--- /dev/null
+++ b/corpus/ink-edit/handwriting/cold/scripts/read.ps1
@@ -0,0 +1,142 @@
+param(
+ [Parameter(Mandatory=$true)][string]$Root,
+ [int]$ExpectedPages = -1,
+ [switch]$UseCurrentCache,
+ [switch]$Pdf,
+ [switch]$KeepOpen,
+ [string]$CloneHost = ''
+)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+$notebook = Join-Path $root 'notebook'
+$output = Join-Path $root 'read'
+if (Test-Path $output) { throw 'Choose a new read destination.' }
+if ($UseCurrentCache) {
+ if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
+ throw 'Park the personal OneNote profile before reading test notebooks.'
+ }
+} else {
+ & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
+}
+New-Item -ItemType Directory -Path $output | Out-Null
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+$failure = $null
+try {
+ $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
+ $process = Get-Process ONENOTE
+ @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
+ powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
+ ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
+ $sections = @()
+ foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
+ $id = ''
+ $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
+ $sections += $id
+ }
+ $deadline = [DateTime]::UtcNow.AddSeconds(300)
+ $previous = ''
+ $lastChange = ''
+ $stableSince = [DateTime]::UtcNow
+ $settled = $false
+ do {
+ $pages = @{}
+ foreach ($section in $sections) {
+ $hierarchy = ''
+ $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
+ [xml]$xml = $hierarchy
+ foreach ($node in $xml.SelectNodes('//*[@path]')) {
+ if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'OneNote opened a section outside the copied notebook.'
+ }
+ }
+ foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
+ $id = $node.GetAttribute('ID')
+ $content = ''
+ $app.GetPageContent($id, [ref]$content, 1, 1)
+ $pages[$id] = $content
+ }
+ }
+ $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
+ if ($signature -ne $previous) {
+ $lastChange = $previous
+ $previous = $signature
+ $stableSince = [DateTime]::UtcNow
+ }
+ if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
+ ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
+ ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
+ Start-Sleep -Milliseconds 250
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
+ [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
+ $index = 0
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ $index++
+ }
+ $hierarchy = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
+ [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
+ throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
+ }
+ $index = 0
+ $payloads = @()
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ if ($Pdf) {
+ $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
+ $app.NavigateTo($id, '', $false)
+ $app.Publish($id, $pdfPath, 3, '')
+ if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
+ }
+ [xml]$page = $pages[$id]
+ foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
+ $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
+ $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
+ [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
+ $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
+ kind = $file.LocalName; name = $file.GetAttribute('preferredName');
+ sha256 = $hash; bytes = $bytes.Length }
+ }
+ $index++
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
+ $all = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
+ [xml]$finalTree = $all
+ $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
+ if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
+ throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
+ Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
+} catch {
+ $failure = $_
+ [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ throw
+} finally {
+ try {
+ try {
+ if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
+ if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
+ $app.CloseNotebook($notebookId, $false)
+ }
+ } catch {
+ if ($null -eq $failure) { throw }
+ [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ }
+ } finally {
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ }
+ $app = $null
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+ if (-not $UseCurrentCache -and -not $CloneHost) {
+ Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
+ }
+}
diff --git a/corpus/ink-edit/handwriting/cold/source.json b/corpus/ink-edit/handwriting/cold/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..dbc9c7a4b3dfe65970e1559a835eb0da32b0599e
--- /dev/null
+++ b/corpus/ink-edit/handwriting/cold/source.json
@@ -0,0 +1,14 @@
+[
+ {
+ "path": "Open Notebook.onetoc2",
+ "bytes": 1596,
+ "sha256": "fc5a7b9e81d5d3e5e4328faa25445795335490f9ef35c0abc047880f90c24699",
+ "mtime_ns": 1789259149540474767
+ },
+ {
+ "path": "ink.one",
+ "bytes": 7240,
+ "sha256": "62ab52907aaef6066817c2598a9e6ca399813f02ab8004971ee1462360eeb555",
+ "mtime_ns": 1789259149540106435
+ }
+]
diff --git a/corpus/ink-edit/handwriting/cold/teardown.json b/corpus/ink-edit/handwriting/cold/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/ink-edit/handwriting/cold/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/crates/notebook/README.md b/crates/notebook/README.md
index cd1073986e9ef784ae93d96983a051ab006d9636..e9b9ee4d9efe9223362d8366610e00664737645e 100644
--- a/crates/notebook/README.md
+++ b/crates/notebook/README.md
@@ -14,7 +14,7 @@ diffs the supplied model against the page stored in the supplied local snapshot,
writes the difference into the working image and queues one
`Operation::Page(PageIntent { before, after, author })`; `before` is the page the
edit started from and is the precondition reconciliation checks. Text, styles,
-paragraph 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,
+paragraph 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,
outline layout, insertions and deletions are all differences between `before` and
`after`; the library never sees editor operations.
diff --git a/crates/onestore/README.md b/crates/onestore/README.md
index 049734dcee99c9139261bfbbe627beea8d82d2cf..1da730d746a2bdae2cecb646bdabd6a064670fb9 100644
--- a/crates/onestore/README.md
+++ b/crates/onestore/README.md
@@ -57,7 +57,7 @@ harness also accepts `--client-profile release`.
| `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules |
| `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs |
| `OutlineEdit`, `PreparedEdit::outline` | Change ordinary outline position/width or a paragraph's saved expansion default, preserving identities and content |
-| `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 |
+| `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 |
| `TreeEdit`, `PreparedEdit::tree` | Move or delete a subtree on one page, normalize surviving containers, and replace an emptied table cell's paragraph atomically |
| `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter |
| `read_file` | Read a snapshot under whole-file exclusion |
diff --git a/crates/onestore/src/page/ink.rs b/crates/onestore/src/page/ink.rs
index b787423b0869a4774243dd20081abaa5c1163ce1..c947855a3541e84ef4cc2d6f9a02795c5f2449ed 100644
--- a/crates/onestore/src/page/ink.rs
+++ b/crates/onestore/src/page/ink.rs
@@ -7,7 +7,16 @@ use crate::{
document::{Kind, Layout, Revision},
};
-const HIMETRIC_PER_POINT: f32 = 2540.0 / 72.0;
+/// Points from a HIMETRIC coordinate, the one conversion every stroke coordinate goes through.
+fn points(himetric: i64, scale: f32) -> f32 {
+ himetric as f32 * scale * 72.0 / 2540.0
+}
+
+/// Rounds a page point to the HIMETRIC grid OneNote stores, so a stroke written from it reads
+/// back equal.
+pub fn snap(value: f32) -> f32 {
+ points((value * 2540.0 / 72.0).round() as i64, 1.0)
+}
const DIMENSION_X: [u8; 16] = [
0x8f, 0x6a, 0x8a, 0x59, 0xc0, 0x52, 0xa0, 0x4b, 0x93, 0xaf, 0xaf, 0x35, 0x74, 0x11, 0xa5, 0x61,
];
@@ -165,7 +174,7 @@ impl InkStroke {
.iter()
.map(move |delta| {
position += delta;
- position as f32 * factor / HIMETRIC_PER_POINT
+ points(position, factor)
})
};
let points = coordinates(x, scale[0])
@@ -175,8 +184,8 @@ impl InkStroke {
Ok(Self {
id,
points,
- width: width.unwrap_or(0.0) / HIMETRIC_PER_POINT,
- height: height.unwrap_or(0.0) / HIMETRIC_PER_POINT,
+ width: width.unwrap_or(0.0) * 72.0 / 2540.0,
+ height: height.unwrap_or(0.0) * 72.0 / 2540.0,
color: *color,
transparency: *transparency,
pen_tip: *pen_tip,
@@ -213,3 +222,69 @@ fn multi_byte(bytes: &[u8]) -> Option> {
}
(cursor == bytes.len()).then_some(values)
}
+
+impl InkStroke {
+ /// The stroke packet OneNote stores: X then Y as ISF multi-byte first differences of the
+ /// points rounded to HIMETRIC.
+ pub(crate) fn packet(&self) -> Vec {
+ let axis = |index: usize| {
+ let mut previous = 0i64;
+ self.points.iter().map(move |point| {
+ let value = (point[index] * 2540.0 / 72.0).round() as i64;
+ let delta = value - previous;
+ previous = value;
+ delta
+ })
+ };
+ let values: Vec = axis(0).chain(axis(1)).collect();
+ let mut out = Vec::new();
+ let mut push = |raw: u64| {
+ let mut raw = raw;
+ loop {
+ let byte = (raw & 0x7f) as u8;
+ raw >>= 7;
+ if raw == 0 {
+ out.push(byte);
+ break;
+ }
+ out.push(byte | 0x80);
+ }
+ };
+ push((values.len() as u64) << 1);
+ for value in values {
+ push(((value.unsigned_abs()) << 1) | u64::from(value < 0));
+ }
+ out
+ }
+}
+
+/// The dimension table OneNote 2010 writes for mouse ink: X and Y in HIMETRIC with the limits
+/// and resolution of the authoring screen.
+pub(crate) const DIMENSIONS: [u8; 64] = [
+ 0x8f, 0x6a, 0x8a, 0x59, 0xc0, 0x52, 0xa0, 0x4b, 0x93, 0xaf, 0xaf, 0x35, 0x74, 0x11, 0xa5, 0x61,
+ 0x00, 0x00, 0x00, 0x00, 0x80, 0x07, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x21, 0xe2, 0xe2, 0x41,
+ 0x75, 0x9f, 0x3f, 0xb5, 0xe0, 0x04, 0x98, 0x44, 0xa7, 0xee, 0xc3, 0x0d, 0xbb, 0x5a, 0x90, 0x11,
+ 0x00, 0x00, 0x00, 0x00, 0x38, 0x04, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x8b, 0xc5, 0xe2, 0x41,
+];
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn packets_round_trip_through_the_multi_byte_coding() {
+ let stroke = InkStroke {
+ id: ExGuid::default(),
+ points: vec![[100.0, 50.0], [100.5, 49.0], [99.0, 49.0]],
+ width: 1.0,
+ height: 1.0,
+ color: None,
+ transparency: None,
+ pen_tip: None,
+ };
+ let values = multi_byte(&stroke.packet()).unwrap();
+ assert_eq!(values.len(), 6);
+ assert_eq!(&values[..3], &[3528, 17, -52]);
+ assert_eq!(&values[3..], &[1764, -35, 0]);
+ }
+}
diff --git a/crates/onestore/src/page/write.rs b/crates/onestore/src/page/write.rs
index 70c47fe6d5cf45bbdfc9b91e24d1fff287d1783d..35abf42da6ecd2792374cb28cac9005215e7d432 100644
--- a/crates/onestore/src/page/write.rs
+++ b/crates/onestore/src/page/write.rs
@@ -1,7 +1,7 @@
//! Publishes an edited page model by lowering the difference from the stored page onto
//! the typed writers, then squashing their transactions into one revision per space.
-use super::{Image, Outline, Page, PageObject, PageParagraph, ParagraphContent, Table};
+use super::{Image, Ink, Outline, Page, PageObject, PageParagraph, ParagraphContent, Table};
use crate::{
Error, ExGuid, Insertion, ObjectData, OutlineEdit, ParagraphJoin, ParagraphSplit, PropertySets,
RevisionIndex, Store, TextAttribute, TreeEdit, Value,
@@ -35,6 +35,155 @@ fn page_images(page: &Page) -> impl Iterator- {
})
}
+fn page_ink(page: &Page) -> impl Iterator
- {
+ page.objects.iter().filter_map(|object| match object {
+ PageObject::Ink(ink) => Some((ink.id, ink)),
+ _ => None,
+ })
+}
+
+/// The compact child references a page object holds.
+fn page_children(object: &PropertyObject) -> Result, Error> {
+ let properties = crate::PropertySets::parse(&object.bytes)?;
+ match properties.sets[0]
+ .iter()
+ .find(|p| p.id == 0x24001c20)
+ .map(|p| &p.value)
+ {
+ Some(crate::Value::References { compact_ids, .. }) => Ok(compact_ids.to_vec()),
+ None => Ok(Vec::new()),
+ _ => Err(invalid("The page has an invalid child list")),
+ }
+}
+
+/// Creates stroke objects (numbered from `first`) and one drawing-attribute object per
+/// distinct pen; returns the compact references for the data node's stroke list.
+fn write_strokes(
+ changed: &mut BTreeMap,
+ data_object: &mut PropertyObject,
+ strokes: &[(ExGuid, &super::InkStroke)],
+ first: usize,
+ filetime: u64,
+) -> Result, Error> {
+ let mut styles: Vec<(InkPen, ExGuid)> = Vec::new();
+ let mut references = Vec::new();
+ for (offset, (stroke_id, stroke)) in strokes.iter().enumerate() {
+ let pen = InkPen::of(stroke);
+ let style = match styles.iter().find(|(known, _)| *known == pen) {
+ Some((_, id)) => *id,
+ None => {
+ let id = ExGuid {
+ guid: crate::write::fresh_guid()?,
+ n: 1,
+ };
+ let mut style = PropertyObject {
+ jcid: 0x120048,
+ bytes: crate::create::properties(&pen.values())?,
+ global_ids: std::sync::Arc::new(BTreeMap::from([(0, id.guid)])),
+ };
+ style.reference(id)?;
+ changed.insert(id, style);
+ styles.push((pen, id));
+ id
+ }
+ };
+ let mut object = PropertyObject {
+ jcid: 0x20047,
+ bytes: crate::create::properties(&stroke_values(
+ stroke,
+ (first + offset) as u32 + 1,
+ filetime,
+ )?)?,
+ global_ids: std::sync::Arc::new(BTreeMap::from([(0, stroke_id.guid)])),
+ };
+ object.reference(*stroke_id)?;
+ let style_reference = object.reference(style)?;
+ object.set(&[(0x20003409, &style_reference)])?;
+ changed.insert(*stroke_id, object);
+ references.extend(data_object.reference(*stroke_id)?);
+ }
+ Ok(references)
+}
+
+/// The drawing attributes OneNote shares between strokes drawn with the same pen.
+#[derive(PartialEq)]
+struct InkPen {
+ width: u32,
+ height: u32,
+ color: Option,
+ transparency: Option,
+ pen_tip: Option,
+}
+
+impl InkPen {
+ fn of(stroke: &super::InkStroke) -> Self {
+ Self {
+ width: (stroke.width * 2540.0 / 72.0).to_bits(),
+ height: (stroke.height * 2540.0 / 72.0).to_bits(),
+ color: stroke.color,
+ transparency: stroke.transparency,
+ pen_tip: stroke.pen_tip,
+ }
+ }
+
+ fn values(&self) -> Values {
+ let mut values: Values = vec![
+ (0x1c00340a, super::ink::DIMENSIONS.to_vec()),
+ (0x1400340c, self.height.to_le_bytes().to_vec()),
+ (0x1400340d, self.width.to_le_bytes().to_vec()),
+ ];
+ if let Some(color) = self.color {
+ values.push((0x1400340f, color.to_le_bytes().to_vec()));
+ }
+ if let Some(transparency) = self.transparency {
+ values.push((0x0c003414, vec![transparency]));
+ }
+ if let Some(tip) = self.pen_tip {
+ values.push((0x0c003412, vec![tip]));
+ }
+ values
+ }
+}
+
+fn stroke_values(stroke: &super::InkStroke, index: u32, filetime: u64) -> Result {
+ if stroke.points.is_empty() {
+ return Err(invalid("A stroke needs at least one point"));
+ }
+ if stroke
+ .points
+ .iter()
+ .any(|p| !p[0].is_finite() || !p[1].is_finite())
+ || !(stroke.width.is_finite() && stroke.height.is_finite())
+ || stroke.width <= 0.0
+ || stroke.height <= 0.0
+ {
+ return Err(invalid(
+ "Stroke points and pen size must be finite and positive",
+ ));
+ }
+ let left = stroke
+ .points
+ .iter()
+ .map(|p| p[0])
+ .fold(f32::INFINITY, f32::min);
+ let top = stroke
+ .points
+ .iter()
+ .map(|p| p[1])
+ .fold(f32::INFINITY, f32::min);
+ let mut origin = ((left - stroke.width / 2.0) / 36.0).to_le_bytes().to_vec();
+ origin.extend_from_slice(&((top - stroke.height / 2.0) / 36.0).to_le_bytes());
+ Ok(vec![
+ (0x1c00340b, stroke.packet()),
+ (0x14003419, index.to_le_bytes().to_vec()),
+ (0x1000341b, 0x409_u16.to_le_bytes().to_vec()),
+ (0x0c00341c, vec![0]),
+ (0x1c00341a, crate::write::fresh_guid()?.to_vec()),
+ (0x1c00341d, filetime.to_le_bytes().to_vec()),
+ (0x1c00345b, origin),
+ ])
+}
+
fn picture_fixed_fields(stored: &Image, image: &Image) -> Result<(), Error> {
if stored.id != image.id
|| stored.bytes != image.bytes
@@ -366,7 +515,6 @@ impl Lowering<'_> {
.iter()
.filter_map(|object| match object {
PageObject::Unsupported(unsupported) => Some(format!("{unsupported:?}")),
- PageObject::Ink(ink) => Some(format!("{ink:?}")),
PageObject::Title(title) => Some(format!(
"{:?} {:?} {:?} {:?}",
title.id,
@@ -374,7 +522,7 @@ impl Lowering<'_> {
title.layout,
title.outlines.iter().map(|o| o.id).collect::>()
)),
- PageObject::Outline(_) | PageObject::Image(_) => None,
+ PageObject::Outline(_) | PageObject::Image(_) | PageObject::Ink(_) => None,
})
.collect()
};
@@ -384,7 +532,7 @@ impl Lowering<'_> {
after_fixed.sort();
if before_fixed != after_fixed {
return Err(invalid(
- "Titles, ink and unsupported objects cannot be edited through the page model",
+ "Titles and unsupported objects cannot be edited through the page model",
));
}
for (id, outline) in &new.outlines {
@@ -447,6 +595,11 @@ impl Lowering<'_> {
}
}
(ParagraphContent::Unsupported(a), ParagraphContent::Unsupported(b)) if a == b => {}
+ (ParagraphContent::Ink(a), ParagraphContent::Ink(b)) => {
+ if a.id != b.id {
+ return Err(invalid("Ink identity cannot change"));
+ }
+ }
(ParagraphContent::Image(a), ParagraphContent::Image(b)) => {
picture_fixed_fields(a, b)?;
if (a.layout.x, a.layout.y) != (b.layout.x, b.layout.y) {
@@ -637,6 +790,7 @@ impl Lowering<'_> {
.any(|object| matches!(object, PageObject::Title(title) if title.id == id))
};
self.edit_page_images(old, new)?;
+ self.edit_page_ink(old, new)?;
let survivors: Vec = page_order
.iter()
.copied()
@@ -693,6 +847,7 @@ impl Lowering<'_> {
self.edit_table_structure(old, new)?;
self.edit_cells(old, new)?;
self.edit_images(old, new)?;
+ self.edit_ink_paragraphs(old, new)?;
self.edit_attachments(old, new)?;
self.place_containers(old, new, placed, &deferred)?;
Ok(())
@@ -860,6 +1015,179 @@ impl Lowering<'_> {
Ok(())
}
+ /// Ink placed on the page: new drawings are appended for the placement pass to order,
+ /// changed ones have their stroke list rewritten, removed ones go in the delete pass.
+ fn edit_page_ink(&mut self, old: &View<'_>, new: &View<'_>) -> Result<(), Error> {
+ let stored: BTreeMap = page_ink(old.page).collect();
+ for (id, ink) in page_ink(new.page) {
+ match stored.get(&id) {
+ Some(previous) => {
+ if *previous != ink {
+ self.edit_ink(previous, ink)?;
+ }
+ }
+ None => self.insert_ink(ink, None)?,
+ }
+ }
+ Ok(())
+ }
+
+ fn edit_ink_paragraphs(&mut self, old: &View<'_>, new: &View<'_>) -> Result<(), Error> {
+ for (paragraph_id, paragraph) in &new.paragraphs {
+ let ParagraphContent::Ink(ink) = ¶graph.content else {
+ continue;
+ };
+ match old.paragraphs.get(paragraph_id) {
+ Some(previous) => {
+ let ParagraphContent::Ink(stored) = &previous.content else {
+ return Err(invalid("Paragraph content type cannot change"));
+ };
+ if stored != ink {
+ self.edit_ink(stored, ink)?;
+ }
+ }
+ None => {
+ let holder = self.id(*paragraph_id);
+ self.insert_ink(ink, Some(holder))?;
+ }
+ }
+ }
+ Ok(())
+ }
+
+ /// Writes new ink the way OneNote 2010 stores a drawing: a container the page lists as a
+ /// child (or a paragraph holds as content), its data node listing stroke objects, each
+ /// stroke's packet and half-inch origin, and one drawing-attribute object per distinct pen.
+ fn insert_ink(&mut self, ink: &Ink, holder: Option) -> Result<(), Error> {
+ if !ink.groups.is_empty() {
+ return Err(invalid("New ink holds strokes, not nested groups"));
+ }
+ if ink.strokes.is_empty() {
+ return Err(invalid("New ink needs at least one stroke"));
+ }
+ if ink.layout != Default::default() {
+ return Err(invalid("Ink positions come from its strokes"));
+ }
+ let container = self.allocate(ink.id)?;
+ let data = ExGuid {
+ guid: crate::write::fresh_guid()?,
+ n: 1,
+ };
+ let mut strokes = Vec::new();
+ for stroke in &ink.strokes {
+ strokes.push((self.allocate(stroke.id)?, stroke));
+ }
+ let (modified, filetime) = crate::create::current_timestamps()?;
+ let modified = modified.to_le_bytes();
+ let (space, page) = (self.space, self.page);
+ self.apply(|current| {
+ crate::write::write_revision(current, space, |raw| {
+ let mut changed = BTreeMap::new();
+ let mut data_object = PropertyObject {
+ jcid: 0x2003b,
+ bytes: crate::create::properties(&[])?,
+ global_ids: std::sync::Arc::new(BTreeMap::from([(0, data.guid)])),
+ };
+ data_object.reference(data)?;
+ let references =
+ write_strokes(&mut changed, &mut data_object, &strokes, 0, filetime)?;
+ data_object.set(&[(0x24003416, &references)])?;
+ changed.insert(data, data_object);
+ let mut object = PropertyObject {
+ jcid: 0x60014,
+ bytes: crate::create::properties(&[
+ (0x14001d7a, modified.to_vec()),
+ (0x14001d4e, 1u32.to_le_bytes().to_vec()),
+ ])?,
+ global_ids: std::sync::Arc::new(BTreeMap::from([(0, container.guid)])),
+ };
+ object.reference(container)?;
+ let data_reference = object.reference(data)?;
+ object.set(&[(0x20003415, &data_reference)])?;
+ changed.insert(container, object);
+ match holder {
+ Some(holder) => {
+ let mut object = PropertyObject::from_object(&raw.objects[&holder])?;
+ let content = object.reference(container)?;
+ object.set(&[(0x24001c1f, &content), (0x14001d7a, &modified)])?;
+ changed.insert(holder, object);
+ }
+ None => {
+ let mut object = PropertyObject::from_object(&raw.objects[&page])?;
+ let mut children = page_children(&object)?;
+ children.extend(object.reference(container)?);
+ object.set(&[(0x24001c20, &children), (0x14001d7a, &modified)])?;
+ changed.insert(page, object);
+ }
+ }
+ Ok(changed)
+ })
+ })
+ }
+
+ /// Rewrites a drawing's stroke list: stored strokes stay as they are (OneNote erases whole
+ /// strokes rather than editing them), removed ones leave the list, new ones are created.
+ fn edit_ink(&mut self, stored: &Ink, ink: &Ink) -> Result<(), Error> {
+ if stored.id != ink.id {
+ return Err(invalid("Ink identity cannot change"));
+ }
+ if stored.layout != ink.layout || stored.groups != ink.groups {
+ return Err(invalid("Ink position and groups stay as stored"));
+ }
+ let mut kept = Vec::new();
+ let mut added = Vec::new();
+ for stroke in &ink.strokes {
+ match stored.strokes.iter().find(|s| s.id == stroke.id) {
+ Some(previous) if previous == stroke => kept.push(self.id(stroke.id)),
+ Some(_) => return Err(invalid("A stored stroke keeps its path and pen")),
+ None => added.push((self.allocate(stroke.id)?, stroke)),
+ }
+ }
+ let container = self.id(ink.id);
+ let data = {
+ let store = Store::parse(&self.image)?;
+ let index = RevisionIndex::parse(&store)?;
+ let document = Document::parse(&index)?;
+ let revision = document
+ .spaces
+ .get(&self.space)
+ .and_then(crate::document::Space::active)
+ .ok_or_else(|| invalid("The page space is unavailable"))?;
+ match revision.nodes.get(&container).map(|node| &node.kind) {
+ Some(crate::document::Kind::Ink {
+ data: Some(data), ..
+ }) => *data,
+ _ => return Err(invalid("Stored ink has no stroke data to rewrite")),
+ }
+ };
+ let (modified, filetime) = crate::create::current_timestamps()?;
+ let modified = modified.to_le_bytes();
+ let space = self.space;
+ self.apply(|current| {
+ crate::write::write_revision(current, space, |raw| {
+ let mut changed = BTreeMap::new();
+ let mut data_object = PropertyObject::from_object(&raw.objects[&data])?;
+ let mut references = Vec::new();
+ for id in &kept {
+ references.extend(data_object.reference(*id)?);
+ }
+ references.extend(write_strokes(
+ &mut changed,
+ &mut data_object,
+ &added,
+ kept.len(),
+ filetime,
+ )?);
+ data_object.set(&[(0x24003416, &references)])?;
+ changed.insert(data, data_object);
+ let mut object = PropertyObject::from_object(&raw.objects[&container])?;
+ object.set(&[(0x14001d7a, &modified)])?;
+ changed.insert(container, object);
+ Ok(changed)
+ })
+ })
+ }
+
/// Gives a new picture what OneNote stores for an inserted one: the payload embedded
/// in the section's file-data store, a file-data object declaring it by identity and
/// extension, and a picture object that a paragraph holds as content or the page
@@ -928,18 +1256,7 @@ impl Lowering<'_> {
}
None => {
let mut object = PropertyObject::from_object(&raw.objects[&page])?;
- let properties = crate::PropertySets::parse(&object.bytes)?;
- let mut children = match properties.sets[0]
- .iter()
- .find(|p| p.id == 0x24001c20)
- .map(|p| &p.value)
- {
- Some(crate::Value::References { compact_ids, .. }) => {
- compact_ids.to_vec()
- }
- None => Vec::new(),
- _ => return Err(invalid("The page has an invalid child list")),
- };
+ let mut children = page_children(&object)?;
children.extend(object.reference(image_id)?);
object.set(&[(0x24001c20, &children), (0x14001d7a, &modified)])?;
changed.insert(page, object);
@@ -1023,7 +1340,8 @@ impl Lowering<'_> {
}
ParagraphContent::Table(_)
| ParagraphContent::Image(_)
- | ParagraphContent::Attachment(_) => ("", None),
+ | ParagraphContent::Attachment(_)
+ | ParagraphContent::Ink(_) => ("", None),
_ => {
return Err(invalid(
"New paragraphs contain plain text without fields or styles",
@@ -1334,8 +1652,14 @@ impl Lowering<'_> {
let space = self.space;
self.apply(|image| edit.apply(image, space))?;
}
- let kept: BTreeSet = page_images(new.page).map(|(id, _)| id).collect();
- for (id, _) in page_images(old.page) {
+ let kept: BTreeSet = page_images(new.page)
+ .map(|(id, _)| id)
+ .chain(page_ink(new.page).map(|(id, _)| id))
+ .collect();
+ for id in page_images(old.page)
+ .map(|(id, _)| id)
+ .chain(page_ink(old.page).map(|(id, _)| id))
+ {
if !kept.contains(&id) {
let edit = TreeEdit::delete(id, self.author)?;
let space = self.space;
diff --git a/crates/onestore/src/tree.rs b/crates/onestore/src/tree.rs
index 73822329e127c179e4e62d33ef1af3292ced9e47..e3d4d8d8e576d34af0506289cb86299d095834ba 100644
--- a/crates/onestore/src/tree.rs
+++ b/crates/onestore/src/tree.rs
@@ -115,7 +115,7 @@ impl TreeEdit {
.get(1)
.ok_or_else(|| invalid("Select a paragraph or ordinary page outline"))?;
let compatible = |parent: ExGuid| match view.nodes[&self.object].kind {
- Kind::Outline { .. } | Kind::Image { .. } => parent == *page,
+ Kind::Outline { .. } | Kind::Image { .. } | Kind::Ink { .. } => parent == *page,
Kind::Paragraph { .. } => matches!(
view.nodes[&parent].kind,
Kind::Outline { .. }
diff --git a/crates/onestore/tests/page_ink.rs b/crates/onestore/tests/page_ink.rs
index 4b7f9935b1030c70363a25d85e3a00532f460cc2..a27a31732ea125864e778834dab907ad05fbee22 100644
--- a/crates/onestore/tests/page_ink.rs
+++ b/crates/onestore/tests/page_ink.rs
@@ -1,9 +1,11 @@
use onestore::{
- ExGuid, RevisionIndex, Store,
+ ExGuid, PreparedEdit, RevisionIndex, Store,
document::Document,
- page::{Ink, Page, PageObject},
+ page::{Ink, InkStroke, Page, PageObject, text::new_id},
};
+const AUTHOR: &str = "Ink author";
+
/// Two drawings made with the mouse in OneNote 2010 (`tools/native/ink.ahk`): a diamond and a
/// diagonal line, whose native read reports their positions and sizes.
const NATIVE: &[u8] =
@@ -74,3 +76,159 @@ fn native_ink_drawings_read_as_strokes_in_page_coordinates() {
}));
}
}
+
+use onestore::page::ink::snap;
+
+fn stroke(points: &[[f32; 2]], color: Option) -> InkStroke {
+ InkStroke {
+ id: new_id().unwrap(),
+ points: points.iter().map(|p| [snap(p[0]), snap(p[1])]).collect(),
+ width: snap(1.0),
+ height: snap(1.0),
+ color,
+ transparency: None,
+ pen_tip: None,
+ }
+}
+
+fn drawing() -> Ink {
+ Ink {
+ id: new_id().unwrap(),
+ layout: Default::default(),
+ strokes: vec![
+ stroke(
+ &[
+ [300.0, 120.0],
+ [360.0, 120.0],
+ [360.0, 180.0],
+ [300.0, 180.0],
+ [300.0, 120.0],
+ ],
+ None,
+ ),
+ stroke(&[[300.0, 120.0], [360.0, 180.0]], Some(0x0000ff)),
+ ],
+ groups: Vec::new(),
+ }
+}
+
+fn page_in(bytes: &[u8], space: ExGuid) -> Page {
+ let store = Store::parse(bytes).unwrap();
+ assert!(store.checksum_mismatches.is_empty());
+ let index = RevisionIndex::parse(&store).unwrap();
+ Page::from_space(&Document::parse(&index).unwrap(), space).unwrap()
+}
+
+fn export(variable: &str, name: &str, written: &[u8]) {
+ if let Some(directory) = std::env::var_os(variable) {
+ let directory = std::path::PathBuf::from(directory);
+ std::fs::create_dir(&directory).unwrap();
+ std::fs::write(directory.join(name), written).unwrap();
+ let file_id = Store::parse(written).unwrap().header.file_id;
+ std::fs::write(
+ directory.join("Open Notebook.onetoc2"),
+ onestore::create_table_of_contents("Open Notebook.onetoc2", &[(name, file_id)])
+ .unwrap(),
+ )
+ .unwrap();
+ }
+}
+
+/// `ONESTORE_INK_EXPORT` names a new directory receiving the candidate for a cold reopen.
+#[test]
+fn an_ink_drawing_is_written_erased_stroke_by_stroke_and_removed() {
+ let source = onestore::create_section("ink.one", "Beside the drawing", "Author").unwrap();
+ let (space, before) = first_page(&source);
+ let mut after = before.clone();
+ let ink = drawing();
+ after.objects.push(PageObject::Ink(ink.clone()));
+ let written = PreparedEdit::page(&source, space, &after, AUTHOR).unwrap();
+ let stored = page_in(written.as_bytes(), space);
+ let mut expected = after.clone();
+ expected.title = stored.title.clone();
+ assert_eq!(stored, expected);
+ assert!(close(
+ drawings(&stored)[0].bounds().unwrap(),
+ [
+ snap(300.0),
+ snap(120.0),
+ snap(360.0) - snap(300.0),
+ snap(180.0) - snap(120.0)
+ ]
+ ));
+ assert_eq!(
+ PreparedEdit::page(written.as_bytes(), space, &stored, AUTHOR)
+ .unwrap()
+ .as_bytes(),
+ written.as_bytes()
+ );
+ export("ONESTORE_INK_EXPORT", "ink.one", written.as_bytes());
+ let mut erased = stored.clone();
+ for object in &mut erased.objects {
+ if let PageObject::Ink(ink) = object {
+ ink.strokes.remove(1);
+ ink.strokes
+ .push(stroke(&[[330.0, 100.0], [330.0, 200.0]], Some(0xff0000)));
+ }
+ }
+ let again = PreparedEdit::page(written.as_bytes(), space, &erased, AUTHOR).unwrap();
+ let stored = page_in(again.as_bytes(), space);
+ assert_eq!(stored, erased);
+ let mut removed = stored.clone();
+ removed.objects.retain(|object| object.id() != ink.id);
+ let last = PreparedEdit::page(again.as_bytes(), space, &removed, AUTHOR).unwrap();
+ let stored = page_in(last.as_bytes(), space);
+ assert!(drawings(&stored).is_empty());
+ assert_eq!(stored, removed);
+}
+
+/// `ONESTORE_INK_PARAGRAPH_EXPORT` names a new directory receiving the candidate for a cold
+/// reopen.
+#[test]
+fn handwriting_is_written_as_paragraph_content() {
+ use onestore::page::{PageParagraph, ParagraphContent};
+ let source = onestore::create_section("ink.one", "Above the handwriting", "Author").unwrap();
+ let (space, before) = first_page(&source);
+ let mut after = before.clone();
+ let outline = after
+ .objects
+ .iter_mut()
+ .find_map(|object| match object {
+ PageObject::Outline(outline) if !outline.title => Some(outline),
+ _ => None,
+ })
+ .unwrap();
+ let mut paragraph: PageParagraph = outline.paragraphs[0].clone();
+ paragraph.id = new_id().unwrap();
+ paragraph.parent = None;
+ paragraph.level = 1;
+ paragraph.lists.clear();
+ paragraph.tags.clear();
+ paragraph.style = None;
+ paragraph.collapsed = false;
+ paragraph.format = Default::default();
+ paragraph.content = ParagraphContent::Ink(drawing());
+ outline.paragraphs.push(paragraph);
+ let written = PreparedEdit::page(&source, space, &after, AUTHOR).unwrap();
+ let stored = page_in(written.as_bytes(), space);
+ let mut expected = after.clone();
+ expected.title = stored.title.clone();
+ assert_eq!(stored, expected);
+ export(
+ "ONESTORE_INK_PARAGRAPH_EXPORT",
+ "ink.one",
+ written.as_bytes(),
+ );
+}
+
+#[test]
+fn stored_strokes_keep_their_paths() {
+ let (space, page) = first_page(NATIVE);
+ let mut moved = page.clone();
+ for object in &mut moved.objects {
+ if let PageObject::Ink(ink) = object {
+ ink.strokes[0].points[0][0] += 1.0;
+ }
+ }
+ assert!(PreparedEdit::page(NATIVE, space, &moved, AUTHOR).is_err());
+}
diff --git a/tools/test_ink_edit.py b/tools/test_ink_edit.py
new file mode 100644
index 0000000000000000000000000000000000000000..eae34b3b762686cb155f20c8efd991c233cd1fa0
--- /dev/null
+++ b/tools/test_ink_edit.py
@@ -0,0 +1,41 @@
+from pathlib import Path
+import runpy
+import shutil
+from tempfile import TemporaryDirectory
+import unittest
+import xml.etree.ElementTree as ET
+
+from native_xml import ns
+
+ROOT = Path(__file__).resolve().parent.parent
+FIXTURE = ROOT / 'corpus/ink-edit'
+compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
+
+
+def native_page(variant):
+ with TemporaryDirectory() as temporary:
+ read = Path(temporary) / 'read'
+ shutil.copytree(FIXTURE / variant / 'cold/read', read)
+ compare(FIXTURE / variant / 'candidate', read)
+ page, = (ET.parse(path).getroot() for path in sorted((FIXTURE / variant / 'cold/read').glob('page-*.xml')))
+ return page
+
+
+class InkEditTest(unittest.TestCase):
+ def test_a_rust_drawing_renders_natively_at_its_stroke_extent(self):
+ page = native_page('drawing')
+ drawing, = page.findall('one:InkDrawing', ns)
+ position, size = drawing.find('one:Position', ns), drawing.find('one:Size', ns)
+ self.assertAlmostEqual(float(position.get('x')), 300.0, delta=0.03)
+ self.assertAlmostEqual(float(position.get('y')), 120.0, delta=0.03)
+ self.assertAlmostEqual(float(size.get('width')), 60.0, delta=0.06)
+ self.assertAlmostEqual(float(size.get('height')), 60.0, delta=0.06)
+ self.assertTrue((FIXTURE / 'drawing/cold/read/page-000.png').exists())
+
+ def test_rust_handwriting_renders_natively_as_an_ink_paragraph(self):
+ page = native_page('handwriting')
+ self.assertEqual(len(list(page.iter('{%s}InkParagraph' % ns['one']))) + len(list(page.iter('{%s}InkDrawing' % ns['one']))), 1)
+
+
+if __name__ == '__main__':
+ unittest.main()