| author | |
| committer | |
| log | 237d4668ad21d8c6275da60e59bc4acb066fb853 |
| tree | 2c7c5a5f2c444fb6c0209e6631af6c549df97459 |
| parent | 7f30bf34648c9ac7a69e74956b04546b9894a380 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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.142 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 | |
| 6 | ink object with two strokes, a 60 pt square at (300, 120) and a red diagonal, | |
| 7 | stored the way OneNote 2010 stores a mouse drawing | |
| 8 | (`corpus/native-ink/cold-ui-ink`): an ink container listed as a page child, its | |
| 9 | data node listing stroke objects, each stroke's coordinates as ISF multi-byte | |
| 10 | first differences in HIMETRIC with its index, language, identity, time and | |
| 11 | half-inch origin, and one drawing-attribute object per distinct pen carrying | |
| 12 | the dimension table OneNote wrote. `drawing/cold` is a fresh OneNote 2010 | |
| 13 | read: the `InkDrawing` reports the stroke extent as its position and size (one | |
| 14 | HIMETRIC unit larger, as OneNote does) and `page-000.png` shows the square and | |
| 15 | diagonal rendered. | |
| 16 | ||
| 17 | `handwriting/candidate` is the same drawing held by a new paragraph of the body | |
| 18 | outline (`handwriting_is_written_as_paragraph_content`); `handwriting/cold` | |
| 19 | shows OneNote accepting it as an `InkDrawing` inside the outline element, the | |
| 20 | outline 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 | |
| 24 | directories 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 | |
| 3 | URK/7HHMzCKxQYkOuxMs7nqhWM9UiuaXxU+PBvi60uGbIsRmbacNslhJmh1OQDQGbBACEQZIEEUj | |
| 4 | RiMJSBBE/wFFI0YjBQgLZU9mZ2hkaRkUMggAgB4CIeLiQTMIAPAQAovF4kFkAxVGAEAKXAWH8gln | |
| 5 | jCj7wnvCe8IAh/B8h4Pke8I7wnvCQAUURgBAAAAJBAUURgAAAAASABEg7Ks5v90DzE+YKNcBjx6b | |
| 6 | FwoAESCA5B1tFkPdAQMVRgBACj9AIm9GCC70WAqACQEKTAIPUq7GcA5CJYzgBRRGAIAAAAkEBRRG | |
| 7 | AAAAABIAESBSpMe3m/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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 7 | } | |
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | |
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | |
| 10 | if ($CloneHost) { | |
| 11 | if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { | |
| 12 | throw 'The disposable clone hostname does not match this machine.' | |
| 13 | } | |
| 14 | New-Item "$key\Options\Paths" -Force | Out-Null | |
| 15 | New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null | |
| 16 | } elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or | |
| 17 | -not (Test-Path 'C:\one-tests\profile-original-cache')) { | |
| 18 | throw 'Park the personal OneNote profile before resetting the test cache.' | |
| 19 | } | |
| 20 | $cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' | |
| 21 | $parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) | |
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | |
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | |
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | |
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | |
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | |
| 27 | New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null |
corpus/ink-edit/drawing/cold/scripts/read.ps1 created+142| ... | ... | @@ -0,0 +1,142 @@ |
| 1 | param( | |
| 2 | [Parameter(Mandatory=$true)][string]$Root, | |
| 3 | [int]$ExpectedPages = -1, | |
| 4 | [switch]$UseCurrentCache, | |
| 5 | [switch]$Pdf, | |
| 6 | [switch]$KeepOpen, | |
| 7 | [string]$CloneHost = '' | |
| 8 | ) | |
| 9 | Set-StrictMode -Version Latest | |
| 10 | $ErrorActionPreference = 'Stop' | |
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 12 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 13 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 14 | } | |
| 15 | $notebook = Join-Path $root 'notebook' | |
| 16 | $output = Join-Path $root 'read' | |
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | |
| 18 | if ($UseCurrentCache) { | |
| 19 | if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { | |
| 20 | throw 'Park the personal OneNote profile before reading test notebooks.' | |
| 21 | } | |
| 22 | } else { | |
| 23 | & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost | |
| 24 | } | |
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | |
| 26 | $app = New-Object -ComObject OneNote.Application | |
| 27 | $notebookId = '' | |
| 28 | $failure = $null | |
| 29 | try { | |
| 30 | $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) | |
| 31 | $process = Get-Process ONENOTE | |
| 32 | @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; | |
| 33 | powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | | |
| 34 | ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 | |
| 35 | $sections = @() | |
| 36 | foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { | |
| 37 | $id = '' | |
| 38 | $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) | |
| 39 | $sections += $id | |
| 40 | } | |
| 41 | $deadline = [DateTime]::UtcNow.AddSeconds(300) | |
| 42 | $previous = '' | |
| 43 | $lastChange = '' | |
| 44 | $stableSince = [DateTime]::UtcNow | |
| 45 | $settled = $false | |
| 46 | do { | |
| 47 | $pages = @{} | |
| 48 | foreach ($section in $sections) { | |
| 49 | $hierarchy = '' | |
| 50 | $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) | |
| 51 | [xml]$xml = $hierarchy | |
| 52 | foreach ($node in $xml.SelectNodes('//*[@path]')) { | |
| 53 | if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { | |
| 54 | throw 'OneNote opened a section outside the copied notebook.' | |
| 55 | } | |
| 56 | } | |
| 57 | foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { | |
| 58 | $id = $node.GetAttribute('ID') | |
| 59 | $content = '' | |
| 60 | $app.GetPageContent($id, [ref]$content, 1, 1) | |
| 61 | $pages[$id] = $content | |
| 62 | } | |
| 63 | } | |
| 64 | $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) | |
| 65 | if ($signature -ne $previous) { | |
| 66 | $lastChange = $previous | |
| 67 | $previous = $signature | |
| 68 | $stableSince = [DateTime]::UtcNow | |
| 69 | } | |
| 70 | if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or | |
| 71 | ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and | |
| 72 | ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } | |
| 73 | Start-Sleep -Milliseconds 250 | |
| 74 | } while ([DateTime]::UtcNow -lt $deadline) | |
| 75 | if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { | |
| 76 | [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) | |
| 77 | $index = 0 | |
| 78 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 79 | [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 80 | $index++ | |
| 81 | } | |
| 82 | $hierarchy = '' | |
| 83 | $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) | |
| 84 | [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) | |
| 85 | throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." | |
| 86 | } | |
| 87 | $index = 0 | |
| 88 | $payloads = @() | |
| 89 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 90 | [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 91 | if ($Pdf) { | |
| 92 | $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) | |
| 93 | $app.NavigateTo($id, '', $false) | |
| 94 | $app.Publish($id, $pdfPath, 3, '') | |
| 95 | if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } | |
| 96 | } | |
| 97 | [xml]$page = $pages[$id] | |
| 98 | foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { | |
| 99 | $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) | |
| 100 | $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() | |
| 101 | [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) | |
| 102 | $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 | |
| 3 | URK/7HHMzCKxQYkOuxMs7nqhWM9UiuaXxU+PBvi60uGbIsRmbacNslhJmh1OQDQGbBACEQZIEEUj | |
| 4 | RiMJSBBE/wFFI0YjBQgLZU9mZ2hkaRkUMggAgB4CIeLiQTMIAPAQAovF4kFkAxVGAEAKXAWH8gln | |
| 5 | jCj7wnvCe8IAh/B8h4Pke8I7wnvCQAUURgBAAAAJBAUURgAAAAASABEgoX0Rk76oCkqcf4WNBm3y | |
| 6 | SgoAESCA5B1tFkPdAQMVRgBACj9AIm9GCC70WAqACQEKTAIPUq7GcA5CJYzgBRRGAIAAAAkEBRRG | |
| 7 | AAAAABIAESCehGefqfnSRo1JDkRPsA5FCgARIIDkHW0WQ90BAxVGAEAKP0Aib0YILvRYCoA=</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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | |
| 2 | Set-StrictMode -Version Latest | |
| 3 | $ErrorActionPreference = 'Stop' | |
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 7 | } | |
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | |
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | |
| 10 | if ($CloneHost) { | |
| 11 | if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { | |
| 12 | throw 'The disposable clone hostname does not match this machine.' | |
| 13 | } | |
| 14 | New-Item "$key\Options\Paths" -Force | Out-Null | |
| 15 | New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null | |
| 16 | } elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or | |
| 17 | -not (Test-Path 'C:\one-tests\profile-original-cache')) { | |
| 18 | throw 'Park the personal OneNote profile before resetting the test cache.' | |
| 19 | } | |
| 20 | $cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' | |
| 21 | $parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) | |
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | |
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | |
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | |
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | |
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | |
| 27 | New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null |
corpus/ink-edit/handwriting/cold/scripts/read.ps1 created+142| ... | ... | @@ -0,0 +1,142 @@ |
| 1 | param( | |
| 2 | [Parameter(Mandatory=$true)][string]$Root, | |
| 3 | [int]$ExpectedPages = -1, | |
| 4 | [switch]$UseCurrentCache, | |
| 5 | [switch]$Pdf, | |
| 6 | [switch]$KeepOpen, | |
| 7 | [string]$CloneHost = '' | |
| 8 | ) | |
| 9 | Set-StrictMode -Version Latest | |
| 10 | $ErrorActionPreference = 'Stop' | |
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | |
| 12 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | |
| 13 | throw 'Choose a run directly below C:\one-tests\runs.' | |
| 14 | } | |
| 15 | $notebook = Join-Path $root 'notebook' | |
| 16 | $output = Join-Path $root 'read' | |
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | |
| 18 | if ($UseCurrentCache) { | |
| 19 | if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { | |
| 20 | throw 'Park the personal OneNote profile before reading test notebooks.' | |
| 21 | } | |
| 22 | } else { | |
| 23 | & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost | |
| 24 | } | |
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | |
| 26 | $app = New-Object -ComObject OneNote.Application | |
| 27 | $notebookId = '' | |
| 28 | $failure = $null | |
| 29 | try { | |
| 30 | $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) | |
| 31 | $process = Get-Process ONENOTE | |
| 32 | @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; | |
| 33 | powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | | |
| 34 | ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 | |
| 35 | $sections = @() | |
| 36 | foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { | |
| 37 | $id = '' | |
| 38 | $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) | |
| 39 | $sections += $id | |
| 40 | } | |
| 41 | $deadline = [DateTime]::UtcNow.AddSeconds(300) | |
| 42 | $previous = '' | |
| 43 | $lastChange = '' | |
| 44 | $stableSince = [DateTime]::UtcNow | |
| 45 | $settled = $false | |
| 46 | do { | |
| 47 | $pages = @{} | |
| 48 | foreach ($section in $sections) { | |
| 49 | $hierarchy = '' | |
| 50 | $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) | |
| 51 | [xml]$xml = $hierarchy | |
| 52 | foreach ($node in $xml.SelectNodes('//*[@path]')) { | |
| 53 | if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { | |
| 54 | throw 'OneNote opened a section outside the copied notebook.' | |
| 55 | } | |
| 56 | } | |
| 57 | foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { | |
| 58 | $id = $node.GetAttribute('ID') | |
| 59 | $content = '' | |
| 60 | $app.GetPageContent($id, [ref]$content, 1, 1) | |
| 61 | $pages[$id] = $content | |
| 62 | } | |
| 63 | } | |
| 64 | $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) | |
| 65 | if ($signature -ne $previous) { | |
| 66 | $lastChange = $previous | |
| 67 | $previous = $signature | |
| 68 | $stableSince = [DateTime]::UtcNow | |
| 69 | } | |
| 70 | if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or | |
| 71 | ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and | |
| 72 | ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } | |
| 73 | Start-Sleep -Milliseconds 250 | |
| 74 | } while ([DateTime]::UtcNow -lt $deadline) | |
| 75 | if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { | |
| 76 | [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) | |
| 77 | $index = 0 | |
| 78 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 79 | [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 80 | $index++ | |
| 81 | } | |
| 82 | $hierarchy = '' | |
| 83 | $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) | |
| 84 | [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) | |
| 85 | throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." | |
| 86 | } | |
| 87 | $index = 0 | |
| 88 | $payloads = @() | |
| 89 | foreach ($id in @($pages.Keys | Sort-Object)) { | |
| 90 | [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) | |
| 91 | if ($Pdf) { | |
| 92 | $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) | |
| 93 | $app.NavigateTo($id, '', $false) | |
| 94 | $app.Publish($id, $pdfPath, 3, '') | |
| 95 | if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } | |
| 96 | } | |
| 97 | [xml]$page = $pages[$id] | |
| 98 | foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { | |
| 99 | $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) | |
| 100 | $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() | |
| 101 | [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) | |
| 102 | $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, |
| 14 | 14 | writes the difference into the working image and queues one |
| 15 | 15 | `Operation::Page(PageIntent { before, after, author })`; `before` is the page the |
| 16 | 16 | edit started from and is the precondition reconciliation checks. Text, styles, |
| 17 | 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, | |
| 17 | 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, | |
| 18 | 18 | outline layout, insertions and deletions are all differences between `before` and |
| 19 | 19 | `after`; the library never sees editor operations. |
| 20 | 20 |
crates/onestore/README.md+1-1| ... | ... | @@ -57,7 +57,7 @@ harness also accepts `--client-profile release`. |
| 57 | 57 | | `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules | |
| 58 | 58 | | `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs | |
| 59 | 59 | | `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 | | |
| 61 | 61 | | `TreeEdit`, `PreparedEdit::tree` | Move or delete a subtree on one page, normalize surviving containers, and replace an emptied table cell's paragraph atomically | |
| 62 | 62 | | `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter | |
| 63 | 63 | | `read_file` | Read a snapshot under whole-file exclusion | |
crates/onestore/src/page/ink.rs+79-4| ... | ... | @@ -7,7 +7,16 @@ use crate::{ |
| 7 | 7 | document::{Kind, Layout, Revision}, |
| 8 | 8 | }; |
| 9 | 9 | |
| 10 | const HIMETRIC_PER_POINT: f32 = 2540.0 / 72.0; | |
| 10 | /// Points from a HIMETRIC coordinate, the one conversion every stroke coordinate goes through. | |
| 11 | fn 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. | |
| 17 | pub fn snap(value: f32) -> f32 { | |
| 18 | points((value * 2540.0 / 72.0).round() as i64, 1.0) | |
| 19 | } | |
| 11 | 20 | const DIMENSION_X: [u8; 16] = [ |
| 12 | 21 | 0x8f, 0x6a, 0x8a, 0x59, 0xc0, 0x52, 0xa0, 0x4b, 0x93, 0xaf, 0xaf, 0x35, 0x74, 0x11, 0xa5, 0x61, |
| 13 | 22 | ]; |
| ... | ... | @@ -165,7 +174,7 @@ impl InkStroke { |
| 165 | 174 | .iter() |
| 166 | 175 | .map(move |delta| { |
| 167 | 176 | position += delta; |
| 168 | position as f32 * factor / HIMETRIC_PER_POINT | |
| 177 | points(position, factor) | |
| 169 | 178 | }) |
| 170 | 179 | }; |
| 171 | 180 | let points = coordinates(x, scale[0]) |
| ... | ... | @@ -175,8 +184,8 @@ impl InkStroke { |
| 175 | 184 | Ok(Self { |
| 176 | 185 | id, |
| 177 | 186 | 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, | |
| 180 | 189 | color: *color, |
| 181 | 190 | transparency: *transparency, |
| 182 | 191 | pen_tip: *pen_tip, |
| ... | ... | @@ -213,3 +222,69 @@ fn multi_byte(bytes: &[u8]) -> Option<Vec<i64>> { |
| 213 | 222 | } |
| 214 | 223 | (cursor == bytes.len()).then_some(values) |
| 215 | 224 | } |
| 225 | ||
| 226 | impl 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. | |
| 263 | pub(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)] | |
| 271 | mod 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 @@ |
| 1 | 1 | //! Publishes an edited page model by lowering the difference from the stored page onto |
| 2 | 2 | //! the typed writers, then squashing their transactions into one revision per space. |
| 3 | 3 | |
| 4 | use super::{Image, Outline, Page, PageObject, PageParagraph, ParagraphContent, Table}; | |
| 4 | use super::{Image, Ink, Outline, Page, PageObject, PageParagraph, ParagraphContent, Table}; | |
| 5 | 5 | use crate::{ |
| 6 | 6 | Error, ExGuid, Insertion, ObjectData, OutlineEdit, ParagraphJoin, ParagraphSplit, PropertySets, |
| 7 | 7 | RevisionIndex, Store, TextAttribute, TreeEdit, Value, |
| ... | ... | @@ -35,6 +35,155 @@ fn page_images(page: &Page) -> impl Iterator<Item = (ExGuid, &Image)> { |
| 35 | 35 | }) |
| 36 | 36 | } |
| 37 | 37 | |
| 38 | fn 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. | |
| 46 | fn 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. | |
| 61 | fn 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)] | |
| 110 | struct InkPen { | |
| 111 | width: u32, | |
| 112 | height: u32, | |
| 113 | color: Option<u32>, | |
| 114 | transparency: Option<u8>, | |
| 115 | pen_tip: Option<u8>, | |
| 116 | } | |
| 117 | ||
| 118 | impl 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 | ||
| 148 | fn 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 | ||
| 38 | 187 | fn picture_fixed_fields(stored: &Image, image: &Image) -> Result<(), Error> { |
| 39 | 188 | if stored.id != image.id |
| 40 | 189 | || stored.bytes != image.bytes |
| ... | ... | @@ -366,7 +515,6 @@ impl Lowering<'_> { |
| 366 | 515 | .iter() |
| 367 | 516 | .filter_map(|object| match object { |
| 368 | 517 | PageObject::Unsupported(unsupported) => Some(format!("{unsupported:?}")), |
| 369 | PageObject::Ink(ink) => Some(format!("{ink:?}")), | |
| 370 | 518 | PageObject::Title(title) => Some(format!( |
| 371 | 519 | "{:?} {:?} {:?} {:?}", |
| 372 | 520 | title.id, |
| ... | ... | @@ -374,7 +522,7 @@ impl Lowering<'_> { |
| 374 | 522 | title.layout, |
| 375 | 523 | title.outlines.iter().map(|o| o.id).collect::<Vec<_>>() |
| 376 | 524 | )), |
| 377 | PageObject::Outline(_) | PageObject::Image(_) => None, | |
| 525 | PageObject::Outline(_) | PageObject::Image(_) | PageObject::Ink(_) => None, | |
| 378 | 526 | }) |
| 379 | 527 | .collect() |
| 380 | 528 | }; |
| ... | ... | @@ -384,7 +532,7 @@ impl Lowering<'_> { |
| 384 | 532 | after_fixed.sort(); |
| 385 | 533 | if before_fixed != after_fixed { |
| 386 | 534 | 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", | |
| 388 | 536 | )); |
| 389 | 537 | } |
| 390 | 538 | for (id, outline) in &new.outlines { |
| ... | ... | @@ -447,6 +595,11 @@ impl Lowering<'_> { |
| 447 | 595 | } |
| 448 | 596 | } |
| 449 | 597 | (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 | } | |
| 450 | 603 | (ParagraphContent::Image(a), ParagraphContent::Image(b)) => { |
| 451 | 604 | picture_fixed_fields(a, b)?; |
| 452 | 605 | if (a.layout.x, a.layout.y) != (b.layout.x, b.layout.y) { |
| ... | ... | @@ -637,6 +790,7 @@ impl Lowering<'_> { |
| 637 | 790 | .any(|object| matches!(object, PageObject::Title(title) if title.id == id)) |
| 638 | 791 | }; |
| 639 | 792 | self.edit_page_images(old, new)?; |
| 793 | self.edit_page_ink(old, new)?; | |
| 640 | 794 | let survivors: Vec<ExGuid> = page_order |
| 641 | 795 | .iter() |
| 642 | 796 | .copied() |
| ... | ... | @@ -693,6 +847,7 @@ impl Lowering<'_> { |
| 693 | 847 | self.edit_table_structure(old, new)?; |
| 694 | 848 | self.edit_cells(old, new)?; |
| 695 | 849 | self.edit_images(old, new)?; |
| 850 | self.edit_ink_paragraphs(old, new)?; | |
| 696 | 851 | self.edit_attachments(old, new)?; |
| 697 | 852 | self.place_containers(old, new, placed, &deferred)?; |
| 698 | 853 | Ok(()) |
| ... | ... | @@ -860,6 +1015,179 @@ impl Lowering<'_> { |
| 860 | 1015 | Ok(()) |
| 861 | 1016 | } |
| 862 | 1017 | |
| 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 | ||
| 863 | 1191 | /// Gives a new picture what OneNote stores for an inserted one: the payload embedded |
| 864 | 1192 | /// in the section's file-data store, a file-data object declaring it by identity and |
| 865 | 1193 | /// extension, and a picture object that a paragraph holds as content or the page |
| ... | ... | @@ -928,18 +1256,7 @@ impl Lowering<'_> { |
| 928 | 1256 | } |
| 929 | 1257 | None => { |
| 930 | 1258 | 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)?; | |
| 943 | 1260 | children.extend(object.reference(image_id)?); |
| 944 | 1261 | object.set(&[(0x24001c20, &children), (0x14001d7a, &modified)])?; |
| 945 | 1262 | changed.insert(page, object); |
| ... | ... | @@ -1023,7 +1340,8 @@ impl Lowering<'_> { |
| 1023 | 1340 | } |
| 1024 | 1341 | ParagraphContent::Table(_) |
| 1025 | 1342 | | ParagraphContent::Image(_) |
| 1026 | | ParagraphContent::Attachment(_) => ("", None), | |
| 1343 | | ParagraphContent::Attachment(_) | |
| 1344 | | ParagraphContent::Ink(_) => ("", None), | |
| 1027 | 1345 | _ => { |
| 1028 | 1346 | return Err(invalid( |
| 1029 | 1347 | "New paragraphs contain plain text without fields or styles", |
| ... | ... | @@ -1334,8 +1652,14 @@ impl Lowering<'_> { |
| 1334 | 1652 | let space = self.space; |
| 1335 | 1653 | self.apply(|image| edit.apply(image, space))?; |
| 1336 | 1654 | } |
| 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 | { | |
| 1339 | 1663 | if !kept.contains(&id) { |
| 1340 | 1664 | let edit = TreeEdit::delete(id, self.author)?; |
| 1341 | 1665 | let space = self.space; |
crates/onestore/src/tree.rs+1-1| ... | ... | @@ -115,7 +115,7 @@ impl TreeEdit { |
| 115 | 115 | .get(1) |
| 116 | 116 | .ok_or_else(|| invalid("Select a paragraph or ordinary page outline"))?; |
| 117 | 117 | 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, | |
| 119 | 119 | Kind::Paragraph { .. } => matches!( |
| 120 | 120 | view.nodes[&parent].kind, |
| 121 | 121 | Kind::Outline { .. } |
crates/onestore/tests/page_ink.rs+160-2| ... | ... | @@ -1,9 +1,11 @@ |
| 1 | 1 | use onestore::{ |
| 2 | ExGuid, RevisionIndex, Store, | |
| 2 | ExGuid, PreparedEdit, RevisionIndex, Store, | |
| 3 | 3 | document::Document, |
| 4 | page::{Ink, Page, PageObject}, | |
| 4 | page::{Ink, InkStroke, Page, PageObject, text::new_id}, | |
| 5 | 5 | }; |
| 6 | 6 | |
| 7 | const AUTHOR: &str = "Ink author"; | |
| 8 | ||
| 7 | 9 | /// Two drawings made with the mouse in OneNote 2010 (`tools/native/ink.ahk`): a diamond and a |
| 8 | 10 | /// diagonal line, whose native read reports their positions and sizes. |
| 9 | 11 | const NATIVE: &[u8] = |
| ... | ... | @@ -74,3 +76,159 @@ fn native_ink_drawings_read_as_strokes_in_page_coordinates() { |
| 74 | 76 | })); |
| 75 | 77 | } |
| 76 | 78 | } |
| 79 | ||
| 80 | use onestore::page::ink::snap; | |
| 81 | ||
| 82 | fn 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 | ||
| 94 | fn 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 | ||
| 115 | fn 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 | ||
| 122 | fn 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] | |
| 139 | fn 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] | |
| 188 | fn 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] | |
| 225 | fn 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 @@ |
| 1 | from pathlib import Path | |
| 2 | import runpy | |
| 3 | import shutil | |
| 4 | from tempfile import TemporaryDirectory | |
| 5 | import unittest | |
| 6 | import xml.etree.ElementTree as ET | |
| 7 | ||
| 8 | from native_xml import ns | |
| 9 | ||
| 10 | ROOT = Path(__file__).resolve().parent.parent | |
| 11 | FIXTURE = ROOT / 'corpus/ink-edit' | |
| 12 | compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] | |
| 13 | ||
| 14 | ||
| 15 | def 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 | ||
| 24 | class 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 | ||
| 40 | if __name__ == '__main__': | |
| 41 | unittest.main() |