authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-12 14:47:24-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-12 17:00:32-07:00
logebed33c87eab2e83fb48269388f22950d118ea53
tree5f2ed70bcdeff20ce0262a8523b62de7fd252a56
parentd0ec66f9b4d3023668fa156d8a0903b3b5b8a316
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: place pictures directly on the page through the page model

A picture outside any outline is a page object in the model, and the writer handles it the way OneNote 2010 was observed to store one placed through the COM API on a Rust-written page (corpus/picture-edit/native-page-level, authored by tools/native/page-picture.ps1): a picture object that is a direct page child with a position, a layout size with the user flag and a description, otherwise identical to a paragraph picture. The model inserts such a picture (appended, then ordered with the other page children), moves, resizes and describes it, and removes it with the tree editor, which now accepts page-level pictures. Paragraph pictures refuse a position of their own. The picture creation and property lowering are shared between the two homes. corpus/picture-edit/page-level is the cold read of a Rust-placed picture with its position, size and description; tools/test_picture_edit.py checks it together with the native placement. Assisted-by: claude-fable-5.1

39 files changed, 922 insertions(+), 104 deletions(-)

corpus/picture-edit/README.md+15-6
...@@ -28,9 +28,18 @@ container under a native identity with its DPAPI blob and hash....@@ -28,9 +28,18 @@ container under a native identity with its DPAPI blob and hash.
28layout width, height, user flag and alternative text on the picture object.28layout width, height, user flag and alternative text on the picture object.
29`resized/cold` is its cold read with the size and description.29`resized/cold` is its cold read with the size and description.
3030
31`tools/test_picture_edit.py` checks all three without a VM. Regenerate the31`native-page-level` is OneNote 2010 placing a second picture directly on
32candidates with `ONESTORE_IMAGE_EXPORT` and `ONESTORE_IMAGE_RESIZE_EXPORT`32the page of `inserted/candidate` through the COM API
33set to new absolute directories while running the tests, then cold-open33(`tools/native/page-picture.ps1`): the picture object is a direct page
34them with `tools/native_runner.py OUTPUT COLD --expected-pages 134child with a position, a user-set size and alternative text, otherwise
35--collect-notebook`; regenerate `native-resize` with `--author35stored like a paragraph picture. `page-level/candidate` is the writer doing
36tools/native/picture-edit.ps1` on `inserted/candidate`.36the same on a fresh section (`a_page_level_picture_is_inserted_moved_and_removed`)
37and `page-level/cold` its cold read with position, size and description.
38
39`tools/test_picture_edit.py` checks all five without a VM. Regenerate the
40candidates with `ONESTORE_IMAGE_EXPORT`, `ONESTORE_IMAGE_RESIZE_EXPORT` and
41`ONESTORE_PAGE_IMAGE_EXPORT` set to new absolute directories while running
42the tests, then cold-open them with `tools/native_runner.py OUTPUT COLD
43--expected-pages 1 --collect-notebook`; regenerate `native-resize` and
44`native-page-level` with `--author tools/native/picture-edit.ps1` or
45`tools/native/page-picture.ps1` on `inserted/candidate`.
corpus/picture-edit/native-page-level/before.xml created+4
...@@ -0,0 +1,4 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{C830726B-F334-0D73-087C-17E759B6DFF9}{1}{B0}" name="Before the picture" dateTime="2026-09-12T20:07:15.000Z" lastModifiedTime="2026-09-12T21:33:26.000Z" pageLevel="1" lang="en-US"><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-12T20:07:15.000Z" objectID="{4F4E8F60-D6A7-040D-1D6D-432E2FF603F1}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="148.3100738525391" height="27.60543251037598"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-12T20:07:15.000Z" lastModifiedTime="2026-09-12T20:07:15.000Z" objectID="{4F4E8F60-D6A7-040D-1D6D-432E2FF603F1}{24}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:T><![CDATA[Before the picture]]></one:T></one:OE><one:OE author="Picture author" lastModifiedBy="Picture author" creationTime="2026-09-12T20:07:15.000Z" lastModifiedTime="2026-09-12T20:07:15.000Z" objectID="{52FD8B38-9555-06DB-33FF-A8BDA8D2288D}{0}{B0}" alignment="left"><one:Image format="png"><one:Data>iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w
3AAAAAElFTkSuQmCC
4</one:Data></one:Image></one:OE><one:OE author="Picture author" lastModifiedBy="Picture author" creationTime="2026-09-12T20:07:15.000Z" lastModifiedTime="2026-09-12T20:07:15.000Z" objectID="{22628005-1D11-0830-181E-AD256ABB41AD}{0}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:T><![CDATA[After the picture]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/picture-edit/native-page-level/commands.jsonl created+4
...@@ -0,0 +1,4 @@
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\\author.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-AEEC289C", "exit": 0, "stdout": "", "stderr": "", "error": null}
3{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-AEEC289C -ExpectedPages 1 -UseCurrentCache", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null}
4{"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/picture-edit/native-page-level/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-aeec289c", "hostname": "ONE-M6-AEEC289C"}
corpus/picture-edit/native-page-level/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/picture-edit/native-page-level/notebook/Open Notebook.onetoc2 differ
corpus/picture-edit/native-page-level/notebook/pictures.one created
Binary files /dev/null and b/corpus/picture-edit/native-page-level/notebook/pictures.one differ
corpus/picture-edit/native-page-level/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-AEEC289C",
5 "cold": false,
6 "onenote": "14.0.4763.1000"
7}
corpus/picture-edit/native-page-level/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="{3DBF5D10-E689-4A30-8DDB-43D3FF272447}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-12T21:34:57.000Z" color="#B49EDE"><one:Section name="pictures" ID="{DA72D44F-966F-063C-20F2-67CF723B9798}{1}{B0}" path="C:\one-tests\runs\capture\notebook\pictures.one" lastModifiedTime="2026-09-12T21:34:57.000Z" color="#8AA8E4"><one:Page ID="{5D0C2944-B3FC-0F42-35E3-3306047B4B90}{1}{B0}" name="Before the picture" dateTime="2026-09-12T20:07:15.000Z" lastModifiedTime="2026-09-12T21:34:57.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/picture-edit/native-page-level/read/page-000.xml created+6
...@@ -0,0 +1,6 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{5D0C2944-B3FC-0F42-35E3-3306047B4B90}{1}{B0}" name="Before the picture" dateTime="2026-09-12T20:07:15.000Z" lastModifiedTime="2026-09-12T21:34:57.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-12T21:33:31.000Z" objectID="{DA72D44F-966F-063C-20F2-67CF723B9798}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="148.3100738525391" height="27.60543251037598"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-12T21:33:31.000Z" lastModifiedTime="2026-09-12T21:33:31.000Z" objectID="{DA72D44F-966F-063C-20F2-67CF723B9798}{24}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Before the picture]]></one:T></one:OE><one:OE author="Picture author" lastModifiedBy="Picture author" creationTime="2026-09-12T20:07:15.000Z" lastModifiedTime="2026-09-12T21:33:31.000Z" objectID="{C7C1D017-D59D-04EA-0E60-8C5CF51FBCE4}{0}{B0}" alignment="left"><one:Image format="png" originalPageNumber="0"><one:Data>iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w
3AAAAAElFTkSuQmCC
4</one:Data></one:Image></one:OE><one:OE creationTime="2026-09-12T21:33:31.000Z" lastModifiedTime="2026-09-12T21:33:31.000Z" objectID="{B75EDB2A-5DD9-0A01-2581-89C43776D5C4}{0}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[After the picture]]></one:T></one:OE></one:OEChildren></one:Outline><one:Image format="png" alt="Page-level picture" originalPageNumber="0" lastModifiedTime="2026-09-12T21:33:31.000Z" objectID="{BDBD6FEA-4F71-491E-8ED3-D406223262CB}{29}{B0}"><one:Position x="360.0" y="240.0" z="1"/><one:Size width="96.00000762939453" height="71.99998474121093" isSetByUser="true"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w
5AAAAAElFTkSuQmCC
6</one:Data></one:Image></one:Page>
corpus/picture-edit/native-page-level/read/payloads.json created+3
...@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/picture-edit/native-page-level/run.json created+19
...@@ -0,0 +1,19 @@
1{
2 "notebook": "/Users/clo/dev/one/corpus/picture-edit/inserted/candidate",
3 "expected_pages": 1,
4 "author": "tools/native/page-picture.ps1",
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 "author.ps1": "c1a1f13ab4890f968dad99000cc24dd076e3676e9944c49fe3b0107d75273414",
16 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
17 "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
18 }
19}
corpus/picture-edit/native-page-level/scripts/author.ps1 created+56
...@@ -0,0 +1,56 @@
1param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost)
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost
5$namespace = 'http://schemas.microsoft.com/office/onenote/2010/onenote'
6$app = New-Object -ComObject OneNote.Application
7$notebookId = ''
8try {
9 $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0)
10 $sectionId = ''
11 $app.OpenHierarchy('pictures.one', $notebookId, [ref]$sectionId, 0)
12 $deadline = [DateTime]::UtcNow.AddSeconds(30)
13 $page = $null
14 do {
15 $hierarchy = ''
16 $app.GetHierarchy($sectionId, 4, [ref]$hierarchy, 1)
17 [xml]$tree = $hierarchy
18 $pages = @($tree.SelectNodes('//*[local-name()="Page"]'))
19 if ($pages.Count -eq 1) {
20 $content = ''
21 $app.GetPageContent($pages[0].GetAttribute('ID'), [ref]$content, 3, 1)
22 [xml]$page = $content
23 if (@($page.SelectNodes('//*[local-name()="Image"]')).Count -eq 1) { break }
24 }
25 Start-Sleep -Milliseconds 250
26 } while ([DateTime]::UtcNow -lt $deadline)
27 if (@($page.SelectNodes('//*[local-name()="Image"]')).Count -ne 1) { throw 'Expected one picture on the page.' }
28 [IO.File]::WriteAllText((Join-Path $Root 'before.xml'), $content, [Text.Encoding]::UTF8)
29 $data = $page.SelectSingleNode('//*[local-name()="Image"]/*[local-name()="Data"]').InnerText
30 $image = $page.CreateElement('one', 'Image', $namespace)
31 $image.SetAttribute('format', 'png')
32 $image.SetAttribute('alt', 'Page-level picture')
33 $position = $page.CreateElement('one', 'Position', $namespace)
34 $position.SetAttribute('x', '360')
35 $position.SetAttribute('y', '240')
36 [void]$image.AppendChild($position)
37 $size = $page.CreateElement('one', 'Size', $namespace)
38 $size.SetAttribute('width', '96')
39 $size.SetAttribute('height', '72')
40 $size.SetAttribute('isSetByUser', 'true')
41 [void]$image.AppendChild($size)
42 $payload = $page.CreateElement('one', 'Data', $namespace)
43 $payload.InnerText = $data
44 [void]$image.AppendChild($payload)
45 [void]$page.DocumentElement.AppendChild($image)
46 [IO.File]::WriteAllText((Join-Path $Root 'update.xml'), $page.OuterXml, [Text.Encoding]::UTF8)
47 $app.UpdatePageContent($page.OuterXml, [DateTime]::MinValue, 1, $false)
48 $app.SyncHierarchy($notebookId)
49 $app.CloseNotebook($notebookId, $false)
50 $notebookId = ''
51} finally {
52 if ($notebookId) { $app.CloseNotebook($notebookId, $false) }
53 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
54 [GC]::Collect()
55 [GC]::WaitForPendingFinalizers()
56}
corpus/picture-edit/native-page-level/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/picture-edit/native-page-level/scripts/read.ps1 created+142
...@@ -0,0 +1,142 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
103 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
104 sha256 = $hash; bytes = $bytes.Length }
105 }
106 $index++
107 }
108 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
109 $all = ''
110 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
111 [xml]$finalTree = $all
112 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
113 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
114 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
115 }
116 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
117 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
118} catch {
119 $failure = $_
120 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
121 throw
122} finally {
123 try {
124 try {
125 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
126 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
127 $app.CloseNotebook($notebookId, $false)
128 }
129 } catch {
130 if ($null -eq $failure) { throw }
131 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
132 }
133 } finally {
134 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
135 }
136 $app = $null
137 [GC]::Collect()
138 [GC]::WaitForPendingFinalizers()
139 if (-not $UseCurrentCache -and -not $CloneHost) {
140 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
141 }
142}
corpus/picture-edit/native-page-level/source.json created+14
...@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1604,
5 "sha256": "0eddd691b52d26bac222cf7e52add3c9909446647b452fb193f03d49e273426a",
6 "mtime_ns": 1789243969388188834
7 },
8 {
9 "path": "pictures.one",
10 "bytes": 7064,
11 "sha256": "3076880e02cf74eb85400852c133060ab8ed50383c6b9df8a07bde8389891a70",
12 "mtime_ns": 1789243969387957378
13 }
14]
corpus/picture-edit/native-page-level/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
corpus/picture-edit/native-page-level/update.xml created+5
...@@ -0,0 +1,5 @@
1<?xml version="1.0"?><one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{C830726B-F334-0D73-087C-17E759B6DFF9}{1}{B0}" name="Before the picture" dateTime="2026-09-12T20:07:15.000Z" lastModifiedTime="2026-09-12T21:33:26.000Z" pageLevel="1" lang="en-US"><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic /></one:PageSize><one:RuleLines visible="false" /></one:PageSettings><one:Outline author="Author" lastModifiedBy="Author" lastModifiedTime="2026-09-12T20:07:15.000Z" objectID="{4F4E8F60-D6A7-040D-1D6D-432E2FF603F1}{23}{B0}"><one:Position x="36.0" y="36.0" z="0" /><one:Size width="148.3100738525391" height="27.60543251037598" /><one:Indents><one:Indent level="0" indent="0.0" /></one:Indents><one:OEChildren><one:OE creationTime="2026-09-12T20:07:15.000Z" lastModifiedTime="2026-09-12T20:07:15.000Z" objectID="{4F4E8F60-D6A7-040D-1D6D-432E2FF603F1}{24}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:T><![CDATA[Before the picture]]></one:T></one:OE><one:OE author="Picture author" lastModifiedBy="Picture author" creationTime="2026-09-12T20:07:15.000Z" lastModifiedTime="2026-09-12T20:07:15.000Z" objectID="{52FD8B38-9555-06DB-33FF-A8BDA8D2288D}{0}{B0}" alignment="left"><one:Image format="png"><one:Data>iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w
2AAAAAElFTkSuQmCC
3</one:Data></one:Image></one:OE><one:OE author="Picture author" lastModifiedBy="Picture author" creationTime="2026-09-12T20:07:15.000Z" lastModifiedTime="2026-09-12T20:07:15.000Z" objectID="{22628005-1D11-0830-181E-AD256ABB41AD}{0}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:T><![CDATA[After the picture]]></one:T></one:OE></one:OEChildren></one:Outline><one:Image format="png" alt="Page-level picture"><one:Position x="360" y="240" /><one:Size width="96" height="72" isSetByUser="true" /><one:Data>iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w
4AAAAAElFTkSuQmCC
5</one:Data></one:Image></one:Page>
\ No newline at end of file
corpus/picture-edit/page-level/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/picture-edit/page-level/candidate/Open Notebook.onetoc2 differ
corpus/picture-edit/page-level/candidate/pictures.one created
Binary files /dev/null and b/corpus/picture-edit/page-level/candidate/pictures.one differ
corpus/picture-edit/page-level/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-3E57E5E3 -ExpectedPages 1", "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/picture-edit/page-level/cold/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-3e57e5e3", "hostname": "ONE-M6-3E57E5E3"}
corpus/picture-edit/page-level/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/picture-edit/page-level/cold/notebook/Open Notebook.onetoc2 differ
corpus/picture-edit/page-level/cold/notebook/pictures.one created
Binary files /dev/null and b/corpus/picture-edit/page-level/cold/notebook/pictures.one differ
corpus/picture-edit/page-level/cold/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-3E57E5E3",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/picture-edit/page-level/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="{7979B79B-55CA-4BD2-9516-7F2833A0B2D3}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-12T21:40:13.000Z" color="#B49EDE"><one:Section name="pictures" ID="{1037AF3E-61F8-0E78-3745-6713BE53BE60}{1}{B0}" path="C:\one-tests\runs\capture\notebook\pictures.one" lastModifiedTime="2026-09-12T21:40:13.000Z" color="#8AA8E4"><one:Page ID="{8EDE3CB0-E79B-0683-237C-820507E0B6A6}{1}{B0}" name="Beside the picture" dateTime="2026-09-12T21:38:24.000Z" lastModifiedTime="2026-09-12T21:40:13.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/picture-edit/page-level/cold/read/page-000.xml created+4
...@@ -0,0 +1,4 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{8EDE3CB0-E79B-0683-237C-820507E0B6A6}{1}{B0}" name="Beside the picture" dateTime="2026-09-12T21:38:24.000Z" lastModifiedTime="2026-09-12T21:40:13.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-12T21:38:24.000Z" objectID="{1037AF3E-61F8-0E78-3745-6713BE53BE60}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="147.9233703613281" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-12T21:38:24.000Z" lastModifiedTime="2026-09-12T21:38:24.000Z" objectID="{1037AF3E-61F8-0E78-3745-6713BE53BE60}{24}{B0}" alignment="left"><one:T><![CDATA[Beside the picture]]></one:T></one:OE></one:OEChildren></one:Outline><one:Image format="png" alt="Placed in Rust" lastModifiedTime="2026-09-12T21:38:24.000Z" objectID="{03E09DC9-97C4-05DC-32C4-49615A981E9C}{0}{B0}"><one:Position x="360.0" y="240.0" z="1"/><one:Size width="96.00000762939453" height="71.99998474121093" isSetByUser="true"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w
3AAAAAElFTkSuQmCC
4</one:Data></one:Image></one:Page>
corpus/picture-edit/page-level/cold/read/payloads.json created+3
...@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/picture-edit/page-level/cold/run.json created+18
...@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/m4/pagelevel",
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/picture-edit/page-level/cold/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/picture-edit/page-level/cold/scripts/read.ps1 created+142
...@@ -0,0 +1,142 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
103 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
104 sha256 = $hash; bytes = $bytes.Length }
105 }
106 $index++
107 }
108 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
109 $all = ''
110 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
111 [xml]$finalTree = $all
112 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
113 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
114 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
115 }
116 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
117 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
118} catch {
119 $failure = $_
120 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
121 throw
122} finally {
123 try {
124 try {
125 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
126 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
127 $app.CloseNotebook($notebookId, $false)
128 }
129 } catch {
130 if ($null -eq $failure) { throw }
131 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
132 }
133 } finally {
134 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
135 }
136 $app = $null
137 [GC]::Collect()
138 [GC]::WaitForPendingFinalizers()
139 if (-not $UseCurrentCache -and -not $CloneHost) {
140 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
141 }
142}
corpus/picture-edit/page-level/cold/source.json created+14
...@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1604,
5 "sha256": "3df6c788afbfe6ecda97d63cdbb3f42b269d61107e78dd0b3babaf55ae567389",
6 "mtime_ns": 1789249104766253246
7 },
8 {
9 "path": "pictures.one",
10 "bytes": 5656,
11 "sha256": "b6c0a2e604c038a2d0fa93a6096c3fecfa08f370f268ad010d7419ee87dd1f02",
12 "mtime_ns": 1789249104766006120
13 }
14]
corpus/picture-edit/page-level/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,7 +14,7 @@ diffs the supplied model against the page stored in the supplied local snapshot,
14writes the difference into the working image and queues one14writes the difference into the working image and queues one
15`Operation::Page(PageIntent { before, after, author })`; `before` is the page the15`Operation::Page(PageIntent { before, after, author })`; `before` is the page the
16edit started from and is the precondition reconciliation checks. Text, styles,16edit started from and is the precondition reconciliation checks. Text, styles,
17paragraph formatting, hyperlinks, bullets, numbering, note tags, table rows and columns, pictures (insertion, size, description) and attachments, paragraph structure,17paragraph formatting, hyperlinks, bullets, numbering, note tags, table rows and columns, pictures (insertion in paragraphs or on the page, position, size, description) and attachments, paragraph structure,
18outline layout, insertions and deletions are all differences between `before` and18outline layout, insertions and deletions are all differences between `before` and
19`after`; the library never sees editor operations.19`after`; the library never sees editor operations.
2020
crates/onestore/README.md+1-1
...@@ -57,7 +57,7 @@ harness also accepts `--client-profile release`....@@ -57,7 +57,7 @@ harness also accepts `--client-profile release`.
57| `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules |57| `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules |
58| `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs |58| `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs |
59| `OutlineEdit`, `PreparedEdit::outline` | Change ordinary outline position/width or a paragraph's saved expansion default, preserving identities and content |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, bullets and numbering, note tags, table rows and columns, inserted pictures and attachments, picture 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, bullets and numbering, note tags, table rows and columns, 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 |
61| `TreeEdit`, `PreparedEdit::tree` | Move or delete a subtree on one page, normalize surviving containers, and replace an emptied table cell's paragraph atomically |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| `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter |62| `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter |
63| `read_file` | Read a snapshot under whole-file exclusion |63| `read_file` | Read a snapshot under whole-file exclusion |
crates/onestore/src/page/write.rs+206-95
...@@ -28,6 +28,73 @@ fn payload_reference(guid: [u8; 16]) -> String {...@@ -28,6 +28,73 @@ fn payload_reference(guid: [u8; 16]) -> String {
28 format!("<ifndf>{}", id.split(',').next().unwrap())28 format!("<ifndf>{}", id.split(',').next().unwrap())
29}29}
3030
31fn page_images(page: &Page) -> impl Iterator<Item = (ExGuid, &Image)> {
32 page.objects.iter().filter_map(|object| match object {
33 PageObject::Image(image) => Some((image.id, image)),
34 _ => None,
35 })
36}
37
38fn picture_fixed_fields(stored: &Image, image: &Image) -> Result<(), Error> {
39 if stored.id != image.id
40 || stored.bytes != image.bytes
41 || stored.size != image.size
42 || stored.background != image.background
43 {
44 return Err(invalid(
45 "A stored picture keeps its payload, intrinsic size and background state",
46 ));
47 }
48 Ok(())
49}
50
51/// Position and displayed-size properties that take `layout` from `stored`, and the ones
52/// to remove.
53fn layout_values(
54 stored: &crate::document::Layout,
55 layout: &crate::document::Layout,
56) -> Result<(Values, Vec<u32>), Error> {
57 let mut values = Values::new();
58 let mut removed = Vec::new();
59 if (layout.x, layout.y) != (stored.x, stored.y) {
60 let (Some(x), Some(y)) = (layout.x, layout.y) else {
61 return Err(invalid("A picture position needs both coordinates"));
62 };
63 if !(x.is_finite() && y.is_finite()) {
64 return Err(invalid("A picture position must be finite"));
65 }
66 values.push((0x14001c14, (x / 36.0).to_le_bytes().to_vec()));
67 values.push((0x14001c15, (y / 36.0).to_le_bytes().to_vec()));
68 }
69 if (
70 layout.max_width,
71 layout.max_height,
72 layout.width_set_by_user,
73 ) != (
74 stored.max_width,
75 stored.max_height,
76 stored.width_set_by_user,
77 ) {
78 match (layout.max_width, layout.max_height) {
79 (Some(width), Some(height)) => {
80 if !(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0) {
81 return Err(invalid("Picture size must be positive"));
82 }
83 values.push((0x14001c1b, (width / 36.0).to_le_bytes().to_vec()));
84 values.push((0x14001c1c, (height / 36.0).to_le_bytes().to_vec()));
85 let user_set = u32::from(layout.width_set_by_user == Some(true));
86 values.push((0x08001cbd | (user_set << 31), Vec::new()));
87 }
88 (None, None) => removed.extend([0x14001c1b, 0x14001c1c, 0x08001cbd]),
89 _ => return Err(invalid("A picture size needs both dimensions")),
90 }
91 }
92 if layout.reserved_width != stored.reserved_width {
93 return Err(invalid("A picture has no reserved width"));
94 }
95 Ok((values, removed))
96}
97
31fn measurement_bytes(values: &[f32], header: usize) -> Result<Vec<u8>, Error> {98fn measurement_bytes(values: &[f32], header: usize) -> Result<Vec<u8>, Error> {
32 let count = u8::try_from(values.len())99 let count = u8::try_from(values.len())
33 .map_err(|_| invalid("A measurement array exceeds the document range"))?;100 .map_err(|_| invalid("A measurement array exceeds the document range"))?;
...@@ -298,10 +365,6 @@ impl Lowering<'_> {...@@ -298,10 +365,6 @@ impl Lowering<'_> {
298 page.objects365 page.objects
299 .iter()366 .iter()
300 .filter_map(|object| match object {367 .filter_map(|object| match object {
301 PageObject::Image(image) => Some(format!(
302 "{:?} {:?} {:?} {:?}",
303 image.id, image.layout, image.alt, image.background
304 )),
305 PageObject::Unsupported(unsupported) => Some(format!("{unsupported:?}")),368 PageObject::Unsupported(unsupported) => Some(format!("{unsupported:?}")),
306 PageObject::Title(title) => Some(format!(369 PageObject::Title(title) => Some(format!(
307 "{:?} {:?} {:?} {:?}",370 "{:?} {:?} {:?} {:?}",
...@@ -310,7 +373,7 @@ impl Lowering<'_> {...@@ -310,7 +373,7 @@ impl Lowering<'_> {
310 title.layout,373 title.layout,
311 title.outlines.iter().map(|o| o.id).collect::<Vec<_>>()374 title.outlines.iter().map(|o| o.id).collect::<Vec<_>>()
312 )),375 )),
313 PageObject::Outline(_) => None,376 PageObject::Outline(_) | PageObject::Image(_) => None,
314 })377 })
315 .collect()378 .collect()
316 };379 };
...@@ -320,7 +383,7 @@ impl Lowering<'_> {...@@ -320,7 +383,7 @@ impl Lowering<'_> {
320 after_fixed.sort();383 after_fixed.sort();
321 if before_fixed != after_fixed {384 if before_fixed != after_fixed {
322 return Err(invalid(385 return Err(invalid(
323 "Images, titles and unsupported objects cannot be edited through the page model",386 "Titles and unsupported objects cannot be edited through the page model",
324 ));387 ));
325 }388 }
326 for (id, outline) in &new.outlines {389 for (id, outline) in &new.outlines {
...@@ -386,15 +449,9 @@ impl Lowering<'_> {...@@ -386,15 +449,9 @@ impl Lowering<'_> {
386 }449 }
387 (ParagraphContent::Unsupported(a), ParagraphContent::Unsupported(b)) if a == b => {}450 (ParagraphContent::Unsupported(a), ParagraphContent::Unsupported(b)) if a == b => {}
388 (ParagraphContent::Image(a), ParagraphContent::Image(b)) => {451 (ParagraphContent::Image(a), ParagraphContent::Image(b)) => {
389 if a.id != b.id452 picture_fixed_fields(a, b)?;
390 || a.bytes != b.bytes453 if (a.layout.x, a.layout.y) != (b.layout.x, b.layout.y) {
391 || a.size != b.size454 return Err(invalid("A paragraph picture has no position of its own"));
392 || a.background != b.background
393 || (a.layout.x, a.layout.y) != (b.layout.x, b.layout.y)
394 {
395 return Err(invalid(
396 "A stored picture keeps its payload, intrinsic size, background state and position",
397 ));
398 }455 }
399 }456 }
400 (ParagraphContent::Attachment(a), ParagraphContent::Attachment(b)) => {457 (ParagraphContent::Attachment(a), ParagraphContent::Attachment(b)) => {
...@@ -580,6 +637,7 @@ impl Lowering<'_> {...@@ -580,6 +637,7 @@ impl Lowering<'_> {
580 .iter()637 .iter()
581 .any(|object| matches!(object, PageObject::Title(title) if title.id == id))638 .any(|object| matches!(object, PageObject::Title(title) if title.id == id))
582 };639 };
640 self.edit_page_images(old, new)?;
583 let survivors: Vec<ExGuid> = page_order641 let survivors: Vec<ExGuid> = page_order
584 .iter()642 .iter()
585 .copied()643 .copied()
...@@ -764,79 +822,138 @@ impl Lowering<'_> {...@@ -764,79 +822,138 @@ impl Lowering<'_> {
764 return Err(invalid("Paragraph content type cannot change"));822 return Err(invalid("Paragraph content type cannot change"));
765 };823 };
766 if stored != image {824 if stored != image {
767 self.resize_image(stored, image)?;825 self.edit_image(stored, image)?;
768 }826 }
769 continue;827 continue;
770 }828 }
771 let Some(bytes) = &image.bytes else {829 if image.layout.x.is_some() || image.layout.y.is_some() {
772 return Err(invalid("A new picture needs its payload"));830 return Err(invalid("A paragraph picture has no position of its own"));
773 };
774 let extension = match bytes.as_ref() {
775 [0x89, b'P', b'N', b'G', ..] => ".png",
776 [0xff, 0xd8, 0xff, ..] => ".jpg",
777 [b'G', b'I', b'F', b'8', ..] => ".gif",
778 [b'B', b'M', ..] => ".bmp",
779 _ => return Err(invalid("Choose a PNG, JPEG, GIF or BMP picture")),
780 };
781 let image_id = self.allocate(image.id)?;
782 let file_id = ExGuid {
783 guid: crate::write::fresh_guid()?,
784 n: 1,
785 };
786 let payload_guid = crate::write::fresh_guid()?;
787 let reference = payload_reference(payload_guid);
788 let modified = crate::create::current_timestamps()?.0.to_le_bytes();
789 let mut values: Values = vec![(0x14001d7a, modified.to_vec())];
790 if let Some([width, height]) = image.size {
791 if !(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0) {
792 return Err(invalid("Picture size must be positive"));
793 }
794 values.push((0x140034cd, (width / 36.0).to_le_bytes().to_vec()));
795 values.push((0x140034ce, (height / 36.0).to_le_bytes().to_vec()));
796 }
797 if let Some(alt) = &image.alt {
798 values.push((0x1c001e58, crate::create::string(alt)));
799 }
800 if image.background {
801 values.push((0x08001d13 | (1 << 31), Vec::new()));
802 }831 }
803 values.push((0x08001d85, Vec::new()));
804 let holder = self.id(*paragraph_id);832 let holder = self.id(*paragraph_id);
805 let space = self.space;833 self.insert_image(image, Some(holder))?;
806 let payload: &[u8] = bytes;834 }
807 self.apply(|current| {835 Ok(())
808 crate::write::write_revision_with_payloads(836 }
809 current,837
810 space,838 /// Page-level pictures are direct page children, as OneNote stores a picture placed
811 &[(payload_guid, payload)],839 /// outside any outline: new ones are appended for the placement pass to order, changed
812 |raw| {840 /// ones are moved, resized or described, and removed ones are deleted in the delete pass.
813 let mut changed = BTreeMap::new();841 fn edit_page_images(&mut self, old: &View<'_>, new: &View<'_>) -> Result<(), Error> {
814 let file = PropertyObject::file(file_id, &reference, extension)?;842 let stored: BTreeMap<ExGuid, &Image> = page_images(old.page).collect();
815 changed.insert(file_id, file);843 for (id, image) in page_images(new.page) {
816 let mut picture = PropertyObject {844 match stored.get(&id) {
817 jcid: 0x60011,845 Some(previous) => {
818 bytes: crate::create::properties(&values)?,846 if *previous != image {
819 global_ids: std::sync::Arc::new(BTreeMap::from([(0, image_id.guid)])),847 self.edit_image(previous, image)?;
820 };848 }
821 picture.reference(image_id)?;849 }
822 let container = picture.reference(file_id)?;850 None => {
823 picture.set(&[(0x20001c3f, &container)])?;851 if image.layout.x.is_none() || image.layout.y.is_none() {
824 changed.insert(image_id, picture);852 return Err(invalid("A new page-level picture needs a position"));
825 let mut object = PropertyObject::from_object(&raw.objects[&holder])?;853 }
826 let content = object.reference(image_id)?;854 self.insert_image(image, None)?;
827 object.set(&[(0x24001c1f, &content), (0x14001d7a, &modified)])?;855 }
828 changed.insert(holder, object);856 }
829 Ok(changed)
830 },
831 )
832 })?;
833 }857 }
834 Ok(())858 Ok(())
835 }859 }
836860
837 /// Writes a displayed size and description the way OneNote stores a resized picture:861 /// Gives a new picture what OneNote stores for an inserted one: the payload embedded
838 /// the layout width and height with the user flag, leaving the intrinsic size alone.862 /// in the section's file-data store, a file-data object declaring it by identity and
839 fn resize_image(&mut self, stored: &Image, image: &Image) -> Result<(), Error> {863 /// extension, and a picture object that a paragraph holds as content or the page
864 /// lists as a child.
865 fn insert_image(&mut self, image: &Image, holder: Option<ExGuid>) -> Result<(), Error> {
866 let Some(bytes) = &image.bytes else {
867 return Err(invalid("A new picture needs its payload"));
868 };
869 let extension = match bytes.as_ref() {
870 [0x89, b'P', b'N', b'G', ..] => ".png",
871 [0xff, 0xd8, 0xff, ..] => ".jpg",
872 [b'G', b'I', b'F', b'8', ..] => ".gif",
873 [b'B', b'M', ..] => ".bmp",
874 _ => return Err(invalid("Choose a PNG, JPEG, GIF or BMP picture")),
875 };
876 let image_id = self.allocate(image.id)?;
877 let file_id = ExGuid {
878 guid: crate::write::fresh_guid()?,
879 n: 1,
880 };
881 let payload_guid = crate::write::fresh_guid()?;
882 let reference = payload_reference(payload_guid);
883 let modified = crate::create::current_timestamps()?.0.to_le_bytes();
884 let mut values: Values = vec![(0x14001d7a, modified.to_vec())];
885 if let Some([width, height]) = image.size {
886 if !(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0) {
887 return Err(invalid("Picture size must be positive"));
888 }
889 values.push((0x140034cd, (width / 36.0).to_le_bytes().to_vec()));
890 values.push((0x140034ce, (height / 36.0).to_le_bytes().to_vec()));
891 }
892 values.extend(layout_values(&Default::default(), &image.layout)?.0);
893 if let Some(alt) = &image.alt {
894 values.push((0x1c001e58, crate::create::string(alt)));
895 }
896 if image.background {
897 values.push((0x08001d13 | (1 << 31), Vec::new()));
898 }
899 values.push((0x08001d85, Vec::new()));
900 let (space, page) = (self.space, self.page);
901 let payload: &[u8] = bytes;
902 self.apply(|current| {
903 crate::write::write_revision_with_payloads(
904 current,
905 space,
906 &[(payload_guid, payload)],
907 |raw| {
908 let mut changed = BTreeMap::new();
909 let file = PropertyObject::file(file_id, &reference, extension)?;
910 changed.insert(file_id, file);
911 let mut picture = PropertyObject {
912 jcid: 0x60011,
913 bytes: crate::create::properties(&values)?,
914 global_ids: std::sync::Arc::new(BTreeMap::from([(0, image_id.guid)])),
915 };
916 picture.reference(image_id)?;
917 let container = picture.reference(file_id)?;
918 picture.set(&[(0x20001c3f, &container)])?;
919 changed.insert(image_id, picture);
920 match holder {
921 Some(holder) => {
922 let mut object = PropertyObject::from_object(&raw.objects[&holder])?;
923 let content = object.reference(image_id)?;
924 object.set(&[(0x24001c1f, &content), (0x14001d7a, &modified)])?;
925 changed.insert(holder, object);
926 }
927 None => {
928 let mut object = PropertyObject::from_object(&raw.objects[&page])?;
929 let properties = crate::PropertySets::parse(&object.bytes)?;
930 let mut children = match properties.sets[0]
931 .iter()
932 .find(|p| p.id == 0x24001c20)
933 .map(|p| &p.value)
934 {
935 Some(crate::Value::References { compact_ids, .. }) => {
936 compact_ids.to_vec()
937 }
938 None => Vec::new(),
939 _ => return Err(invalid("The page has an invalid child list")),
940 };
941 children.extend(object.reference(image_id)?);
942 object.set(&[(0x24001c20, &children), (0x14001d7a, &modified)])?;
943 changed.insert(page, object);
944 }
945 }
946 Ok(changed)
947 },
948 )
949 })
950 }
951
952 /// Writes a moved, resized or described picture the way OneNote stores one: the
953 /// position, the layout width and height with the user flag and the description on
954 /// the picture object, leaving the intrinsic size alone.
955 fn edit_image(&mut self, stored: &Image, image: &Image) -> Result<(), Error> {
956 picture_fixed_fields(stored, image)?;
840 let mut values: Values = vec![(957 let mut values: Values = vec![(
841 0x14001d7a,958 0x14001d7a,
842 crate::create::current_timestamps()?959 crate::create::current_timestamps()?
...@@ -844,22 +961,8 @@ impl Lowering<'_> {...@@ -844,22 +961,8 @@ impl Lowering<'_> {
844 .to_le_bytes()961 .to_le_bytes()
845 .to_vec(),962 .to_vec(),
846 )];963 )];
847 let mut removed = Vec::new();964 let (layout, mut removed) = layout_values(&stored.layout, &image.layout)?;
848 if image.layout != stored.layout {965 values.extend(layout);
849 match (image.layout.max_width, image.layout.max_height) {
850 (Some(width), Some(height)) => {
851 if !(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0) {
852 return Err(invalid("Picture size must be positive"));
853 }
854 values.push((0x14001c1b, (width / 36.0).to_le_bytes().to_vec()));
855 values.push((0x14001c1c, (height / 36.0).to_le_bytes().to_vec()));
856 let user_set = u32::from(image.layout.width_set_by_user == Some(true));
857 values.push((0x08001cbd | (user_set << 31), Vec::new()));
858 }
859 (None, None) => removed.extend([0x14001c1b, 0x14001c1c, 0x08001cbd]),
860 _ => return Err(invalid("A picture size needs both dimensions")),
861 }
862 }
863 if image.alt != stored.alt {966 if image.alt != stored.alt {
864 match &image.alt {967 match &image.alt {
865 Some(alt) => values.push((0x1c001e58, crate::create::string(alt))),968 Some(alt) => values.push((0x1c001e58, crate::create::string(alt))),
...@@ -1165,6 +1268,14 @@ impl Lowering<'_> {...@@ -1165,6 +1268,14 @@ impl Lowering<'_> {
1165 let space = self.space;1268 let space = self.space;
1166 self.apply(|image| edit.apply(image, space))?;1269 self.apply(|image| edit.apply(image, space))?;
1167 }1270 }
1271 let kept: BTreeSet<ExGuid> = page_images(new.page).map(|(id, _)| id).collect();
1272 for (id, _) in page_images(old.page) {
1273 if !kept.contains(&id) {
1274 let edit = TreeEdit::delete(id, self.author)?;
1275 let space = self.space;
1276 self.apply(|image| edit.apply(image, space))?;
1277 }
1278 }
1168 for id in old.outlines.keys() {1279 for id in old.outlines.keys() {
1169 if removed_outline(*id) {1280 if removed_outline(*id) {
1170 if old.title_outlines.contains(id) {1281 if old.title_outlines.contains(id) {
crates/onestore/src/tree.rs+1-1
...@@ -115,7 +115,7 @@ impl TreeEdit {...@@ -115,7 +115,7 @@ impl TreeEdit {
115 .get(1)115 .get(1)
116 .ok_or_else(|| invalid("Select a paragraph or ordinary page outline"))?;116 .ok_or_else(|| invalid("Select a paragraph or ordinary page outline"))?;
117 let compatible = |parent: ExGuid| match view.nodes[&self.object].kind {117 let compatible = |parent: ExGuid| match view.nodes[&self.object].kind {
118 Kind::Outline { .. } => parent == *page,118 Kind::Outline { .. } | Kind::Image { .. } => parent == *page,
119 Kind::Paragraph { .. } => matches!(119 Kind::Paragraph { .. } => matches!(
120 view.nodes[&parent].kind,120 view.nodes[&parent].kind,
121 Kind::Outline { .. }121 Kind::Outline { .. }
crates/onestore/tests/page_images.rs+109
...@@ -9,6 +9,8 @@ const NATIVE: &[u8] =...@@ -9,6 +9,8 @@ const NATIVE: &[u8] =
9 include_bytes!("../../../corpus/native/cold-05-05-image/notebook/synthetic.one");9 include_bytes!("../../../corpus/native/cold-05-05-image/notebook/synthetic.one");
10const NATIVE_RESIZE: &[u8] =10const NATIVE_RESIZE: &[u8] =
11 include_bytes!("../../../corpus/picture-edit/native-resize/notebook/pictures.one");11 include_bytes!("../../../corpus/picture-edit/native-resize/notebook/pictures.one");
12const NATIVE_PAGE_LEVEL: &[u8] =
13 include_bytes!("../../../corpus/picture-edit/native-page-level/notebook/pictures.one");
12const AUTHOR: &str = "Picture author";14const AUTHOR: &str = "Picture author";
13/// A one-pixel PNG.15/// A one-pixel PNG.
14const PNG: &[u8] = &[16const PNG: &[u8] = &[
...@@ -236,6 +238,113 @@ fn a_rust_picture_resized_natively_reads_back_with_its_size_and_description() {...@@ -236,6 +238,113 @@ fn a_rust_picture_resized_natively_reads_back_with_its_size_and_description() {
236 assert_eq!(page_in(written.as_bytes(), space), wider);238 assert_eq!(page_in(written.as_bytes(), space), wider);
237}239}
238240
241fn page_pictures(page: &Page) -> Vec<&Image> {
242 page.objects
243 .iter()
244 .filter_map(|object| match object {
245 PageObject::Image(image) => Some(image),
246 _ => None,
247 })
248 .collect()
249}
250
251#[test]
252fn a_page_level_picture_placed_natively_reads_as_a_page_object() {
253 let (space, page) = first_page(NATIVE_PAGE_LEVEL);
254 assert_eq!(pictures(&page).len(), 1);
255 let found = page_pictures(&page);
256 let [picture] = found.as_slice() else {
257 panic!("{page:?}");
258 };
259 assert_eq!(picture.bytes.as_deref(), Some(PNG));
260 assert_eq!(
261 (picture.layout.x, picture.layout.y),
262 (Some(360.0), Some(240.0))
263 );
264 assert_eq!(picture.layout.width_set_by_user, Some(true));
265 assert_eq!(picture.alt.as_deref(), Some("Page-level picture"));
266 let mut moved = page.clone();
267 for object in &mut moved.objects {
268 if let PageObject::Image(image) = object {
269 image.layout.x = Some(72.0);
270 image.layout.y = Some(400.0);
271 }
272 }
273 let written = PreparedEdit::page(NATIVE_PAGE_LEVEL, space, &moved, AUTHOR).unwrap();
274 assert_eq!(page_in(written.as_bytes(), space), moved);
275}
276
277/// `ONESTORE_PAGE_IMAGE_EXPORT` names a new directory receiving the candidate for a cold reopen.
278#[test]
279fn a_page_level_picture_is_inserted_moved_and_removed() {
280 let source = onestore::create_section("pictures.one", "Beside the picture", "Author").unwrap();
281 let (space, before) = first_page(&source);
282 let mut after = before.clone();
283 let image = Image {
284 id: new_id().unwrap(),
285 layout: Layout {
286 x: Some(360.0),
287 y: Some(240.0),
288 max_width: Some(96.0),
289 width_set_by_user: Some(true),
290 max_height: Some(72.0),
291 reserved_width: None,
292 },
293 size: Some([0.75, 0.75]),
294 bytes: Some(Arc::from(PNG)),
295 alt: Some("Placed in Rust".into()),
296 background: false,
297 };
298 after.objects.push(PageObject::Image(image.clone()));
299 let written = PreparedEdit::page(&source, space, &after, AUTHOR).unwrap();
300 let stored = page_in(written.as_bytes(), space);
301 let mut expected = after.clone();
302 expected.title = stored.title.clone();
303 assert_eq!(stored, expected);
304 assert_eq!(page_pictures(&stored)[0].bytes.as_deref(), Some(PNG));
305 assert_eq!(
306 PreparedEdit::page(written.as_bytes(), space, &stored, AUTHOR)
307 .unwrap()
308 .as_bytes(),
309 written.as_bytes()
310 );
311 if let Some(directory) = std::env::var_os("ONESTORE_PAGE_IMAGE_EXPORT") {
312 let directory = std::path::PathBuf::from(directory);
313 std::fs::create_dir(&directory).unwrap();
314 std::fs::write(directory.join("pictures.one"), written.as_bytes()).unwrap();
315 let written_store = Store::parse(written.as_bytes()).unwrap();
316 std::fs::write(
317 directory.join("Open Notebook.onetoc2"),
318 onestore::create_table_of_contents(
319 "Open Notebook.onetoc2",
320 &[("pictures.one", written_store.header.file_id)],
321 )
322 .unwrap(),
323 )
324 .unwrap();
325 }
326 let mut moved = stored.clone();
327 for object in &mut moved.objects {
328 if let PageObject::Image(image) = object {
329 image.layout.x = Some(36.0);
330 image.alt = None;
331 }
332 }
333 let again = PreparedEdit::page(written.as_bytes(), space, &moved, AUTHOR).unwrap();
334 assert_eq!(page_in(again.as_bytes(), space), moved);
335 let mut removed = moved.clone();
336 removed.objects.retain(|object| object.id() != image.id);
337 let last = PreparedEdit::page(again.as_bytes(), space, &removed, AUTHOR).unwrap();
338 let stored = page_in(last.as_bytes(), space);
339 assert!(page_pictures(&stored).is_empty());
340 assert_eq!(stored, removed);
341 let mut unplaced = stored.clone();
342 let mut nowhere = image.clone();
343 nowhere.layout = Layout::default();
344 unplaced.objects.push(PageObject::Image(nowhere));
345 assert!(PreparedEdit::page(last.as_bytes(), space, &unplaced, AUTHOR).is_err());
346}
347
239#[test]348#[test]
240fn pictures_need_a_recognised_payload_and_stored_ones_stay_fixed() {349fn pictures_need_a_recognised_payload_and_stored_ones_stay_fixed() {
241 let source = onestore::create_section("pictures.one", "Text", "Author").unwrap();350 let source = onestore::create_section("pictures.one", "Text", "Author").unwrap();
tools/native/page-picture.ps1 created+56
...@@ -0,0 +1,56 @@
1param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost)
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost
5$namespace = 'http://schemas.microsoft.com/office/onenote/2010/onenote'
6$app = New-Object -ComObject OneNote.Application
7$notebookId = ''
8try {
9 $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0)
10 $sectionId = ''
11 $app.OpenHierarchy('pictures.one', $notebookId, [ref]$sectionId, 0)
12 $deadline = [DateTime]::UtcNow.AddSeconds(30)
13 $page = $null
14 do {
15 $hierarchy = ''
16 $app.GetHierarchy($sectionId, 4, [ref]$hierarchy, 1)
17 [xml]$tree = $hierarchy
18 $pages = @($tree.SelectNodes('//*[local-name()="Page"]'))
19 if ($pages.Count -eq 1) {
20 $content = ''
21 $app.GetPageContent($pages[0].GetAttribute('ID'), [ref]$content, 3, 1)
22 [xml]$page = $content
23 if (@($page.SelectNodes('//*[local-name()="Image"]')).Count -eq 1) { break }
24 }
25 Start-Sleep -Milliseconds 250
26 } while ([DateTime]::UtcNow -lt $deadline)
27 if (@($page.SelectNodes('//*[local-name()="Image"]')).Count -ne 1) { throw 'Expected one picture on the page.' }
28 [IO.File]::WriteAllText((Join-Path $Root 'before.xml'), $content, [Text.Encoding]::UTF8)
29 $data = $page.SelectSingleNode('//*[local-name()="Image"]/*[local-name()="Data"]').InnerText
30 $image = $page.CreateElement('one', 'Image', $namespace)
31 $image.SetAttribute('format', 'png')
32 $image.SetAttribute('alt', 'Page-level picture')
33 $position = $page.CreateElement('one', 'Position', $namespace)
34 $position.SetAttribute('x', '360')
35 $position.SetAttribute('y', '240')
36 [void]$image.AppendChild($position)
37 $size = $page.CreateElement('one', 'Size', $namespace)
38 $size.SetAttribute('width', '96')
39 $size.SetAttribute('height', '72')
40 $size.SetAttribute('isSetByUser', 'true')
41 [void]$image.AppendChild($size)
42 $payload = $page.CreateElement('one', 'Data', $namespace)
43 $payload.InnerText = $data
44 [void]$image.AppendChild($payload)
45 [void]$page.DocumentElement.AppendChild($image)
46 [IO.File]::WriteAllText((Join-Path $Root 'update.xml'), $page.OuterXml, [Text.Encoding]::UTF8)
47 $app.UpdatePageContent($page.OuterXml, [DateTime]::MinValue, 1, $false)
48 $app.SyncHierarchy($notebookId)
49 $app.CloseNotebook($notebookId, $false)
50 $notebookId = ''
51} finally {
52 if ($notebookId) { $app.CloseNotebook($notebookId, $false) }
53 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
54 [GC]::Collect()
55 [GC]::WaitForPendingFinalizers()
56}
tools/test_picture_edit.py+20
...@@ -53,6 +53,26 @@ class PictureEditTest(unittest.TestCase):...@@ -53,6 +53,26 @@ class PictureEditTest(unittest.TestCase):
53 self.assertEqual((size.get('width'), size.get('height'), size.get('isSetByUser')), ('144.0', '108.0', 'true'))53 self.assertEqual((size.get('width'), size.get('height'), size.get('isSetByUser')), ('144.0', '108.0', 'true'))
54 self.assertEqual(image.get('alt'), 'Resized in Rust')54 self.assertEqual(image.get('alt'), 'Resized in Rust')
5555
56 def test_onenote_places_a_picture_directly_on_a_page_with_a_rust_picture(self):
57 page = native_page(FIXTURE / 'native-page-level/notebook', FIXTURE / 'native-page-level')
58 images = list(page.iter('{%s}Image' % ns['one']))
59 self.assertEqual(len(images), 2)
60 placed = page.find('one:Image', ns)
61 self.assertEqual(placed.get('alt'), 'Page-level picture')
62 position = placed.find('one:Position', ns)
63 self.assertEqual((position.get('x'), position.get('y')), ('360.0', '240.0'))
64
65 def test_a_rust_page_level_picture_renders_with_its_position_size_and_description(self):
66 page = native_page(FIXTURE / 'page-level/candidate', FIXTURE / 'page-level/cold')
67 placed = page.find('one:Image', ns)
68 self.assertIsNotNone(placed)
69 position = placed.find('one:Position', ns)
70 self.assertEqual((position.get('x'), position.get('y')), ('360.0', '240.0'))
71 size = placed.find('one:Size', ns)
72 self.assertEqual((size.get('width'), size.get('height'), size.get('isSetByUser')), ('96.00000762939453', '71.99998474121093', 'true'))
73 self.assertEqual(placed.get('alt'), 'Placed in Rust')
74 self.assertEqual(texts(page), ['Beside the picture'])
75
5676
57if __name__ == '__main__':77if __name__ == '__main__':
58 unittest.main()78 unittest.main()