| author | |
| committer | |
| log | ebed33c87eab2e83fb48269388f22950d118ea53 |
| tree | 5f2ed70bcdeff20ce0262a8523b62de7fd252a56 |
| parent | d0ec66f9b4d3023668fa156d8a0903b3b5b8a316 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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.139 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 | 28 | layout width, height, user flag and alternative text on the picture object. |
| 29 | 29 | `resized/cold` is its cold read with the size and description. |
| 30 | 30 | |
| 31 | `tools/test_picture_edit.py` checks all three without a VM. Regenerate the | |
| 32 | candidates with `ONESTORE_IMAGE_EXPORT` and `ONESTORE_IMAGE_RESIZE_EXPORT` | |
| 33 | set to new absolute directories while running the tests, then cold-open | |
| 34 | them with `tools/native_runner.py OUTPUT COLD --expected-pages 1 | |
| 35 | --collect-notebook`; regenerate `native-resize` with `--author | |
| 36 | tools/native/picture-edit.ps1` on `inserted/candidate`. | |
| 31 | `native-page-level` is OneNote 2010 placing a second picture directly on | |
| 32 | the page of `inserted/candidate` through the COM API | |
| 33 | (`tools/native/page-picture.ps1`): the picture object is a direct page | |
| 34 | child with a position, a user-set size and alternative text, otherwise | |
| 35 | stored like a paragraph picture. `page-level/candidate` is the writer doing | |
| 36 | the same on a fresh section (`a_page_level_picture_is_inserted_moved_and_removed`) | |
| 37 | and `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 | |
| 40 | candidates with `ONESTORE_IMAGE_EXPORT`, `ONESTORE_IMAGE_RESIZE_EXPORT` and | |
| 41 | `ONESTORE_PAGE_IMAGE_EXPORT` set to new absolute directories while running | |
| 42 | the 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 | |
| 3 | AAAAAElFTkSuQmCC | |
| 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 | |
| 3 | AAAAAElFTkSuQmCC | |
| 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 | |
| 5 | AAAAAElFTkSuQmCC | |
| 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 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost) | |
| 2 | Set-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 = '' | |
| 8 | try { | |
| 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 @@ |
| 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/picture-edit/native-page-level/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/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 | |
| 2 | AAAAAElFTkSuQmCC | |
| 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 | |
| 4 | AAAAAElFTkSuQmCC | |
| 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 | |
| 3 | AAAAAElFTkSuQmCC | |
| 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 @@ |
| 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/picture-edit/page-level/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/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 | 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, bullets, numbering, note tags, table rows and columns, pictures (insertion, size, description) and attachments, paragraph structure, | |
| 17 | paragraph 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, | |
| 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, 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 | 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/write.rs+206-95| ... | ... | @@ -28,6 +28,73 @@ fn payload_reference(guid: [u8; 16]) -> String { |
| 28 | 28 | format!("<ifndf>{}", id.split(',').next().unwrap()) |
| 29 | 29 | } |
| 30 | 30 | |
| 31 | fn 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 | ||
| 38 | fn 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. | |
| 53 | fn 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 | ||
| 31 | 98 | fn measurement_bytes(values: &[f32], header: usize) -> Result<Vec<u8>, Error> { |
| 32 | 99 | let count = u8::try_from(values.len()) |
| 33 | 100 | .map_err(|_| invalid("A measurement array exceeds the document range"))?; |
| ... | ... | @@ -298,10 +365,6 @@ impl Lowering<'_> { |
| 298 | 365 | page.objects |
| 299 | 366 | .iter() |
| 300 | 367 | .filter_map(|object| match object { |
| 301 | PageObject::Image(image) => Some(format!( | |
| 302 | "{:?} {:?} {:?} {:?}", | |
| 303 | image.id, image.layout, image.alt, image.background | |
| 304 | )), | |
| 305 | 368 | PageObject::Unsupported(unsupported) => Some(format!("{unsupported:?}")), |
| 306 | 369 | PageObject::Title(title) => Some(format!( |
| 307 | 370 | "{:?} {:?} {:?} {:?}", |
| ... | ... | @@ -310,7 +373,7 @@ impl Lowering<'_> { |
| 310 | 373 | title.layout, |
| 311 | 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 | 378 | .collect() |
| 316 | 379 | }; |
| ... | ... | @@ -320,7 +383,7 @@ impl Lowering<'_> { |
| 320 | 383 | after_fixed.sort(); |
| 321 | 384 | if before_fixed != after_fixed { |
| 322 | 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 | 389 | for (id, outline) in &new.outlines { |
| ... | ... | @@ -386,15 +449,9 @@ impl Lowering<'_> { |
| 386 | 449 | } |
| 387 | 450 | (ParagraphContent::Unsupported(a), ParagraphContent::Unsupported(b)) if a == b => {} |
| 388 | 451 | (ParagraphContent::Image(a), ParagraphContent::Image(b)) => { |
| 389 | if a.id != b.id | |
| 390 | || a.bytes != b.bytes | |
| 391 | || a.size != b.size | |
| 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 | )); | |
| 452 | picture_fixed_fields(a, b)?; | |
| 453 | if (a.layout.x, a.layout.y) != (b.layout.x, b.layout.y) { | |
| 454 | return Err(invalid("A paragraph picture has no position of its own")); | |
| 398 | 455 | } |
| 399 | 456 | } |
| 400 | 457 | (ParagraphContent::Attachment(a), ParagraphContent::Attachment(b)) => { |
| ... | ... | @@ -580,6 +637,7 @@ impl Lowering<'_> { |
| 580 | 637 | .iter() |
| 581 | 638 | .any(|object| matches!(object, PageObject::Title(title) if title.id == id)) |
| 582 | 639 | }; |
| 640 | self.edit_page_images(old, new)?; | |
| 583 | 641 | let survivors: Vec<ExGuid> = page_order |
| 584 | 642 | .iter() |
| 585 | 643 | .copied() |
| ... | ... | @@ -764,79 +822,138 @@ impl Lowering<'_> { |
| 764 | 822 | return Err(invalid("Paragraph content type cannot change")); |
| 765 | 823 | }; |
| 766 | 824 | if stored != image { |
| 767 | self.resize_image(stored, image)?; | |
| 825 | self.edit_image(stored, image)?; | |
| 768 | 826 | } |
| 769 | 827 | continue; |
| 770 | 828 | } |
| 771 | let Some(bytes) = &image.bytes else { | |
| 772 | return Err(invalid("A new picture needs its payload")); | |
| 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())); | |
| 829 | if image.layout.x.is_some() || image.layout.y.is_some() { | |
| 830 | return Err(invalid("A paragraph picture has no position of its own")); | |
| 802 | 831 | } |
| 803 | values.push((0x08001d85, Vec::new())); | |
| 804 | 832 | let holder = self.id(*paragraph_id); |
| 805 | let space = self.space; | |
| 806 | let payload: &[u8] = bytes; | |
| 807 | self.apply(|current| { | |
| 808 | crate::write::write_revision_with_payloads( | |
| 809 | current, | |
| 810 | space, | |
| 811 | &[(payload_guid, payload)], | |
| 812 | |raw| { | |
| 813 | let mut changed = BTreeMap::new(); | |
| 814 | let file = PropertyObject::file(file_id, &reference, extension)?; | |
| 815 | changed.insert(file_id, file); | |
| 816 | let mut picture = PropertyObject { | |
| 817 | jcid: 0x60011, | |
| 818 | bytes: crate::create::properties(&values)?, | |
| 819 | global_ids: std::sync::Arc::new(BTreeMap::from([(0, image_id.guid)])), | |
| 820 | }; | |
| 821 | picture.reference(image_id)?; | |
| 822 | let container = picture.reference(file_id)?; | |
| 823 | picture.set(&[(0x20001c3f, &container)])?; | |
| 824 | changed.insert(image_id, picture); | |
| 825 | let mut object = PropertyObject::from_object(&raw.objects[&holder])?; | |
| 826 | let content = object.reference(image_id)?; | |
| 827 | object.set(&[(0x24001c1f, &content), (0x14001d7a, &modified)])?; | |
| 828 | changed.insert(holder, object); | |
| 829 | Ok(changed) | |
| 830 | }, | |
| 831 | ) | |
| 832 | })?; | |
| 833 | self.insert_image(image, Some(holder))?; | |
| 834 | } | |
| 835 | Ok(()) | |
| 836 | } | |
| 837 | ||
| 838 | /// Page-level pictures are direct page children, as OneNote stores a picture placed | |
| 839 | /// outside any outline: new ones are appended for the placement pass to order, changed | |
| 840 | /// ones are moved, resized or described, and removed ones are deleted in the delete pass. | |
| 841 | fn edit_page_images(&mut self, old: &View<'_>, new: &View<'_>) -> Result<(), Error> { | |
| 842 | let stored: BTreeMap<ExGuid, &Image> = page_images(old.page).collect(); | |
| 843 | for (id, image) in page_images(new.page) { | |
| 844 | match stored.get(&id) { | |
| 845 | Some(previous) => { | |
| 846 | if *previous != image { | |
| 847 | self.edit_image(previous, image)?; | |
| 848 | } | |
| 849 | } | |
| 850 | None => { | |
| 851 | if image.layout.x.is_none() || image.layout.y.is_none() { | |
| 852 | return Err(invalid("A new page-level picture needs a position")); | |
| 853 | } | |
| 854 | self.insert_image(image, None)?; | |
| 855 | } | |
| 856 | } | |
| 833 | 857 | } |
| 834 | 858 | Ok(()) |
| 835 | 859 | } |
| 836 | 860 | |
| 837 | /// Writes a displayed size and description the way OneNote stores a resized picture: | |
| 838 | /// the layout width and height with the user flag, leaving the intrinsic size alone. | |
| 839 | fn resize_image(&mut self, stored: &Image, image: &Image) -> Result<(), Error> { | |
| 861 | /// Gives a new picture what OneNote stores for an inserted one: the payload embedded | |
| 862 | /// in the section's file-data store, a file-data object declaring it by identity and | |
| 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 | 957 | let mut values: Values = vec![( |
| 841 | 958 | 0x14001d7a, |
| 842 | 959 | crate::create::current_timestamps()? |
| ... | ... | @@ -844,22 +961,8 @@ impl Lowering<'_> { |
| 844 | 961 | .to_le_bytes() |
| 845 | 962 | .to_vec(), |
| 846 | 963 | )]; |
| 847 | let mut removed = Vec::new(); | |
| 848 | if image.layout != stored.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 | } | |
| 964 | let (layout, mut removed) = layout_values(&stored.layout, &image.layout)?; | |
| 965 | values.extend(layout); | |
| 863 | 966 | if image.alt != stored.alt { |
| 864 | 967 | match &image.alt { |
| 865 | 968 | Some(alt) => values.push((0x1c001e58, crate::create::string(alt))), |
| ... | ... | @@ -1165,6 +1268,14 @@ impl Lowering<'_> { |
| 1165 | 1268 | let space = self.space; |
| 1166 | 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 | 1279 | for id in old.outlines.keys() { |
| 1169 | 1280 | if removed_outline(*id) { |
| 1170 | 1281 | if old.title_outlines.contains(id) { |
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 { .. } => parent == *page, | |
| 118 | Kind::Outline { .. } | Kind::Image { .. } => parent == *page, | |
| 119 | 119 | Kind::Paragraph { .. } => matches!( |
| 120 | 120 | view.nodes[&parent].kind, |
| 121 | 121 | Kind::Outline { .. } |
crates/onestore/tests/page_images.rs+109| ... | ... | @@ -9,6 +9,8 @@ const NATIVE: &[u8] = |
| 9 | 9 | include_bytes!("../../../corpus/native/cold-05-05-image/notebook/synthetic.one"); |
| 10 | 10 | const NATIVE_RESIZE: &[u8] = |
| 11 | 11 | include_bytes!("../../../corpus/picture-edit/native-resize/notebook/pictures.one"); |
| 12 | const NATIVE_PAGE_LEVEL: &[u8] = | |
| 13 | include_bytes!("../../../corpus/picture-edit/native-page-level/notebook/pictures.one"); | |
| 12 | 14 | const AUTHOR: &str = "Picture author"; |
| 13 | 15 | /// A one-pixel PNG. |
| 14 | 16 | const PNG: &[u8] = &[ |
| ... | ... | @@ -236,6 +238,113 @@ fn a_rust_picture_resized_natively_reads_back_with_its_size_and_description() { |
| 236 | 238 | assert_eq!(page_in(written.as_bytes(), space), wider); |
| 237 | 239 | } |
| 238 | 240 | |
| 241 | fn 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] | |
| 252 | fn 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] | |
| 279 | fn 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 | 348 | #[test] |
| 240 | 349 | fn pictures_need_a_recognised_payload_and_stored_ones_stay_fixed() { |
| 241 | 350 | let source = onestore::create_section("pictures.one", "Text", "Author").unwrap(); |
tools/native/page-picture.ps1 created+56| ... | ... | @@ -0,0 +1,56 @@ |
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost) | |
| 2 | Set-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 = '' | |
| 8 | try { | |
| 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 | 53 | self.assertEqual((size.get('width'), size.get('height'), size.get('isSetByUser')), ('144.0', '108.0', 'true')) |
| 54 | 54 | self.assertEqual(image.get('alt'), 'Resized in Rust') |
| 55 | 55 | |
| 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 | ||
| 56 | 76 | |
| 57 | 77 | if __name__ == '__main__': |
| 58 | 78 | unittest.main() |