From ebed33c87eab2e83fb48269388f22950d118ea53 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Sat, 12 Sep 2026 14:47:24 -0700 Subject: [PATCH] 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 --- corpus/picture-edit/README.md | 21 +- .../picture-edit/native-page-level/before.xml | 4 + .../native-page-level/commands.jsonl | 4 + .../native-page-level/machine.json | 1 + .../notebook/Open Notebook.onetoc2 | Bin 0 -> 3764 bytes .../native-page-level/notebook/pictures.one | Bin 0 -> 17504 bytes .../native-page-level/read/environment.json | 7 + .../native-page-level/read/hierarchy.xml | 2 + .../native-page-level/read/page-000.xml | 6 + .../native-page-level/read/payloads.json | 3 + .../picture-edit/native-page-level/run.json | 19 ++ .../native-page-level/scripts/author.ps1 | 56 ++++ .../native-page-level/scripts/cold.ps1 | 27 ++ .../native-page-level/scripts/read.ps1 | 142 +++++++++ .../native-page-level/source.json | 14 + .../native-page-level/teardown.json | 1 + .../picture-edit/native-page-level/update.xml | 5 + .../candidate/Open Notebook.onetoc2 | Bin 0 -> 1604 bytes .../page-level/candidate/pictures.one | Bin 0 -> 5656 bytes .../page-level/cold/commands.jsonl | 3 + .../picture-edit/page-level/cold/machine.json | 1 + .../cold/notebook/Open Notebook.onetoc2 | Bin 0 -> 3764 bytes .../page-level/cold/notebook/pictures.one | Bin 0 -> 5656 bytes .../page-level/cold/read/environment.json | 7 + .../page-level/cold/read/hierarchy.xml | 2 + .../page-level/cold/read/page-000.xml | 4 + .../page-level/cold/read/payloads.json | 3 + corpus/picture-edit/page-level/cold/run.json | 18 ++ .../page-level/cold/scripts/cold.ps1 | 27 ++ .../page-level/cold/scripts/read.ps1 | 142 +++++++++ .../picture-edit/page-level/cold/source.json | 14 + .../page-level/cold/teardown.json | 1 + crates/notebook/README.md | 2 +- crates/onestore/README.md | 2 +- crates/onestore/src/page/write.rs | 301 ++++++++++++------ crates/onestore/src/tree.rs | 2 +- crates/onestore/tests/page_images.rs | 109 +++++++ tools/native/page-picture.ps1 | 56 ++++ tools/test_picture_edit.py | 20 ++ 39 files changed, 922 insertions(+), 104 deletions(-) create mode 100644 corpus/picture-edit/native-page-level/before.xml create mode 100644 corpus/picture-edit/native-page-level/commands.jsonl create mode 100644 corpus/picture-edit/native-page-level/machine.json create mode 100644 corpus/picture-edit/native-page-level/notebook/Open Notebook.onetoc2 create mode 100644 corpus/picture-edit/native-page-level/notebook/pictures.one create mode 100644 corpus/picture-edit/native-page-level/read/environment.json create mode 100644 corpus/picture-edit/native-page-level/read/hierarchy.xml create mode 100644 corpus/picture-edit/native-page-level/read/page-000.xml create mode 100644 corpus/picture-edit/native-page-level/read/payloads.json create mode 100644 corpus/picture-edit/native-page-level/run.json create mode 100644 corpus/picture-edit/native-page-level/scripts/author.ps1 create mode 100644 corpus/picture-edit/native-page-level/scripts/cold.ps1 create mode 100644 corpus/picture-edit/native-page-level/scripts/read.ps1 create mode 100644 corpus/picture-edit/native-page-level/source.json create mode 100644 corpus/picture-edit/native-page-level/teardown.json create mode 100644 corpus/picture-edit/native-page-level/update.xml create mode 100644 corpus/picture-edit/page-level/candidate/Open Notebook.onetoc2 create mode 100644 corpus/picture-edit/page-level/candidate/pictures.one create mode 100644 corpus/picture-edit/page-level/cold/commands.jsonl create mode 100644 corpus/picture-edit/page-level/cold/machine.json create mode 100644 corpus/picture-edit/page-level/cold/notebook/Open Notebook.onetoc2 create mode 100644 corpus/picture-edit/page-level/cold/notebook/pictures.one create mode 100644 corpus/picture-edit/page-level/cold/read/environment.json create mode 100644 corpus/picture-edit/page-level/cold/read/hierarchy.xml create mode 100644 corpus/picture-edit/page-level/cold/read/page-000.xml create mode 100644 corpus/picture-edit/page-level/cold/read/payloads.json create mode 100644 corpus/picture-edit/page-level/cold/run.json create mode 100644 corpus/picture-edit/page-level/cold/scripts/cold.ps1 create mode 100644 corpus/picture-edit/page-level/cold/scripts/read.ps1 create mode 100644 corpus/picture-edit/page-level/cold/source.json create mode 100644 corpus/picture-edit/page-level/cold/teardown.json create mode 100644 tools/native/page-picture.ps1 diff --git a/corpus/picture-edit/README.md b/corpus/picture-edit/README.md index dff677ee50977d55b8098d7c17b27ee03bdb848d..65049dd9c62515def388a5bdc746c406e5ae0c6e 100644 --- a/corpus/picture-edit/README.md +++ b/corpus/picture-edit/README.md @@ -28,9 +28,18 @@ container under a native identity with its DPAPI blob and hash. layout width, height, user flag and alternative text on the picture object. `resized/cold` is its cold read with the size and description. -`tools/test_picture_edit.py` checks all three without a VM. Regenerate the -candidates with `ONESTORE_IMAGE_EXPORT` and `ONESTORE_IMAGE_RESIZE_EXPORT` -set to new absolute directories while running the tests, then cold-open -them with `tools/native_runner.py OUTPUT COLD --expected-pages 1 ---collect-notebook`; regenerate `native-resize` with `--author -tools/native/picture-edit.ps1` on `inserted/candidate`. +`native-page-level` is OneNote 2010 placing a second picture directly on +the page of `inserted/candidate` through the COM API +(`tools/native/page-picture.ps1`): the picture object is a direct page +child with a position, a user-set size and alternative text, otherwise +stored like a paragraph picture. `page-level/candidate` is the writer doing +the same on a fresh section (`a_page_level_picture_is_inserted_moved_and_removed`) +and `page-level/cold` its cold read with position, size and description. + +`tools/test_picture_edit.py` checks all five without a VM. Regenerate the +candidates with `ONESTORE_IMAGE_EXPORT`, `ONESTORE_IMAGE_RESIZE_EXPORT` and +`ONESTORE_PAGE_IMAGE_EXPORT` set to new absolute directories while running +the tests, then cold-open them with `tools/native_runner.py OUTPUT COLD +--expected-pages 1 --collect-notebook`; regenerate `native-resize` and +`native-page-level` with `--author tools/native/picture-edit.ps1` or +`tools/native/page-picture.ps1` on `inserted/candidate`. diff --git a/corpus/picture-edit/native-page-level/before.xml b/corpus/picture-edit/native-page-level/before.xml new file mode 100644 index 0000000000000000000000000000000000000000..431c0d90b07c1cd06fa4a0447580414a3ec767f9 --- /dev/null +++ b/corpus/picture-edit/native-page-level/before.xml @@ -0,0 +1,4 @@ + +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w +AAAAAElFTkSuQmCC + diff --git a/corpus/picture-edit/native-page-level/commands.jsonl b/corpus/picture-edit/native-page-level/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..5d635b05ee20ad2ec36994fce9d183c069fed877 --- /dev/null +++ b/corpus/picture-edit/native-page-level/commands.jsonl @@ -0,0 +1,4 @@ +{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\author.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-AEEC289C", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-AEEC289C -ExpectedPages 1 -UseCurrentCache", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} diff --git a/corpus/picture-edit/native-page-level/machine.json b/corpus/picture-edit/native-page-level/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..82b03cffda7526178cfc483a77475de268f4cd8e --- /dev/null +++ b/corpus/picture-edit/native-page-level/machine.json @@ -0,0 +1 @@ +{"name": "m6-aeec289c", "hostname": "ONE-M6-AEEC289C"} diff --git a/corpus/picture-edit/native-page-level/notebook/Open Notebook.onetoc2 b/corpus/picture-edit/native-page-level/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..a7bcb6b4cf63c11f7984c722d32485a2ba522578 GIT binary patch literal 3764 zcmZ3e|KIuM`!b(-j|5(YE0x8s-QSbsU--|r_$D{+NrZTWxHDZQJEjqSo$}uH%j}=mVDuJ`A7b0|EXKm43$PdHV zRg8*`hQN>yfd&?aDY6VQJ7pL|vVrtIAT0p&)aI^BBDuFkDjQ_g4ptCrOeU}(^*VCZ0A0qJ632moS;7eG9a z4v=gBR8|5c3l;~-fN-P%(78~y4A3lwEzmLxWT5vhaXe zOu&I*LI%_-u<1ZCNJ$4N>(I>;1{nqf920>0pyeaT1K7-F!C@yh^Fi?h@)XFupfVg} z4~Pc2aoM)|DO*62AOLhLP#hcppg>~+$2%wufmCmqcMq(Hp#ggu`#JI2uIn80eY+a3 z)`jQtT?Uy>IE}%=668jZ4(S&S4Yt9$&u3V&drd8!RlF>+Bn>2vEe#nkPmTEJbe9n% z&%mH4(f~>?4Gkdfn}XKOU&@^wJ6`Unn8&{SwW(bnQlW9T0+Ycal-GGLk6kzmoAtfM3i-sN-pco4m&?MHD6oww;00-H?P@~E4 wO9p5xm@`U@hQMeDKtljItH8=WP`(!hVo-+DockdIs&teZ4S~@R7y%&w0N&m@_y7O^ literal 0 HcmV?d00001 diff --git a/corpus/picture-edit/native-page-level/notebook/pictures.one b/corpus/picture-edit/native-page-level/notebook/pictures.one new file mode 100644 index 0000000000000000000000000000000000000000..63960ef8c80421a99919771bf8b17b4ba8f454a8 GIT binary patch literal 17504 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 + diff --git a/corpus/picture-edit/native-page-level/read/page-000.xml b/corpus/picture-edit/native-page-level/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..121594d4b29b5442a36d7737ea810ad485a9b2c9 --- /dev/null +++ b/corpus/picture-edit/native-page-level/read/page-000.xml @@ -0,0 +1,6 @@ + +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w +AAAAAElFTkSuQmCC +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w +AAAAAElFTkSuQmCC + diff --git a/corpus/picture-edit/native-page-level/read/payloads.json b/corpus/picture-edit/native-page-level/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/picture-edit/native-page-level/read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/picture-edit/native-page-level/run.json b/corpus/picture-edit/native-page-level/run.json new file mode 100644 index 0000000000000000000000000000000000000000..96eb07c254c2b21a68d68f5e82a188f45ca54c8c --- /dev/null +++ b/corpus/picture-edit/native-page-level/run.json @@ -0,0 +1,19 @@ +{ + "notebook": "/Users/clo/dev/one/corpus/picture-edit/inserted/candidate", + "expected_pages": 1, + "author": "tools/native/page-picture.ps1", + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "author.ps1": "c1a1f13ab4890f968dad99000cc24dd076e3676e9944c49fe3b0107d75273414", + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" + } +} diff --git a/corpus/picture-edit/native-page-level/scripts/author.ps1 b/corpus/picture-edit/native-page-level/scripts/author.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..83021a116d4982bea1706f7ca290799c7443f512 --- /dev/null +++ b/corpus/picture-edit/native-page-level/scripts/author.ps1 @@ -0,0 +1,56 @@ +param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost +$namespace = 'http://schemas.microsoft.com/office/onenote/2010/onenote' +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +try { + $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0) + $sectionId = '' + $app.OpenHierarchy('pictures.one', $notebookId, [ref]$sectionId, 0) + $deadline = [DateTime]::UtcNow.AddSeconds(30) + $page = $null + do { + $hierarchy = '' + $app.GetHierarchy($sectionId, 4, [ref]$hierarchy, 1) + [xml]$tree = $hierarchy + $pages = @($tree.SelectNodes('//*[local-name()="Page"]')) + if ($pages.Count -eq 1) { + $content = '' + $app.GetPageContent($pages[0].GetAttribute('ID'), [ref]$content, 3, 1) + [xml]$page = $content + if (@($page.SelectNodes('//*[local-name()="Image"]')).Count -eq 1) { break } + } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (@($page.SelectNodes('//*[local-name()="Image"]')).Count -ne 1) { throw 'Expected one picture on the page.' } + [IO.File]::WriteAllText((Join-Path $Root 'before.xml'), $content, [Text.Encoding]::UTF8) + $data = $page.SelectSingleNode('//*[local-name()="Image"]/*[local-name()="Data"]').InnerText + $image = $page.CreateElement('one', 'Image', $namespace) + $image.SetAttribute('format', 'png') + $image.SetAttribute('alt', 'Page-level picture') + $position = $page.CreateElement('one', 'Position', $namespace) + $position.SetAttribute('x', '360') + $position.SetAttribute('y', '240') + [void]$image.AppendChild($position) + $size = $page.CreateElement('one', 'Size', $namespace) + $size.SetAttribute('width', '96') + $size.SetAttribute('height', '72') + $size.SetAttribute('isSetByUser', 'true') + [void]$image.AppendChild($size) + $payload = $page.CreateElement('one', 'Data', $namespace) + $payload.InnerText = $data + [void]$image.AppendChild($payload) + [void]$page.DocumentElement.AppendChild($image) + [IO.File]::WriteAllText((Join-Path $Root 'update.xml'), $page.OuterXml, [Text.Encoding]::UTF8) + $app.UpdatePageContent($page.OuterXml, [DateTime]::MinValue, 1, $false) + $app.SyncHierarchy($notebookId) + $app.CloseNotebook($notebookId, $false) + $notebookId = '' +} finally { + if ($notebookId) { $app.CloseNotebook($notebookId, $false) } + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + [GC]::Collect() + [GC]::WaitForPendingFinalizers() +} diff --git a/corpus/picture-edit/native-page-level/scripts/cold.ps1 b/corpus/picture-edit/native-page-level/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/picture-edit/native-page-level/scripts/cold.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } +$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' +if ($CloneHost) { + if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { + throw 'The disposable clone hostname does not match this machine.' + } + New-Item "$key\Options\Paths" -Force | Out-Null + New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null +} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or + -not (Test-Path 'C:\one-tests\profile-original-cache')) { + throw 'Park the personal OneNote profile before resetting the test cache.' +} +$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' +$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) +if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } +New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null +if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } +if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } +New-Item "$key\OpenNotebooks" | Out-Null +New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null diff --git a/corpus/picture-edit/native-page-level/scripts/read.ps1 b/corpus/picture-edit/native-page-level/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b --- /dev/null +++ b/corpus/picture-edit/native-page-level/scripts/read.ps1 @@ -0,0 +1,142 @@ +param( + [Parameter(Mandatory=$true)][string]$Root, + [int]$ExpectedPages = -1, + [switch]$UseCurrentCache, + [switch]$Pdf, + [switch]$KeepOpen, + [string]$CloneHost = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +$notebook = Join-Path $root 'notebook' +$output = Join-Path $root 'read' +if (Test-Path $output) { throw 'Choose a new read destination.' } +if ($UseCurrentCache) { + if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { + throw 'Park the personal OneNote profile before reading test notebooks.' + } +} else { + & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost +} +New-Item -ItemType Directory -Path $output | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +$failure = $null +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $process = Get-Process ONENOTE + @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; + powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | + ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 + $sections = @() + foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { + $id = '' + $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) + $sections += $id + } + $deadline = [DateTime]::UtcNow.AddSeconds(300) + $previous = '' + $lastChange = '' + $stableSince = [DateTime]::UtcNow + $settled = $false + do { + $pages = @{} + foreach ($section in $sections) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$xml = $hierarchy + foreach ($node in $xml.SelectNodes('//*[@path]')) { + if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { + throw 'OneNote opened a section outside the copied notebook.' + } + } + foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { + $id = $node.GetAttribute('ID') + $content = '' + $app.GetPageContent($id, [ref]$content, 1, 1) + $pages[$id] = $content + } + } + $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) + if ($signature -ne $previous) { + $lastChange = $previous + $previous = $signature + $stableSince = [DateTime]::UtcNow + } + if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or + ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and + ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { + [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) + $index = 0 + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + $index++ + } + $hierarchy = '' + $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) + throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." + } + $index = 0 + $payloads = @() + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + if ($Pdf) { + $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) + $app.NavigateTo($id, '', $false) + $app.Publish($id, $pdfPath, 3, '') + if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } + } + [xml]$page = $pages[$id] + foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { + $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) + $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID'); + kind = $file.LocalName; name = $file.GetAttribute('preferredName'); + sha256 = $hash; bytes = $bytes.Length } + } + $index++ + } + [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) + $all = '' + $app.GetHierarchy($notebookId, 4, [ref]$all, 1) + [xml]$finalTree = $all + $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) + if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { + throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' + } + [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) + Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." +} catch { + $failure = $_ + [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + throw +} finally { + try { + try { + if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } + if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { + $app.CloseNotebook($notebookId, $false) + } + } catch { + if ($null -eq $failure) { throw } + [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + } + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + if (-not $UseCurrentCache -and -not $CloneHost) { + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + } +} diff --git a/corpus/picture-edit/native-page-level/source.json b/corpus/picture-edit/native-page-level/source.json new file mode 100644 index 0000000000000000000000000000000000000000..0613aac0d637eacd23e38a986eb8d4aed828b657 --- /dev/null +++ b/corpus/picture-edit/native-page-level/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 1604, + "sha256": "0eddd691b52d26bac222cf7e52add3c9909446647b452fb193f03d49e273426a", + "mtime_ns": 1789243969388188834 + }, + { + "path": "pictures.one", + "bytes": 7064, + "sha256": "3076880e02cf74eb85400852c133060ab8ed50383c6b9df8a07bde8389891a70", + "mtime_ns": 1789243969387957378 + } +] diff --git a/corpus/picture-edit/native-page-level/teardown.json b/corpus/picture-edit/native-page-level/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/picture-edit/native-page-level/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/picture-edit/native-page-level/update.xml b/corpus/picture-edit/native-page-level/update.xml new file mode 100644 index 0000000000000000000000000000000000000000..4611a85ede77d365c50791dcffd90d140145fcba --- /dev/null +++ b/corpus/picture-edit/native-page-level/update.xml @@ -0,0 +1,5 @@ +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w +AAAAAElFTkSuQmCC +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w +AAAAAElFTkSuQmCC + \ No newline at end of file diff --git a/corpus/picture-edit/page-level/candidate/Open Notebook.onetoc2 b/corpus/picture-edit/page-level/candidate/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..52d19e53c867b3228411ceec37a5747fb72aea4d GIT binary patch literal 1604 zcmZ3e|KIuM`!b(-j|5(YE0x77@kn|~T>0U=z1Qd|_^gi2Zl$edj;VX75AYUsKD&VCqNd(GVEHA<)3WFh!O@ zW~U5;NH&n(2c!jn!Lcjz?RxDK(;a&Me!G~yxupn{i==?KfFYA1nW2QCl%a?rm7$nH zk0GBS56EI*0Of6su5teo@SW!!XF^iVg<|3W~8Aix8347vkBh6y+@Ovr#*1vVWh z1~Ll-K>EP&|P=1#&MF5QFRi(I7W2+g3jX GSq}iM@Yc2f literal 0 HcmV?d00001 diff --git a/corpus/picture-edit/page-level/candidate/pictures.one b/corpus/picture-edit/page-level/candidate/pictures.one new file mode 100644 index 0000000000000000000000000000000000000000..b656ae835d49ab68c65a0aa4629b82385df4ffd0 GIT binary patch literal 5656 zcmeHKdr*{B6hAA3%R@nVgt6#q4A}!hQ9`4D6%3R$@>Q5qnxa^a5|AQ}_C=QXpwLRQ zF>4wK!Ui)mj7IT2RAbVVr4uHpWiQH1R5Z2UZ@=$@W&Jc~`m2dEbM}7sp2t1++;e~T z?(xwR=Py54Zm-%nCcojLTsw(bMx-=8Z!YBvi_mi9ds-}&C^c#+z){RRiMeL6nA z+-z%oeDc|}=MTLdxwa;{K#!sN(J{@7@A)8QU7Y*V(M_c%I$Cx1Iy3P9%YaEl;SqC_ zD2rG!EfQ?CNSGf+j{e*d85rn!-zybe3O=5&>yi|G>3P|9*aQoe6gMdhm4Ib~Bw(9G z0{UZ=ytLxDra(=qxBH$FDtFL#>DGa_=I@UlR<~{X2zy?c^$Opz`}EnCjRoUNsZH86 zNtCI-1{J<|dT8Z|6vk>oD+I)y0faJoqwpb3n1DXB zXNF}L3b(+u5aUSb%gEa3!d-zom+9;2!9h?!wkWRg(gCS=y(k$uAq)4Wb+vCg5*ar; zsBx$+Q#IOu)$#XAq~ujCrUnR&GPNjMiqyE&$aEhUny0-y!5|uDgx-y0ObAic!sT9b zy*;5t^1)sht=BI>O0sTHbxx{{p+7{T5avzN2ld$@8;XaccK<~E>h*XTb;(+0^|+Po zh#!@AF|n-wGZ$ixyfZW$V>Zc@T$v$L(OM->vK(zTMy8NwFq`y zT?#M<&&pGwVu=H%Tfcsgn%%*P@IkM$;YsBBXLQV^1jEMM=HJaCK0UFv0GzQva0u7} z*5@;sq28VlB_5dqrzgI)rdK@9o+!PbaFfO!4!_JH{pN>?bspFgYiaYx4?0vrV56?H zALjS(En$}|;z#lIuZV$ps+Fmjgt@vhKTsh7PwvLLyfBwH=Ajk$ZX)@LkUMuw4^oLl6j# za>j?Kt{4GL0yAE3XUh^zm<|C;$;VmPqzQ>hoVS~^Fqm5nq9r%AyDr?bNs6*Gp%nt| zwn=dIct|6nf)0d#+EV1(I_S+?;zJiuxkyuvf}GoIr!w>P&6$zgGFmn@KfH0rTkA3t z7ZT8%QNtC_m=_Z9%oS(pk3>8h^UmJIRWZ3~)$^ZU_e;ia-nvZHX#Z8miDz515K%T_ zw6j;kv)4!E?li(IMoRBSGHzfyo}Kemm$8pc+U3LAa$Oe-k+f6kSV^wML%DPDxYi4O z7}bj(#y!CdfhCS|b7Y+7mx#dK;kdpN%nPu~aX`2;+_~n#_Ml)IlC;}S&m+$Yi8!z! z*6;p;n#z=wFa3D)vtf&e)7BXWy!?IgN3Ly1t^TXL$t~kxG~WZcKblx*G6cxWHUb<6 zyqPAma7#>+du2XQeUJQ1J>$~MRv$Vap2rDG!vG|Bz+QCy5 z39Ba#?0d=d#{kzny4G13Y^dW}mmFu5^Ovsqr1jU+!^$7&`Qe$I17#fN9)IQTeb&pj zDWU4rw~tR9zu}nvK6|qpgrF>N2NZR@vW)YE`4zr7y&d=5KD zp<<4=h?RHR`pU{UvWPWt+_(b?>nm?dz{Cl?@so}lXfoagnWzOTs$v#?N8^sq#VxOr zUWY_p67x6Hy!oe3=h)IJ8$VBe(q6X{VRfiOqkK@uwLbOyT`f-y+V^jGnjU+#1@O zwaDvXZ&YpnWS^oT^?R^4%J#MF)we4Q4e{zk(6OzV;XRT|cNg2e3)a1xOhp%3l0NFb zZeBML?YpI>B#lPP)R@dH_L8|{zD47cnv^)!{PmP``?mwoP?^Dpiy5t?CXY(0vP~-F zH>h9vg(v6vdXMA z+|~W?{4lr@+)uA+eo|-zGO{C{@;V|O5Vu*^W-#l8NoQR2N56z(es8R~Q(q2xoz$6u O&J6rlGT@8>jQMZMBj}F+ literal 0 HcmV?d00001 diff --git a/corpus/picture-edit/page-level/cold/commands.jsonl b/corpus/picture-edit/page-level/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..73b9bd3014f117c57e27064b4131c0747326f7c2 --- /dev/null +++ b/corpus/picture-edit/page-level/cold/commands.jsonl @@ -0,0 +1,3 @@ +{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-3E57E5E3 -ExpectedPages 1", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null} +{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null} diff --git a/corpus/picture-edit/page-level/cold/machine.json b/corpus/picture-edit/page-level/cold/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..c8cb9f4c9ef121e59a02fbd224d6cdbf81a47347 --- /dev/null +++ b/corpus/picture-edit/page-level/cold/machine.json @@ -0,0 +1 @@ +{"name": "m6-3e57e5e3", "hostname": "ONE-M6-3E57E5E3"} diff --git a/corpus/picture-edit/page-level/cold/notebook/Open Notebook.onetoc2 b/corpus/picture-edit/page-level/cold/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..b9e7f4815ce3a65ccfb1f0e432e3d5ae94af1a2b GIT binary patch literal 3764 zcmZ3e|KIuM`!b(-j|5(YE0x77@kn|~T>0U=z1Qql_HhUlH{+e1IhR}hXwMBCwKMZ46 zF)BJ50z*Co8dw;n$TG<6lwlCb2GaY0v;fdkyE5Od*FG`bq4)2%i|LzNiWq@nQb1h5 zkjaqDP{L5kP{feRP|Tpmkk60@WI^&IP*npLkYHr^Cc~ft(gOiUzI^|>u_|ndAe1js zz|e5&hsLJZbXBJvwVQ9c@@C(Gsg{1h&~S{i(awiO*n7(aMcpZ3eh*-BF!Ru9h`gc* zLj!|K0+0X#bmKwdAZ219FE($ww%>imqqgmTN|og_1Q;6hDi}H#SU|cM7y^J8;sp>7 zqyr=y0F{*h$%4f}G9Vmj0Q54HEdw-*VGFd(0vYJN%ed*V=%H?y|Al}GL4XJ77<31M z3=?o*n2-Ur3T!%13{uiT$~tuOgh7S@0mlTOK4|#}@&GooS#a2i&3sTifjk9rFQ^O$ z*#n|MZd|sle##b*BnUv<+JHR`9jY{+*?eq<+a9i{i=BRR1wgt9ry-y{;4}oXf(e|C zKxqaE1R5H8fO^(F<#{qW ze}QY8rR|$k!QdqzIY*0zo+-fSXW;_sV$}_3=y3t^xM0ZxXh2H}Ll1I*12r)))MzsN wk^veE=8O`fAut*O&=5e*DzLH-l7?j~O=YGh5DjlUpLtr!nMnDJv0Chq@IRF3v literal 0 HcmV?d00001 diff --git a/corpus/picture-edit/page-level/cold/notebook/pictures.one b/corpus/picture-edit/page-level/cold/notebook/pictures.one new file mode 100644 index 0000000000000000000000000000000000000000..c608c2f7e561582e3f90bc3facd1ed5518724bc2 GIT binary patch literal 5656 zcmeHKdr*{B6hAAZ%R@m0Tw~Fd7*Zz91xuGiSeh?|IyF&pr2d z@17kwarTnq#g5W#qjIWxtoXcg*1%N@4z5i4zf`!wsBh;K%NJMTEAms$PyhD(^!xvO_c`%Zr5X5SXzkHx z=lH1OdFrR(*AmpwbT-~u@mJaMuGK^GyT%={eZ8aJQxlgxJgRo#z3(Nji}sw?ucq)^ zQ>*5^@*Z#b>D*k}GSZTP|9=KdA_|X~heTM#nr;<~%_?C$_NRZU>m3;AHRSaTt#S`f zIB;F^zVNEIpESWjCB;MLhe*KU{t~dqDgoUwN`866SxteORB!Y>B}DF`@50@Ed*po9 zFMjWyM~6DHi)_t&%ZHaP*KNxkTc`t2`6N-Mo)4P;>ZL)6uY7X4V#S(^bLl_SUz9M& z(3EbG)n_BGCJft@b-|*n_3O%U>TD?*kUM7H-uS&k1KZuxyUmI2uqn!xJkfw53Oe54 zHqzP|uTU7J32hJ%clr>@=#9d0nlKi9X3q@ERut}lYazyw(3fF5`^`TPSh-kVPkRo6 z0y0H$kCzTeT@Hyden#-zgTvc6%1`xc`L@B2ftQ zCh3a$Y?2M7wX=4ANB!#cSdY47Ewg$&iuT8(X8#>m)cyHuk*78e3dfjT(j`kCld1UH zBwI3^-%N~5LGM|afxcSJtZ3(Kjn%Jo!kDMzVh)~_XG6pq4NkLt^#C>7f#cwVUY~|1 zkz1e9IhSIAjk&?UhgEz#U~K_7V}amcuobM&XEH;*F(FC}G6hbHTfM4t49=b?ouP2M z#;u)xnM3-`2@%^Yuou?S;ExSDRDxlnt}_(#ckCiz*RA4D@vX0jfqAKwshEVhYMvjc zkbozHu`X}S<%4->#r=zy$?_z2<`LxbG4$2TUO!JAX$c}ejHoCJ{Agx~V@des(|>-u zc?{?NFZi&bwbQk5KLWr5UGZU`Cd`B&5FFu(4-suJ0-6M7ygsg$1)4Ao0+y1mtFT5B z5|cP@TUVilTMeQmH?^HE{HR>=GBlwL0`9g+aQ1jedqo5t3qMkq=XbUL`a5Gn=1@6L zQ;vX~+w7n+^Y!%~!}g4?E3bWU+y1xLrN_-Bpt+)kJDxEwB;uJn&e9)=cs6R|!G)!f zS;Na_zqIb>@xS=!GF7ATR~;vw?a)F**@)53-U!dure;+dVHP8$cOw}$uo2I$_^I*O zpzgk2YLbX&M(ZV>MG-nd7u1g-p6T_gKV8jO@i?udxV=VLNuBQ68<;vq^LrvLBinlH zV~uwCu(sUS#X=-iDorcNy?7{hEgtuJp%0^a@yECqm?5ylQEtwR^ZXJKxH}xzcY=8V zb~z6ScZNGxJHQcSp&?1T?eskItdNKUTcZ3&|%Zu+f{dmi2EEDSc(aj#2`Gs@X7 zulo4v`AhM|4|RC|j~T~`ILBVmzpE`ES8U1~>zAc2H3~&<^b-uEU z^M&~pzBzrI_uMCVn|=y*zdLtZ@rLUn))zy>9Agz5@3c)NCDCLNTilp2$6_~?^o+&C zv0diN4-o~pS0pd9`n(e3qX;U;;~Fnxb`Ig^$H zcP$!zE9ke|Il6G?uNn63-=8>f)tYaojuGzaet3QuTmkN + diff --git a/corpus/picture-edit/page-level/cold/read/page-000.xml b/corpus/picture-edit/page-level/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..6f9dbd9dc67dad811b4f892a4fe7f8853d43762e --- /dev/null +++ b/corpus/picture-edit/page-level/cold/read/page-000.xml @@ -0,0 +1,4 @@ + +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w +AAAAAElFTkSuQmCC + diff --git a/corpus/picture-edit/page-level/cold/read/payloads.json b/corpus/picture-edit/page-level/cold/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/picture-edit/page-level/cold/read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/picture-edit/page-level/cold/run.json b/corpus/picture-edit/page-level/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..8efa63abedb2bef67a9d90bff430c1a013d862e2 --- /dev/null +++ b/corpus/picture-edit/page-level/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/m4/pagelevel", + "expected_pages": 1, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" + } +} diff --git a/corpus/picture-edit/page-level/cold/scripts/cold.ps1 b/corpus/picture-edit/page-level/cold/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/picture-edit/page-level/cold/scripts/cold.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } +$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' +if ($CloneHost) { + if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { + throw 'The disposable clone hostname does not match this machine.' + } + New-Item "$key\Options\Paths" -Force | Out-Null + New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null +} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or + -not (Test-Path 'C:\one-tests\profile-original-cache')) { + throw 'Park the personal OneNote profile before resetting the test cache.' +} +$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' +$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) +if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } +New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null +if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } +if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } +New-Item "$key\OpenNotebooks" | Out-Null +New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null diff --git a/corpus/picture-edit/page-level/cold/scripts/read.ps1 b/corpus/picture-edit/page-level/cold/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b --- /dev/null +++ b/corpus/picture-edit/page-level/cold/scripts/read.ps1 @@ -0,0 +1,142 @@ +param( + [Parameter(Mandatory=$true)][string]$Root, + [int]$ExpectedPages = -1, + [switch]$UseCurrentCache, + [switch]$Pdf, + [switch]$KeepOpen, + [string]$CloneHost = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +$notebook = Join-Path $root 'notebook' +$output = Join-Path $root 'read' +if (Test-Path $output) { throw 'Choose a new read destination.' } +if ($UseCurrentCache) { + if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { + throw 'Park the personal OneNote profile before reading test notebooks.' + } +} else { + & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost +} +New-Item -ItemType Directory -Path $output | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +$failure = $null +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $process = Get-Process ONENOTE + @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; + powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | + ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 + $sections = @() + foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { + $id = '' + $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) + $sections += $id + } + $deadline = [DateTime]::UtcNow.AddSeconds(300) + $previous = '' + $lastChange = '' + $stableSince = [DateTime]::UtcNow + $settled = $false + do { + $pages = @{} + foreach ($section in $sections) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$xml = $hierarchy + foreach ($node in $xml.SelectNodes('//*[@path]')) { + if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { + throw 'OneNote opened a section outside the copied notebook.' + } + } + foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { + $id = $node.GetAttribute('ID') + $content = '' + $app.GetPageContent($id, [ref]$content, 1, 1) + $pages[$id] = $content + } + } + $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) + if ($signature -ne $previous) { + $lastChange = $previous + $previous = $signature + $stableSince = [DateTime]::UtcNow + } + if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or + ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and + ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { + [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) + $index = 0 + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + $index++ + } + $hierarchy = '' + $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) + throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." + } + $index = 0 + $payloads = @() + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + if ($Pdf) { + $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) + $app.NavigateTo($id, '', $false) + $app.Publish($id, $pdfPath, 3, '') + if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } + } + [xml]$page = $pages[$id] + foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { + $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) + $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID'); + kind = $file.LocalName; name = $file.GetAttribute('preferredName'); + sha256 = $hash; bytes = $bytes.Length } + } + $index++ + } + [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) + $all = '' + $app.GetHierarchy($notebookId, 4, [ref]$all, 1) + [xml]$finalTree = $all + $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) + if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { + throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' + } + [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) + Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." +} catch { + $failure = $_ + [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + throw +} finally { + try { + try { + if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } + if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { + $app.CloseNotebook($notebookId, $false) + } + } catch { + if ($null -eq $failure) { throw } + [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + } + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + if (-not $UseCurrentCache -and -not $CloneHost) { + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + } +} diff --git a/corpus/picture-edit/page-level/cold/source.json b/corpus/picture-edit/page-level/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..8791fc92eea165f04e91d51b94af79d1b470412c --- /dev/null +++ b/corpus/picture-edit/page-level/cold/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 1604, + "sha256": "3df6c788afbfe6ecda97d63cdbb3f42b269d61107e78dd0b3babaf55ae567389", + "mtime_ns": 1789249104766253246 + }, + { + "path": "pictures.one", + "bytes": 5656, + "sha256": "b6c0a2e604c038a2d0fa93a6096c3fecfa08f370f268ad010d7419ee87dd1f02", + "mtime_ns": 1789249104766006120 + } +] diff --git a/corpus/picture-edit/page-level/cold/teardown.json b/corpus/picture-edit/page-level/cold/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/picture-edit/page-level/cold/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/crates/notebook/README.md b/crates/notebook/README.md index 136c30cd4e9c4cff1989b0a3e532432d396e83d4..cef70b51ababe2a8ec269f1d2196f7e8cec75249 100644 --- a/crates/notebook/README.md +++ b/crates/notebook/README.md @@ -14,7 +14,7 @@ diffs the supplied model against the page stored in the supplied local snapshot, writes the difference into the working image and queues one `Operation::Page(PageIntent { before, after, author })`; `before` is the page the edit started from and is the precondition reconciliation checks. Text, styles, -paragraph formatting, hyperlinks, bullets, numbering, note tags, table rows and columns, pictures (insertion, size, description) and attachments, paragraph structure, +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, outline layout, insertions and deletions are all differences between `before` and `after`; the library never sees editor operations. diff --git a/crates/onestore/README.md b/crates/onestore/README.md index 2952cbffa9748b6362fef6a86437ee71eb4c5398..8d2e8936738494415ad65f951bb198b66ea11a96 100644 --- a/crates/onestore/README.md +++ b/crates/onestore/README.md @@ -57,7 +57,7 @@ harness also accepts `--client-profile release`. | `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules | | `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs | | `OutlineEdit`, `PreparedEdit::outline` | Change ordinary outline position/width or a paragraph's saved expansion default, preserving identities and content | -| `PreparedEdit::page` | Publish an edited `page::Page` as one revision per changed space: text, character and paragraph formatting, hyperlinks, 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 | +| `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 | | `TreeEdit`, `PreparedEdit::tree` | Move or delete a subtree on one page, normalize surviving containers, and replace an emptied table cell's paragraph atomically | | `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter | | `read_file` | Read a snapshot under whole-file exclusion | diff --git a/crates/onestore/src/page/write.rs b/crates/onestore/src/page/write.rs index 2cddf00e9329d72913ab52318a62b45054a7c370..276698f1ccd1f69648b3a387612dc61882c88504 100644 --- a/crates/onestore/src/page/write.rs +++ b/crates/onestore/src/page/write.rs @@ -28,6 +28,73 @@ fn payload_reference(guid: [u8; 16]) -> String { format!("{}", id.split(',').next().unwrap()) } +fn page_images(page: &Page) -> impl Iterator { + page.objects.iter().filter_map(|object| match object { + PageObject::Image(image) => Some((image.id, image)), + _ => None, + }) +} + +fn picture_fixed_fields(stored: &Image, image: &Image) -> Result<(), Error> { + if stored.id != image.id + || stored.bytes != image.bytes + || stored.size != image.size + || stored.background != image.background + { + return Err(invalid( + "A stored picture keeps its payload, intrinsic size and background state", + )); + } + Ok(()) +} + +/// Position and displayed-size properties that take `layout` from `stored`, and the ones +/// to remove. +fn layout_values( + stored: &crate::document::Layout, + layout: &crate::document::Layout, +) -> Result<(Values, Vec), Error> { + let mut values = Values::new(); + let mut removed = Vec::new(); + if (layout.x, layout.y) != (stored.x, stored.y) { + let (Some(x), Some(y)) = (layout.x, layout.y) else { + return Err(invalid("A picture position needs both coordinates")); + }; + if !(x.is_finite() && y.is_finite()) { + return Err(invalid("A picture position must be finite")); + } + values.push((0x14001c14, (x / 36.0).to_le_bytes().to_vec())); + values.push((0x14001c15, (y / 36.0).to_le_bytes().to_vec())); + } + if ( + layout.max_width, + layout.max_height, + layout.width_set_by_user, + ) != ( + stored.max_width, + stored.max_height, + stored.width_set_by_user, + ) { + match (layout.max_width, layout.max_height) { + (Some(width), Some(height)) => { + if !(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0) { + return Err(invalid("Picture size must be positive")); + } + values.push((0x14001c1b, (width / 36.0).to_le_bytes().to_vec())); + values.push((0x14001c1c, (height / 36.0).to_le_bytes().to_vec())); + let user_set = u32::from(layout.width_set_by_user == Some(true)); + values.push((0x08001cbd | (user_set << 31), Vec::new())); + } + (None, None) => removed.extend([0x14001c1b, 0x14001c1c, 0x08001cbd]), + _ => return Err(invalid("A picture size needs both dimensions")), + } + } + if layout.reserved_width != stored.reserved_width { + return Err(invalid("A picture has no reserved width")); + } + Ok((values, removed)) +} + fn measurement_bytes(values: &[f32], header: usize) -> Result, Error> { let count = u8::try_from(values.len()) .map_err(|_| invalid("A measurement array exceeds the document range"))?; @@ -298,10 +365,6 @@ impl Lowering<'_> { page.objects .iter() .filter_map(|object| match object { - PageObject::Image(image) => Some(format!( - "{:?} {:?} {:?} {:?}", - image.id, image.layout, image.alt, image.background - )), PageObject::Unsupported(unsupported) => Some(format!("{unsupported:?}")), PageObject::Title(title) => Some(format!( "{:?} {:?} {:?} {:?}", @@ -310,7 +373,7 @@ impl Lowering<'_> { title.layout, title.outlines.iter().map(|o| o.id).collect::>() )), - PageObject::Outline(_) => None, + PageObject::Outline(_) | PageObject::Image(_) => None, }) .collect() }; @@ -320,7 +383,7 @@ impl Lowering<'_> { after_fixed.sort(); if before_fixed != after_fixed { return Err(invalid( - "Images, titles and unsupported objects cannot be edited through the page model", + "Titles and unsupported objects cannot be edited through the page model", )); } for (id, outline) in &new.outlines { @@ -386,15 +449,9 @@ impl Lowering<'_> { } (ParagraphContent::Unsupported(a), ParagraphContent::Unsupported(b)) if a == b => {} (ParagraphContent::Image(a), ParagraphContent::Image(b)) => { - if a.id != b.id - || a.bytes != b.bytes - || a.size != b.size - || a.background != b.background - || (a.layout.x, a.layout.y) != (b.layout.x, b.layout.y) - { - return Err(invalid( - "A stored picture keeps its payload, intrinsic size, background state and position", - )); + picture_fixed_fields(a, b)?; + if (a.layout.x, a.layout.y) != (b.layout.x, b.layout.y) { + return Err(invalid("A paragraph picture has no position of its own")); } } (ParagraphContent::Attachment(a), ParagraphContent::Attachment(b)) => { @@ -580,6 +637,7 @@ impl Lowering<'_> { .iter() .any(|object| matches!(object, PageObject::Title(title) if title.id == id)) }; + self.edit_page_images(old, new)?; let survivors: Vec = page_order .iter() .copied() @@ -764,79 +822,138 @@ impl Lowering<'_> { return Err(invalid("Paragraph content type cannot change")); }; if stored != image { - self.resize_image(stored, image)?; + self.edit_image(stored, image)?; } continue; } - let Some(bytes) = &image.bytes else { - return Err(invalid("A new picture needs its payload")); - }; - let extension = match bytes.as_ref() { - [0x89, b'P', b'N', b'G', ..] => ".png", - [0xff, 0xd8, 0xff, ..] => ".jpg", - [b'G', b'I', b'F', b'8', ..] => ".gif", - [b'B', b'M', ..] => ".bmp", - _ => return Err(invalid("Choose a PNG, JPEG, GIF or BMP picture")), - }; - let image_id = self.allocate(image.id)?; - let file_id = ExGuid { - guid: crate::write::fresh_guid()?, - n: 1, - }; - let payload_guid = crate::write::fresh_guid()?; - let reference = payload_reference(payload_guid); - let modified = crate::create::current_timestamps()?.0.to_le_bytes(); - let mut values: Values = vec![(0x14001d7a, modified.to_vec())]; - if let Some([width, height]) = image.size { - if !(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0) { - return Err(invalid("Picture size must be positive")); - } - values.push((0x140034cd, (width / 36.0).to_le_bytes().to_vec())); - values.push((0x140034ce, (height / 36.0).to_le_bytes().to_vec())); + if image.layout.x.is_some() || image.layout.y.is_some() { + return Err(invalid("A paragraph picture has no position of its own")); } - if let Some(alt) = &image.alt { - values.push((0x1c001e58, crate::create::string(alt))); - } - if image.background { - values.push((0x08001d13 | (1 << 31), Vec::new())); - } - values.push((0x08001d85, Vec::new())); let holder = self.id(*paragraph_id); - let space = self.space; - let payload: &[u8] = bytes; - self.apply(|current| { - crate::write::write_revision_with_payloads( - current, - space, - &[(payload_guid, payload)], - |raw| { - let mut changed = BTreeMap::new(); - let file = PropertyObject::file(file_id, &reference, extension)?; - changed.insert(file_id, file); - let mut picture = PropertyObject { - jcid: 0x60011, - bytes: crate::create::properties(&values)?, - global_ids: std::sync::Arc::new(BTreeMap::from([(0, image_id.guid)])), - }; - picture.reference(image_id)?; - let container = picture.reference(file_id)?; - picture.set(&[(0x20001c3f, &container)])?; - changed.insert(image_id, picture); - let mut object = PropertyObject::from_object(&raw.objects[&holder])?; - let content = object.reference(image_id)?; - object.set(&[(0x24001c1f, &content), (0x14001d7a, &modified)])?; - changed.insert(holder, object); - Ok(changed) - }, - ) - })?; + self.insert_image(image, Some(holder))?; } Ok(()) } - /// Writes a displayed size and description the way OneNote stores a resized picture: - /// the layout width and height with the user flag, leaving the intrinsic size alone. - fn resize_image(&mut self, stored: &Image, image: &Image) -> Result<(), Error> { + /// Page-level pictures are direct page children, as OneNote stores a picture placed + /// outside any outline: new ones are appended for the placement pass to order, changed + /// ones are moved, resized or described, and removed ones are deleted in the delete pass. + fn edit_page_images(&mut self, old: &View<'_>, new: &View<'_>) -> Result<(), Error> { + let stored: BTreeMap = page_images(old.page).collect(); + for (id, image) in page_images(new.page) { + match stored.get(&id) { + Some(previous) => { + if *previous != image { + self.edit_image(previous, image)?; + } + } + None => { + if image.layout.x.is_none() || image.layout.y.is_none() { + return Err(invalid("A new page-level picture needs a position")); + } + self.insert_image(image, None)?; + } + } + } + Ok(()) + } + + /// Gives a new picture what OneNote stores for an inserted one: the payload embedded + /// in the section's file-data store, a file-data object declaring it by identity and + /// extension, and a picture object that a paragraph holds as content or the page + /// lists as a child. + fn insert_image(&mut self, image: &Image, holder: Option) -> Result<(), Error> { + let Some(bytes) = &image.bytes else { + return Err(invalid("A new picture needs its payload")); + }; + let extension = match bytes.as_ref() { + [0x89, b'P', b'N', b'G', ..] => ".png", + [0xff, 0xd8, 0xff, ..] => ".jpg", + [b'G', b'I', b'F', b'8', ..] => ".gif", + [b'B', b'M', ..] => ".bmp", + _ => return Err(invalid("Choose a PNG, JPEG, GIF or BMP picture")), + }; + let image_id = self.allocate(image.id)?; + let file_id = ExGuid { + guid: crate::write::fresh_guid()?, + n: 1, + }; + let payload_guid = crate::write::fresh_guid()?; + let reference = payload_reference(payload_guid); + let modified = crate::create::current_timestamps()?.0.to_le_bytes(); + let mut values: Values = vec![(0x14001d7a, modified.to_vec())]; + if let Some([width, height]) = image.size { + if !(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0) { + return Err(invalid("Picture size must be positive")); + } + values.push((0x140034cd, (width / 36.0).to_le_bytes().to_vec())); + values.push((0x140034ce, (height / 36.0).to_le_bytes().to_vec())); + } + values.extend(layout_values(&Default::default(), &image.layout)?.0); + if let Some(alt) = &image.alt { + values.push((0x1c001e58, crate::create::string(alt))); + } + if image.background { + values.push((0x08001d13 | (1 << 31), Vec::new())); + } + values.push((0x08001d85, Vec::new())); + let (space, page) = (self.space, self.page); + let payload: &[u8] = bytes; + self.apply(|current| { + crate::write::write_revision_with_payloads( + current, + space, + &[(payload_guid, payload)], + |raw| { + let mut changed = BTreeMap::new(); + let file = PropertyObject::file(file_id, &reference, extension)?; + changed.insert(file_id, file); + let mut picture = PropertyObject { + jcid: 0x60011, + bytes: crate::create::properties(&values)?, + global_ids: std::sync::Arc::new(BTreeMap::from([(0, image_id.guid)])), + }; + picture.reference(image_id)?; + let container = picture.reference(file_id)?; + picture.set(&[(0x20001c3f, &container)])?; + changed.insert(image_id, picture); + match holder { + Some(holder) => { + let mut object = PropertyObject::from_object(&raw.objects[&holder])?; + let content = object.reference(image_id)?; + object.set(&[(0x24001c1f, &content), (0x14001d7a, &modified)])?; + changed.insert(holder, object); + } + None => { + let mut object = PropertyObject::from_object(&raw.objects[&page])?; + let properties = crate::PropertySets::parse(&object.bytes)?; + let mut children = match properties.sets[0] + .iter() + .find(|p| p.id == 0x24001c20) + .map(|p| &p.value) + { + Some(crate::Value::References { compact_ids, .. }) => { + compact_ids.to_vec() + } + None => Vec::new(), + _ => return Err(invalid("The page has an invalid child list")), + }; + children.extend(object.reference(image_id)?); + object.set(&[(0x24001c20, &children), (0x14001d7a, &modified)])?; + changed.insert(page, object); + } + } + Ok(changed) + }, + ) + }) + } + + /// Writes a moved, resized or described picture the way OneNote stores one: the + /// position, the layout width and height with the user flag and the description on + /// the picture object, leaving the intrinsic size alone. + fn edit_image(&mut self, stored: &Image, image: &Image) -> Result<(), Error> { + picture_fixed_fields(stored, image)?; let mut values: Values = vec![( 0x14001d7a, crate::create::current_timestamps()? @@ -844,22 +961,8 @@ impl Lowering<'_> { .to_le_bytes() .to_vec(), )]; - let mut removed = Vec::new(); - if image.layout != stored.layout { - match (image.layout.max_width, image.layout.max_height) { - (Some(width), Some(height)) => { - if !(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0) { - return Err(invalid("Picture size must be positive")); - } - values.push((0x14001c1b, (width / 36.0).to_le_bytes().to_vec())); - values.push((0x14001c1c, (height / 36.0).to_le_bytes().to_vec())); - let user_set = u32::from(image.layout.width_set_by_user == Some(true)); - values.push((0x08001cbd | (user_set << 31), Vec::new())); - } - (None, None) => removed.extend([0x14001c1b, 0x14001c1c, 0x08001cbd]), - _ => return Err(invalid("A picture size needs both dimensions")), - } - } + let (layout, mut removed) = layout_values(&stored.layout, &image.layout)?; + values.extend(layout); if image.alt != stored.alt { match &image.alt { Some(alt) => values.push((0x1c001e58, crate::create::string(alt))), @@ -1165,6 +1268,14 @@ impl Lowering<'_> { let space = self.space; self.apply(|image| edit.apply(image, space))?; } + let kept: BTreeSet = page_images(new.page).map(|(id, _)| id).collect(); + for (id, _) in page_images(old.page) { + if !kept.contains(&id) { + let edit = TreeEdit::delete(id, self.author)?; + let space = self.space; + self.apply(|image| edit.apply(image, space))?; + } + } for id in old.outlines.keys() { if removed_outline(*id) { if old.title_outlines.contains(id) { diff --git a/crates/onestore/src/tree.rs b/crates/onestore/src/tree.rs index 34aff9ec5cfa65598dd2d0e4dd600b06fd033893..73822329e127c179e4e62d33ef1af3292ced9e47 100644 --- a/crates/onestore/src/tree.rs +++ b/crates/onestore/src/tree.rs @@ -115,7 +115,7 @@ impl TreeEdit { .get(1) .ok_or_else(|| invalid("Select a paragraph or ordinary page outline"))?; let compatible = |parent: ExGuid| match view.nodes[&self.object].kind { - Kind::Outline { .. } => parent == *page, + Kind::Outline { .. } | Kind::Image { .. } => parent == *page, Kind::Paragraph { .. } => matches!( view.nodes[&parent].kind, Kind::Outline { .. } diff --git a/crates/onestore/tests/page_images.rs b/crates/onestore/tests/page_images.rs index 5d32d6225b666a61ce6f11ddd7e6453303b53b71..ad0f14d9bd2b0bff181adbfd3f888f28cc7a8182 100644 --- a/crates/onestore/tests/page_images.rs +++ b/crates/onestore/tests/page_images.rs @@ -9,6 +9,8 @@ const NATIVE: &[u8] = include_bytes!("../../../corpus/native/cold-05-05-image/notebook/synthetic.one"); const NATIVE_RESIZE: &[u8] = include_bytes!("../../../corpus/picture-edit/native-resize/notebook/pictures.one"); +const NATIVE_PAGE_LEVEL: &[u8] = + include_bytes!("../../../corpus/picture-edit/native-page-level/notebook/pictures.one"); const AUTHOR: &str = "Picture author"; /// A one-pixel PNG. const PNG: &[u8] = &[ @@ -236,6 +238,113 @@ fn a_rust_picture_resized_natively_reads_back_with_its_size_and_description() { assert_eq!(page_in(written.as_bytes(), space), wider); } +fn page_pictures(page: &Page) -> Vec<&Image> { + page.objects + .iter() + .filter_map(|object| match object { + PageObject::Image(image) => Some(image), + _ => None, + }) + .collect() +} + +#[test] +fn a_page_level_picture_placed_natively_reads_as_a_page_object() { + let (space, page) = first_page(NATIVE_PAGE_LEVEL); + assert_eq!(pictures(&page).len(), 1); + let found = page_pictures(&page); + let [picture] = found.as_slice() else { + panic!("{page:?}"); + }; + assert_eq!(picture.bytes.as_deref(), Some(PNG)); + assert_eq!( + (picture.layout.x, picture.layout.y), + (Some(360.0), Some(240.0)) + ); + assert_eq!(picture.layout.width_set_by_user, Some(true)); + assert_eq!(picture.alt.as_deref(), Some("Page-level picture")); + let mut moved = page.clone(); + for object in &mut moved.objects { + if let PageObject::Image(image) = object { + image.layout.x = Some(72.0); + image.layout.y = Some(400.0); + } + } + let written = PreparedEdit::page(NATIVE_PAGE_LEVEL, space, &moved, AUTHOR).unwrap(); + assert_eq!(page_in(written.as_bytes(), space), moved); +} + +/// `ONESTORE_PAGE_IMAGE_EXPORT` names a new directory receiving the candidate for a cold reopen. +#[test] +fn a_page_level_picture_is_inserted_moved_and_removed() { + let source = onestore::create_section("pictures.one", "Beside the picture", "Author").unwrap(); + let (space, before) = first_page(&source); + let mut after = before.clone(); + let image = Image { + id: new_id().unwrap(), + layout: Layout { + x: Some(360.0), + y: Some(240.0), + max_width: Some(96.0), + width_set_by_user: Some(true), + max_height: Some(72.0), + reserved_width: None, + }, + size: Some([0.75, 0.75]), + bytes: Some(Arc::from(PNG)), + alt: Some("Placed in Rust".into()), + background: false, + }; + after.objects.push(PageObject::Image(image.clone())); + let written = PreparedEdit::page(&source, space, &after, AUTHOR).unwrap(); + let stored = page_in(written.as_bytes(), space); + let mut expected = after.clone(); + expected.title = stored.title.clone(); + assert_eq!(stored, expected); + assert_eq!(page_pictures(&stored)[0].bytes.as_deref(), Some(PNG)); + assert_eq!( + PreparedEdit::page(written.as_bytes(), space, &stored, AUTHOR) + .unwrap() + .as_bytes(), + written.as_bytes() + ); + if let Some(directory) = std::env::var_os("ONESTORE_PAGE_IMAGE_EXPORT") { + let directory = std::path::PathBuf::from(directory); + std::fs::create_dir(&directory).unwrap(); + std::fs::write(directory.join("pictures.one"), written.as_bytes()).unwrap(); + let written_store = Store::parse(written.as_bytes()).unwrap(); + std::fs::write( + directory.join("Open Notebook.onetoc2"), + onestore::create_table_of_contents( + "Open Notebook.onetoc2", + &[("pictures.one", written_store.header.file_id)], + ) + .unwrap(), + ) + .unwrap(); + } + let mut moved = stored.clone(); + for object in &mut moved.objects { + if let PageObject::Image(image) = object { + image.layout.x = Some(36.0); + image.alt = None; + } + } + let again = PreparedEdit::page(written.as_bytes(), space, &moved, AUTHOR).unwrap(); + assert_eq!(page_in(again.as_bytes(), space), moved); + let mut removed = moved.clone(); + removed.objects.retain(|object| object.id() != image.id); + let last = PreparedEdit::page(again.as_bytes(), space, &removed, AUTHOR).unwrap(); + let stored = page_in(last.as_bytes(), space); + assert!(page_pictures(&stored).is_empty()); + assert_eq!(stored, removed); + let mut unplaced = stored.clone(); + let mut nowhere = image.clone(); + nowhere.layout = Layout::default(); + unplaced.objects.push(PageObject::Image(nowhere)); + assert!(PreparedEdit::page(last.as_bytes(), space, &unplaced, AUTHOR).is_err()); +} + #[test] fn pictures_need_a_recognised_payload_and_stored_ones_stay_fixed() { let source = onestore::create_section("pictures.one", "Text", "Author").unwrap(); diff --git a/tools/native/page-picture.ps1 b/tools/native/page-picture.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..83021a116d4982bea1706f7ca290799c7443f512 --- /dev/null +++ b/tools/native/page-picture.ps1 @@ -0,0 +1,56 @@ +param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost +$namespace = 'http://schemas.microsoft.com/office/onenote/2010/onenote' +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +try { + $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0) + $sectionId = '' + $app.OpenHierarchy('pictures.one', $notebookId, [ref]$sectionId, 0) + $deadline = [DateTime]::UtcNow.AddSeconds(30) + $page = $null + do { + $hierarchy = '' + $app.GetHierarchy($sectionId, 4, [ref]$hierarchy, 1) + [xml]$tree = $hierarchy + $pages = @($tree.SelectNodes('//*[local-name()="Page"]')) + if ($pages.Count -eq 1) { + $content = '' + $app.GetPageContent($pages[0].GetAttribute('ID'), [ref]$content, 3, 1) + [xml]$page = $content + if (@($page.SelectNodes('//*[local-name()="Image"]')).Count -eq 1) { break } + } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (@($page.SelectNodes('//*[local-name()="Image"]')).Count -ne 1) { throw 'Expected one picture on the page.' } + [IO.File]::WriteAllText((Join-Path $Root 'before.xml'), $content, [Text.Encoding]::UTF8) + $data = $page.SelectSingleNode('//*[local-name()="Image"]/*[local-name()="Data"]').InnerText + $image = $page.CreateElement('one', 'Image', $namespace) + $image.SetAttribute('format', 'png') + $image.SetAttribute('alt', 'Page-level picture') + $position = $page.CreateElement('one', 'Position', $namespace) + $position.SetAttribute('x', '360') + $position.SetAttribute('y', '240') + [void]$image.AppendChild($position) + $size = $page.CreateElement('one', 'Size', $namespace) + $size.SetAttribute('width', '96') + $size.SetAttribute('height', '72') + $size.SetAttribute('isSetByUser', 'true') + [void]$image.AppendChild($size) + $payload = $page.CreateElement('one', 'Data', $namespace) + $payload.InnerText = $data + [void]$image.AppendChild($payload) + [void]$page.DocumentElement.AppendChild($image) + [IO.File]::WriteAllText((Join-Path $Root 'update.xml'), $page.OuterXml, [Text.Encoding]::UTF8) + $app.UpdatePageContent($page.OuterXml, [DateTime]::MinValue, 1, $false) + $app.SyncHierarchy($notebookId) + $app.CloseNotebook($notebookId, $false) + $notebookId = '' +} finally { + if ($notebookId) { $app.CloseNotebook($notebookId, $false) } + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + [GC]::Collect() + [GC]::WaitForPendingFinalizers() +} diff --git a/tools/test_picture_edit.py b/tools/test_picture_edit.py index 3abb872b16f7e0240e121825c56f6901a52feda8..ec611bf754827ab4c02997b874e005851471e88c 100644 --- a/tools/test_picture_edit.py +++ b/tools/test_picture_edit.py @@ -53,6 +53,26 @@ class PictureEditTest(unittest.TestCase): self.assertEqual((size.get('width'), size.get('height'), size.get('isSetByUser')), ('144.0', '108.0', 'true')) self.assertEqual(image.get('alt'), 'Resized in Rust') + def test_onenote_places_a_picture_directly_on_a_page_with_a_rust_picture(self): + page = native_page(FIXTURE / 'native-page-level/notebook', FIXTURE / 'native-page-level') + images = list(page.iter('{%s}Image' % ns['one'])) + self.assertEqual(len(images), 2) + placed = page.find('one:Image', ns) + self.assertEqual(placed.get('alt'), 'Page-level picture') + position = placed.find('one:Position', ns) + self.assertEqual((position.get('x'), position.get('y')), ('360.0', '240.0')) + + def test_a_rust_page_level_picture_renders_with_its_position_size_and_description(self): + page = native_page(FIXTURE / 'page-level/candidate', FIXTURE / 'page-level/cold') + placed = page.find('one:Image', ns) + self.assertIsNotNone(placed) + position = placed.find('one:Position', ns) + self.assertEqual((position.get('x'), position.get('y')), ('360.0', '240.0')) + size = placed.find('one:Size', ns) + self.assertEqual((size.get('width'), size.get('height'), size.get('isSetByUser')), ('96.00000762939453', '71.99998474121093', 'true')) + self.assertEqual(placed.get('alt'), 'Placed in Rust') + self.assertEqual(texts(page), ['Beside the picture']) + if __name__ == '__main__': unittest.main() -- 2.54.0