| author | |
| committer | |
| log | d0ec66f9b4d3023668fa156d8a0903b3b5b8a316 |
| tree | 313733ce0e5280fa41d4d86a19594a1e4d031424 |
| parent | cb4842d4e6047a60f7c99dcbf033c4fe366e8b8f |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
A picture paragraph in the model can change its layout width and height,
the user-set flag and its alternative text; the writer stores exactly what
OneNote 2010 was observed to store when it resized a Rust-written picture
through the COM API (corpus/picture-edit/native-resize, authored by
tools/native/picture-edit.ps1): the layout size and user flag on the picture
object, with the intrinsic picture size untouched. Clearing the size or the
description removes the properties, so a reset page equals the original.
The payload, intrinsic size, background state and position stay fixed.
corpus/picture-edit now holds three captures: inserted (the Rust-inserted
picture cold read), native-resize (OneNote editing that picture, kept as a
reader fixture for a Rust picture rewritten natively with its DPAPI blob and
hash) and resized (a Rust resize of the native picture cold read with its
size and description). tools/test_picture_edit.py checks all three.
Assisted-by: claude-fable-5.168 files changed, 978 insertions(+), 245 deletions(-)
corpus/picture-edit/README.md+24-8| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | # Rust picture authoring | 1 | # Rust picture authoring |
| 2 | 2 | ||
| 3 | `candidate/` is the page writer's output for | 3 | `inserted/candidate` is the page writer's output for |
| 4 | `a_picture_inserted_on_a_fresh_page_reads_back_and_can_be_removed` in | 4 | `a_picture_inserted_on_a_fresh_page_reads_back_and_can_be_removed` in |
| 5 | `crates/onestore/tests/page_images.rs`: on a section created in Rust, a | 5 | `crates/onestore/tests/page_images.rs`: on a section created in Rust, a |
| 6 | paragraph between two text paragraphs becomes a picture, stored the way | 6 | paragraph between two text paragraphs becomes a picture, stored the way |
| ... | @@ -10,11 +10,27 @@ object, a picture-container file object declaring it by identity and | ... | @@ -10,11 +10,27 @@ object, a picture-container file object declaring it by identity and |
| 10 | extension, and a picture object with the displayed size that the paragraph | 10 | extension, and a picture object with the displayed size that the paragraph |
| 11 | holds as content. OneNote's own picture objects also carry a DPAPI-protected | 11 | holds as content. OneNote's own picture objects also carry a DPAPI-protected |
| 12 | blob (`0x1c001dfb`) that only the authoring Windows user can decrypt; the | 12 | blob (`0x1c001dfb`) that only the authoring Windows user can decrypt; the |
| 13 | writer omits it. | 13 | writer omits it. `inserted/cold` is a fresh OneNote 2010 read: the image |
| 14 | reads back as `format="png"` with the exact payload bytes, between the two | ||
| 15 | text paragraphs. | ||
| 14 | 16 | ||
| 15 | `cold/` is a fresh OneNote 2010 read: the image reads back as `format="png"` | 17 | `native-resize` is OneNote 2010 editing that Rust-written picture |
| 16 | with the exact payload bytes, between the two text paragraphs. | 18 | (`tools/native/picture-edit.ps1` on `inserted/candidate`): the picture gets |
| 17 | `tools/test_picture_edit.py` checks this without a VM. Regenerate with | 19 | a 144 by 108 point user-set size and alternative text through the COM API |
| 18 | `ONESTORE_IMAGE_EXPORT` set to a new absolute directory while running the | 20 | (`before.xml`, `update.xml`), and `read/` is the read after the edit. In |
| 19 | test, then cold-open it with `tools/native_runner.py OUTPUT COLD | 21 | `notebook/`, OneNote stored the size as the picture object's layout width |
| 20 | --expected-pages 1 --collect-notebook`. | 22 | and height with the user flag (`0x14001c1b`, `0x14001c1c`, `0x08001cbd`) and |
| 23 | left the intrinsic size (`0x140034cd/ce`) alone; it also rewrote the picture | ||
| 24 | container under a native identity with its DPAPI blob and hash. | ||
| 25 | |||
| 26 | `resized/candidate` is the writer doing the same on the native picture of | ||
| 27 | `cold-05-05-image` (`a_native_picture_is_resized_and_described_then_reset`): | ||
| 28 | layout width, height, user flag and alternative text on the picture object. | ||
| 29 | `resized/cold` is its cold read with the size and description. | ||
| 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`. |
corpus/picture-edit/candidate/Open Notebook.onetoc2 deleted| Binary files a/corpus/picture-edit/candidate/Open Notebook.onetoc2 and /dev/null differ | |||
corpus/picture-edit/candidate/pictures.one deleted| Binary files a/corpus/picture-edit/candidate/pictures.one and /dev/null differ | |||
corpus/picture-edit/cold/commands.jsonl deleted-3| ... | @@ -1,3 +0,0 @@ | ||
| 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-A5A8705B -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/cold/machine.json deleted-1| ... | @@ -1 +0,0 @@ | ||
| 1 | {"name": "m6-a5a8705b", "hostname": "ONE-M6-A5A8705B"} | ||
corpus/picture-edit/cold/notebook/Open Notebook.onetoc2 deleted| Binary files a/corpus/picture-edit/cold/notebook/Open Notebook.onetoc2 and /dev/null differ | |||
corpus/picture-edit/cold/notebook/pictures.one deleted| Binary files a/corpus/picture-edit/cold/notebook/pictures.one and /dev/null differ | |||
corpus/picture-edit/cold/read/environment.json deleted-7| ... | @@ -1,7 +0,0 @@ | ||
| 1 | { | ||
| 2 | "powershell": "5.1.14409.1005", | ||
| 3 | "schema": "xs2010", | ||
| 4 | "hostname": "ONE-M6-A5A8705B", | ||
| 5 | "cold": true, | ||
| 6 | "onenote": "14.0.4763.1000" | ||
| 7 | } | ||
corpus/picture-edit/cold/read/hierarchy.xml deleted-2| ... | @@ -1,2 +0,0 @@ | ||
| 1 | <?xml version="1.0"?> | ||
| 2 | <one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{BB903F04-A992-47A1-B747-4D7C2D151005}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-12T20:08:56.000Z" color="#B49EDE"><one:Section name="pictures" ID="{54DE5097-F4BE-0BCC-0392-19FB4210AAAD}{1}{B0}" path="C:\one-tests\runs\capture\notebook\pictures.one" lastModifiedTime="2026-09-12T20:08:56.000Z" color="#8AA8E4"><one:Page ID="{D3A0AD9C-D12D-02B2-1683-4D32345076A5}{1}{B0}" name="Before the picture" dateTime="2026-09-12T20:07:15.000Z" lastModifiedTime="2026-09-12T20:08:56.000Z" pageLevel="1"/></one:Section></one:Notebook> | ||
corpus/picture-edit/cold/read/page-000.xml deleted-4| ... | @@ -1,4 +0,0 @@ | ||
| 1 | <?xml version="1.0"?> | ||
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{D3A0AD9C-D12D-02B2-1683-4D32345076A5}{1}{B0}" name="Before the picture" dateTime="2026-09-12T20:07:15.000Z" lastModifiedTime="2026-09-12T20:08:56.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="{54DE5097-F4BE-0BCC-0392-19FB4210AAAD}{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="{54DE5097-F4BE-0BCC-0392-19FB4210AAAD}{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="{496D54CF-B74C-091A-2D00-F268C53481D1}{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="{39F25FF2-3F08-07F1-06E1-F7F0075DE8F1}{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/cold/read/payloads.json deleted-3| ... | @@ -1,3 +0,0 @@ | ||
| 1 | [ | ||
| 2 | |||
| 3 | ] | ||
| \ No newline at end of file | |||
corpus/picture-edit/cold/run.json deleted-18| ... | @@ -1,18 +0,0 @@ | ||
| 1 | { | ||
| 2 | "notebook": "/private/tmp/m4/pictures", | ||
| 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/cold/scripts/cold.ps1 deleted-27| ... | @@ -1,27 +0,0 @@ | ||
| 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/cold/scripts/read.ps1 deleted-142| ... | @@ -1,142 +0,0 @@ | ||
| 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/cold/source.json deleted-14| ... | @@ -1,14 +0,0 @@ | ||
| 1 | [ | ||
| 2 | { | ||
| 3 | "path": "Open Notebook.onetoc2", | ||
| 4 | "bytes": 1604, | ||
| 5 | "sha256": "0eddd691b52d26bac222cf7e52add3c9909446647b452fb193f03d49e273426a", | ||
| 6 | "mtime_ns": 1789243635388246438 | ||
| 7 | }, | ||
| 8 | { | ||
| 9 | "path": "pictures.one", | ||
| 10 | "bytes": 7064, | ||
| 11 | "sha256": "3076880e02cf74eb85400852c133060ab8ed50383c6b9df8a07bde8389891a70", | ||
| 12 | "mtime_ns": 1789243635387879438 | ||
| 13 | } | ||
| 14 | ] | ||
corpus/picture-edit/cold/teardown.json deleted-1| ... | @@ -1 +0,0 @@ | ||
| 1 | {"absent": true} | ||
corpus/picture-edit/inserted/candidate/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/picture-edit/inserted/candidate/Open Notebook.onetoc2 differ | |||
corpus/picture-edit/inserted/candidate/pictures.one created| Binary files /dev/null and b/corpus/picture-edit/inserted/candidate/pictures.one differ | |||
corpus/picture-edit/inserted/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-A5A8705B -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/inserted/cold/machine.json created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | {"name": "m6-a5a8705b", "hostname": "ONE-M6-A5A8705B"} | ||
corpus/picture-edit/inserted/cold/notebook/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/picture-edit/inserted/cold/notebook/Open Notebook.onetoc2 differ | |||
corpus/picture-edit/inserted/cold/notebook/pictures.one created| Binary files /dev/null and b/corpus/picture-edit/inserted/cold/notebook/pictures.one differ | |||
corpus/picture-edit/inserted/cold/read/environment.json created+7| ... | @@ -0,0 +1,7 @@ | ||
| 1 | { | ||
| 2 | "powershell": "5.1.14409.1005", | ||
| 3 | "schema": "xs2010", | ||
| 4 | "hostname": "ONE-M6-A5A8705B", | ||
| 5 | "cold": true, | ||
| 6 | "onenote": "14.0.4763.1000" | ||
| 7 | } | ||
corpus/picture-edit/inserted/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="{BB903F04-A992-47A1-B747-4D7C2D151005}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-12T20:08:56.000Z" color="#B49EDE"><one:Section name="pictures" ID="{54DE5097-F4BE-0BCC-0392-19FB4210AAAD}{1}{B0}" path="C:\one-tests\runs\capture\notebook\pictures.one" lastModifiedTime="2026-09-12T20:08:56.000Z" color="#8AA8E4"><one:Page ID="{D3A0AD9C-D12D-02B2-1683-4D32345076A5}{1}{B0}" name="Before the picture" dateTime="2026-09-12T20:07:15.000Z" lastModifiedTime="2026-09-12T20:08:56.000Z" pageLevel="1"/></one:Section></one:Notebook> | ||
corpus/picture-edit/inserted/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="{D3A0AD9C-D12D-02B2-1683-4D32345076A5}{1}{B0}" name="Before the picture" dateTime="2026-09-12T20:07:15.000Z" lastModifiedTime="2026-09-12T20:08:56.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="{54DE5097-F4BE-0BCC-0392-19FB4210AAAD}{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="{54DE5097-F4BE-0BCC-0392-19FB4210AAAD}{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="{496D54CF-B74C-091A-2D00-F268C53481D1}{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="{39F25FF2-3F08-07F1-06E1-F7F0075DE8F1}{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/inserted/cold/read/payloads.json created+3| ... | @@ -0,0 +1,3 @@ | ||
| 1 | [ | ||
| 2 | |||
| 3 | ] | ||
| \ No newline at end of file | |||
corpus/picture-edit/inserted/cold/run.json created+18| ... | @@ -0,0 +1,18 @@ | ||
| 1 | { | ||
| 2 | "notebook": "/private/tmp/m4/pictures", | ||
| 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/inserted/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/inserted/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/inserted/cold/source.json created+14| ... | @@ -0,0 +1,14 @@ | ||
| 1 | [ | ||
| 2 | { | ||
| 3 | "path": "Open Notebook.onetoc2", | ||
| 4 | "bytes": 1604, | ||
| 5 | "sha256": "0eddd691b52d26bac222cf7e52add3c9909446647b452fb193f03d49e273426a", | ||
| 6 | "mtime_ns": 1789243635388246438 | ||
| 7 | }, | ||
| 8 | { | ||
| 9 | "path": "pictures.one", | ||
| 10 | "bytes": 7064, | ||
| 11 | "sha256": "3076880e02cf74eb85400852c133060ab8ed50383c6b9df8a07bde8389891a70", | ||
| 12 | "mtime_ns": 1789243635387879438 | ||
| 13 | } | ||
| 14 | ] | ||
corpus/picture-edit/inserted/cold/teardown.json created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | {"absent": true} | ||
corpus/picture-edit/native-resize/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="{793ED43C-73DF-0C13-3ECD-AF01DCB57EC2}{1}{B0}" name="Before the picture" dateTime="2026-09-12T20:07:15.000Z" lastModifiedTime="2026-09-12T21:15:06.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="{FE402937-564C-056D-2BDC-FBC8AAF5A2CA}{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="{FE402937-564C-056D-2BDC-FBC8AAF5A2CA}{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="{E3F32D6F-15BE-07BB-054E-105B2DD189B6}{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="{936C2652-9DFA-0950-2EAF-15C3EFB8E096}{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-resize/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-3BE178F1", "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-3BE178F1 -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-resize/machine.json created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | {"name": "m6-3be178f1", "hostname": "ONE-M6-3BE178F1"} | ||
corpus/picture-edit/native-resize/notebook/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/picture-edit/native-resize/notebook/Open Notebook.onetoc2 differ | |||
corpus/picture-edit/native-resize/notebook/pictures.one created| Binary files /dev/null and b/corpus/picture-edit/native-resize/notebook/pictures.one differ | |||
corpus/picture-edit/native-resize/read/environment.json created+7| ... | @@ -0,0 +1,7 @@ | ||
| 1 | { | ||
| 2 | "powershell": "5.1.14409.1005", | ||
| 3 | "schema": "xs2010", | ||
| 4 | "hostname": "ONE-M6-3BE178F1", | ||
| 5 | "cold": false, | ||
| 6 | "onenote": "14.0.4763.1000" | ||
| 7 | } | ||
corpus/picture-edit/native-resize/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="{2DCFFDE2-71DF-4568-9FB9-9B223E2A8DEC}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-12T21:16:38.000Z" color="#B49EDE"><one:Section name="pictures" ID="{886B6EBB-7FAD-0D44-0372-05E7CC43DFC7}{1}{B0}" path="C:\one-tests\runs\capture\notebook\pictures.one" lastModifiedTime="2026-09-12T21:16:38.000Z" color="#8AA8E4"><one:Page ID="{0F1593B0-5A3E-043A-1663-512EBA0303CF}{1}{B0}" name="Before the picture" dateTime="2026-09-12T20:07:15.000Z" lastModifiedTime="2026-09-12T21:16:38.000Z" pageLevel="1"/></one:Section></one:Notebook> | ||
corpus/picture-edit/native-resize/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="{0F1593B0-5A3E-043A-1663-512EBA0303CF}{1}{B0}" name="Before the picture" dateTime="2026-09-12T20:07:15.000Z" lastModifiedTime="2026-09-12T21:16:38.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:15:09.000Z" objectID="{886B6EBB-7FAD-0D44-0372-05E7CC43DFC7}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="171.0" height="134.8554229736328"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-12T21:15:09.000Z" lastModifiedTime="2026-09-12T21:15:09.000Z" objectID="{886B6EBB-7FAD-0D44-0372-05E7CC43DFC7}{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:15:09.000Z" objectID="{95D86AE3-3C5F-0F92-2DE0-EE744B67F4BB}{0}{B0}" alignment="left"><one:Image format="png" alt="Resized by OneNote" originalPageNumber="0"><one:Size width="144.0" height="108.0" isSetByUser="true"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w | ||
| 3 | AAAAAElFTkSuQmCC | ||
| 4 | </one:Data></one:Image></one:OE><one:OE creationTime="2026-09-12T21:15:09.000Z" lastModifiedTime="2026-09-12T21:15:09.000Z" objectID="{E54761DE-B41B-0179-0601-EBEC890E9D9B}{0}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[After the picture]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page> | ||
corpus/picture-edit/native-resize/read/payloads.json created+3| ... | @@ -0,0 +1,3 @@ | ||
| 1 | [ | ||
| 2 | |||
| 3 | ] | ||
| \ No newline at end of file | |||
corpus/picture-edit/native-resize/run.json created+19| ... | @@ -0,0 +1,19 @@ | ||
| 1 | { | ||
| 2 | "notebook": "/Users/clo/dev/one/corpus/picture-edit/candidate", | ||
| 3 | "expected_pages": 1, | ||
| 4 | "author": "tools/native/picture-edit.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": "ac663d020a6049b4b688aed1be4ae2661a59fff25d83fe7c5eaabbf14d357f55", | ||
| 16 | "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", | ||
| 17 | "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41" | ||
| 18 | } | ||
| 19 | } | ||
corpus/picture-edit/native-resize/scripts/author.ps1 created+49| ... | @@ -0,0 +1,49 @@ | ||
| 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 | $images = @($page.SelectNodes('//*[local-name()="Image"]')) | ||
| 28 | if ($images.Count -ne 1) { throw 'Expected one picture on the page.' } | ||
| 29 | [IO.File]::WriteAllText((Join-Path $Root 'before.xml'), $content, [Text.Encoding]::UTF8) | ||
| 30 | $image = $images[0] | ||
| 31 | $image.SetAttribute('alt', 'Resized by OneNote') | ||
| 32 | $existing = $image.SelectSingleNode('*[local-name()="Size"]') | ||
| 33 | if ($existing) { [void]$image.RemoveChild($existing) } | ||
| 34 | $size = $page.CreateElement('one', 'Size', $namespace) | ||
| 35 | $size.SetAttribute('width', '144') | ||
| 36 | $size.SetAttribute('height', '108') | ||
| 37 | $size.SetAttribute('isSetByUser', 'true') | ||
| 38 | [void]$image.PrependChild($size) | ||
| 39 | [IO.File]::WriteAllText((Join-Path $Root 'update.xml'), $page.OuterXml, [Text.Encoding]::UTF8) | ||
| 40 | $app.UpdatePageContent($page.OuterXml, [DateTime]::MinValue, 1, $false) | ||
| 41 | $app.SyncHierarchy($notebookId) | ||
| 42 | $app.CloseNotebook($notebookId, $false) | ||
| 43 | $notebookId = '' | ||
| 44 | } finally { | ||
| 45 | if ($notebookId) { $app.CloseNotebook($notebookId, $false) } | ||
| 46 | [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) | ||
| 47 | [GC]::Collect() | ||
| 48 | [GC]::WaitForPendingFinalizers() | ||
| 49 | } | ||
corpus/picture-edit/native-resize/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-resize/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-resize/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-resize/teardown.json created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | {"absent": true} | ||
corpus/picture-edit/native-resize/update.xml created+3| ... | @@ -0,0 +1,3 @@ | ||
| 1 | <?xml version="1.0"?><one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{793ED43C-73DF-0C13-3ECD-AF01DCB57EC2}{1}{B0}" name="Before the picture" dateTime="2026-09-12T20:07:15.000Z" lastModifiedTime="2026-09-12T21:15:06.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="{FE402937-564C-056D-2BDC-FBC8AAF5A2CA}{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="{FE402937-564C-056D-2BDC-FBC8AAF5A2CA}{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="{E3F32D6F-15BE-07BB-054E-105B2DD189B6}{0}{B0}" alignment="left"><one:Image format="png" alt="Resized by OneNote"><one:Size width="144" height="108" isSetByUser="true" /><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="{936C2652-9DFA-0950-2EAF-15C3EFB8E096}{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> | ||
| \ No newline at end of file | |||
corpus/picture-edit/resized/candidate/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/picture-edit/resized/candidate/Open Notebook.onetoc2 differ | |||
corpus/picture-edit/resized/candidate/synthetic.one created| Binary files /dev/null and b/corpus/picture-edit/resized/candidate/synthetic.one differ | |||
corpus/picture-edit/resized/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-49185CAA -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/resized/cold/machine.json created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | {"name": "m6-49185caa", "hostname": "ONE-M6-49185CAA"} | ||
corpus/picture-edit/resized/cold/notebook/Open Notebook.onetoc2 created| Binary files /dev/null and b/corpus/picture-edit/resized/cold/notebook/Open Notebook.onetoc2 differ | |||
corpus/picture-edit/resized/cold/notebook/synthetic.one created| Binary files /dev/null and b/corpus/picture-edit/resized/cold/notebook/synthetic.one differ | |||
corpus/picture-edit/resized/cold/read/environment.json created+7| ... | @@ -0,0 +1,7 @@ | ||
| 1 | { | ||
| 2 | "powershell": "5.1.14409.1005", | ||
| 3 | "schema": "xs2010", | ||
| 4 | "hostname": "ONE-M6-49185CAA", | ||
| 5 | "cold": true, | ||
| 6 | "onenote": "14.0.4763.1000" | ||
| 7 | } | ||
corpus/picture-edit/resized/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="{D280F52D-D262-4B6A-A88B-95A566ED5B46}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-12T21:21:40.000Z" color="#B49EDE"><one:Section name="synthetic" ID="{FA31A0CC-36F2-0200-3C00-C8AE231D89D9}{1}{B0}" path="C:\one-tests\runs\capture\notebook\synthetic.one" lastModifiedTime="2026-09-12T21:21:40.000Z" color="#8AA8E4"><one:Page ID="{D678534A-BF39-4111-BB67-603FDF2595C6}{1}{B0}" name="Fictitious: café, 東京, مرحبا" dateTime="2026-09-05T05:06:54.000Z" lastModifiedTime="2026-09-12T21:21:40.000Z" pageLevel="1"/></one:Section></one:Notebook> | ||
corpus/picture-edit/resized/cold/read/page-000.xml created+8| ... | @@ -0,0 +1,8 @@ | ||
| 1 | <?xml version="1.0"?> | ||
| 2 | <one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{D678534A-BF39-4111-BB67-603FDF2595C6}{1}{B0}" name="Fictitious: café, 東京, مرحبا" dateTime="2026-09-05T05:06:54.000Z" lastModifiedTime="2026-09-12T21:21:40.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="snow" authorInitials="S" lastModifiedBy="snow" lastModifiedByInitials="S" lastModifiedTime="2026-09-05T05:06:56.000Z" objectID="{B1B4FBF6-F44C-42A7-B495-ECE420B970A7}{10}{B0}"><one:Position x="36.0" y="14.40000057220459" z="0"/><one:Size width="127.5136947631836" height="14.30000114440918"/><one:OEChildren><one:OE creationTime="2026-09-05T05:06:55.000Z" lastModifiedTime="2026-09-05T05:06:56.000Z" objectID="{B1B4FBF6-F44C-42A7-B495-ECE420B970A7}{11}{B0}" alignment="left" quickStyleIndex="0" style="font-size:11.0pt;color:#1F4E79"><one:T><![CDATA[<span | ||
| 3 | style='font-weight:bold;font-family:Calibri' lang=en-US>Fictitious: café, </span><span | ||
| 4 | style='font-weight:bold;font-family:SimSun' lang=en-US>東京</span><span | ||
| 5 | style='font-weight:bold;font-family:Calibri' lang=en-US>, </span><span | ||
| 6 | style='font-weight:bold;font-family:Arial;direction:rtl;unicode-bidi:embed' | ||
| 7 | lang=ar-SA>مرحبا</span>]]></one:T></one:OE></one:OEChildren></one:Outline><one:Outline author="snow" authorInitials="S" lastModifiedBy="snow" lastModifiedByInitials="S" lastModifiedTime="2026-09-05T05:06:57.000Z" objectID="{E70427E3-4719-48FA-AE23-6E6C84B594EE}{10}{B0}"><one:Position x="144.0" y="96.0" z="1"/><one:Size width="167.0449523925781" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-05T05:06:57.000Z" lastModifiedTime="2026-09-05T05:06:57.000Z" objectID="{E70427E3-4719-48FA-AE23-6E6C84B594EE}{11}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Fictitious positioned outline.]]></one:T></one:OE></one:OEChildren></one:Outline><one:Outline author="snow" authorInitials="S" lastModifiedBy="snow" lastModifiedByInitials="S" lastModifiedTime="2026-09-12T21:19:28.000Z" objectID="{DA2D4F6C-A183-4154-9853-C59868D8DC60}{10}{B0}"><one:Position x="144.0" y="192.0" z="2"/><one:Size width="144.0" height="108.0000152587891"/><one:OEChildren><one:OE creationTime="2026-09-05T05:06:58.000Z" lastModifiedTime="2026-09-12T21:19:28.000Z" objectID="{DA2D4F6C-A183-4154-9853-C59868D8DC60}{11}{B0}" alignment="left"><one:Image format="png" alt="Resized in Rust" originalPageNumber="0"><one:Size width="144.0" height="108.0" isSetByUser="true"/><one:Data>iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9J1uoA | ||
| 8 | AAAASUVORK5CYII=</one:Data></one:Image></one:OE></one:OEChildren></one:Outline></one:Page> | ||
corpus/picture-edit/resized/cold/read/payloads.json created+3| ... | @@ -0,0 +1,3 @@ | ||
| 1 | [ | ||
| 2 | |||
| 3 | ] | ||
| \ No newline at end of file | |||
corpus/picture-edit/resized/cold/run.json created+18| ... | @@ -0,0 +1,18 @@ | ||
| 1 | { | ||
| 2 | "notebook": "/private/tmp/m4/resized", | ||
| 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/resized/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/resized/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/resized/cold/source.json created+14| ... | @@ -0,0 +1,14 @@ | ||
| 1 | [ | ||
| 2 | { | ||
| 3 | "path": "Open Notebook.onetoc2", | ||
| 4 | "bytes": 1612, | ||
| 5 | "sha256": "32c3fa727c56069e1c5e885e237b95dad32762b4c2ef269a2b0e566fa534382c", | ||
| 6 | "mtime_ns": 1789247968519334299 | ||
| 7 | }, | ||
| 8 | { | ||
| 9 | "path": "synthetic.one", | ||
| 10 | "bytes": 19040, | ||
| 11 | "sha256": "6cedb887b23e772353a266850d6bbf4f0a0e8ff6d0e6f513b2a1a8af8b2a37d8", | ||
| 12 | "mtime_ns": 1789247968518969882 | ||
| 13 | } | ||
| 14 | ] | ||
corpus/picture-edit/resized/cold/teardown.json created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | {"absent": true} | ||
crates/notebook/README.md+1-1| ... | @@ -14,7 +14,7 @@ diffs the supplied model against the page stored in the supplied local snapshot, | ... | @@ -14,7 +14,7 @@ diffs the supplied model against the page stored in the supplied local snapshot, |
| 14 | writes the difference into the working image and queues one | 14 | writes the difference into the working image and queues one |
| 15 | `Operation::Page(PageIntent { before, after, author })`; `before` is the page the | 15 | `Operation::Page(PageIntent { before, after, author })`; `before` is the page the |
| 16 | edit started from and is the precondition reconciliation checks. Text, styles, | 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 and attachments, paragraph structure, | 17 | paragraph formatting, hyperlinks, bullets, numbering, note tags, table rows and columns, pictures (insertion, size, description) and attachments, paragraph structure, |
| 18 | outline layout, insertions and deletions are all differences between `before` and | 18 | outline layout, insertions and deletions are all differences between `before` and |
| 19 | `after`; the library never sees editor operations. | 19 | `after`; the library never sees editor operations. |
| 20 | 20 |
crates/onestore/README.md+1-1| ... | @@ -57,7 +57,7 @@ harness also accepts `--client-profile release`. | ... | @@ -57,7 +57,7 @@ harness also accepts `--client-profile release`. |
| 57 | | `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules | | 57 | | `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules | |
| 58 | | `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs | | 58 | | `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs | |
| 59 | | `OutlineEdit`, `PreparedEdit::outline` | Change ordinary outline position/width or a paragraph's saved expansion default, preserving identities and content | | 59 | | `OutlineEdit`, `PreparedEdit::outline` | Change ordinary outline position/width or a paragraph's saved expansion default, preserving identities and content | |
| 60 | | `PreparedEdit::page` | Publish an edited `page::Page` as one revision per changed space: text, character and paragraph formatting, hyperlinks, bullets and numbering, note tags, table rows and columns, inserted pictures and attachments, 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 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 | |
| 61 | | `TreeEdit`, `PreparedEdit::tree` | Move or delete a subtree on one page, normalize surviving containers, and replace an emptied table cell's paragraph atomically | | 61 | | `TreeEdit`, `PreparedEdit::tree` | Move or delete a subtree on one page, normalize surviving containers, and replace an emptied table cell's paragraph atomically | |
| 62 | | `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter | | 62 | | `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter | |
| 63 | | `read_file` | Read a snapshot under whole-file exclusion | | 63 | | `read_file` | Read a snapshot under whole-file exclusion | |
crates/onestore/src/page/write.rs+62-5| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | //! Publishes an edited page model by lowering the difference from the stored page onto | 1 | //! Publishes an edited page model by lowering the difference from the stored page onto |
| 2 | //! the typed writers, then squashing their transactions into one revision per space. | 2 | //! the typed writers, then squashing their transactions into one revision per space. |
| 3 | 3 | ||
| 4 | use super::{Outline, Page, PageObject, PageParagraph, ParagraphContent, Table}; | 4 | use super::{Image, Outline, Page, PageObject, PageParagraph, ParagraphContent, Table}; |
| 5 | use crate::{ | 5 | use crate::{ |
| 6 | Error, ExGuid, Insertion, ObjectData, OutlineEdit, ParagraphJoin, ParagraphSplit, PropertySets, | 6 | Error, ExGuid, Insertion, ObjectData, OutlineEdit, ParagraphJoin, ParagraphSplit, PropertySets, |
| 7 | RevisionIndex, Store, TextAttribute, TreeEdit, Value, | 7 | RevisionIndex, Store, TextAttribute, TreeEdit, Value, |
| ... | @@ -386,8 +386,15 @@ impl Lowering<'_> { | ... | @@ -386,8 +386,15 @@ impl Lowering<'_> { |
| 386 | } | 386 | } |
| 387 | (ParagraphContent::Unsupported(a), ParagraphContent::Unsupported(b)) if a == b => {} | 387 | (ParagraphContent::Unsupported(a), ParagraphContent::Unsupported(b)) if a == b => {} |
| 388 | (ParagraphContent::Image(a), ParagraphContent::Image(b)) => { | 388 | (ParagraphContent::Image(a), ParagraphContent::Image(b)) => { |
| 389 | if a != b { | 389 | if a.id != b.id |
| 390 | return Err(invalid("A stored picture cannot be edited")); | 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 | )); | ||
| 391 | } | 398 | } |
| 392 | } | 399 | } |
| 393 | (ParagraphContent::Attachment(a), ParagraphContent::Attachment(b)) => { | 400 | (ParagraphContent::Attachment(a), ParagraphContent::Attachment(b)) => { |
| ... | @@ -753,8 +760,11 @@ impl Lowering<'_> { | ... | @@ -753,8 +760,11 @@ impl Lowering<'_> { |
| 753 | continue; | 760 | continue; |
| 754 | }; | 761 | }; |
| 755 | if let Some(previous) = old.paragraphs.get(paragraph_id) { | 762 | if let Some(previous) = old.paragraphs.get(paragraph_id) { |
| 756 | if previous.content != paragraph.content { | 763 | let ParagraphContent::Image(stored) = &previous.content else { |
| 757 | return Err(invalid("A stored picture cannot be edited")); | 764 | return Err(invalid("Paragraph content type cannot change")); |
| 765 | }; | ||
| 766 | if stored != image { | ||
| 767 | self.resize_image(stored, image)?; | ||
| 758 | } | 768 | } |
| 759 | continue; | 769 | continue; |
| 760 | } | 770 | } |
| ... | @@ -824,6 +834,53 @@ impl Lowering<'_> { | ... | @@ -824,6 +834,53 @@ impl Lowering<'_> { |
| 824 | Ok(()) | 834 | Ok(()) |
| 825 | } | 835 | } |
| 826 | 836 | ||
| 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> { | ||
| 840 | let mut values: Values = vec![( | ||
| 841 | 0x14001d7a, | ||
| 842 | crate::create::current_timestamps()? | ||
| 843 | .0 | ||
| 844 | .to_le_bytes() | ||
| 845 | .to_vec(), | ||
| 846 | )]; | ||
| 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 | } | ||
| 863 | if image.alt != stored.alt { | ||
| 864 | match &image.alt { | ||
| 865 | Some(alt) => values.push((0x1c001e58, crate::create::string(alt))), | ||
| 866 | None => removed.push(0x1c001e58), | ||
| 867 | } | ||
| 868 | } | ||
| 869 | let (space, object) = (self.space, self.id(image.id)); | ||
| 870 | self.apply(|current| { | ||
| 871 | crate::write::write_revision(current, space, |raw| { | ||
| 872 | let mut picture = PropertyObject::from_object(&raw.objects[&object])?; | ||
| 873 | picture.remove(&removed)?; | ||
| 874 | let values: Vec<(u32, &[u8])> = values | ||
| 875 | .iter() | ||
| 876 | .map(|(id, bytes)| (*id, bytes.as_slice())) | ||
| 877 | .collect(); | ||
| 878 | picture.set(&values)?; | ||
| 879 | Ok(BTreeMap::from([(object, picture)])) | ||
| 880 | }) | ||
| 881 | }) | ||
| 882 | } | ||
| 883 | |||
| 827 | fn place_containers( | 884 | fn place_containers( |
| 828 | &mut self, | 885 | &mut self, |
| 829 | old: &View<'_>, | 886 | old: &View<'_>, |
crates/onestore/tests/page_images.rs+82-1| ... | @@ -1,12 +1,14 @@ | ... | @@ -1,12 +1,14 @@ |
| 1 | use onestore::{ | 1 | use onestore::{ |
| 2 | ExGuid, PreparedEdit, RevisionIndex, Store, | 2 | ExGuid, PreparedEdit, RevisionIndex, Store, |
| 3 | document::{Document, Format}, | 3 | document::{Document, Format, Layout}, |
| 4 | page::{Image, Page, PageObject, PageParagraph, ParagraphContent, TextObject, text::new_id}, | 4 | page::{Image, Page, PageObject, PageParagraph, ParagraphContent, TextObject, text::new_id}, |
| 5 | }; | 5 | }; |
| 6 | use std::sync::Arc; | 6 | use std::sync::Arc; |
| 7 | 7 | ||
| 8 | const NATIVE: &[u8] = | 8 | const NATIVE: &[u8] = |
| 9 | include_bytes!("../../../corpus/native/cold-05-05-image/notebook/synthetic.one"); | 9 | include_bytes!("../../../corpus/native/cold-05-05-image/notebook/synthetic.one"); |
| 10 | const NATIVE_RESIZE: &[u8] = | ||
| 11 | include_bytes!("../../../corpus/picture-edit/native-resize/notebook/pictures.one"); | ||
| 10 | const AUTHOR: &str = "Picture author"; | 12 | const AUTHOR: &str = "Picture author"; |
| 11 | /// A one-pixel PNG. | 13 | /// A one-pixel PNG. |
| 12 | const PNG: &[u8] = &[ | 14 | const PNG: &[u8] = &[ |
| ... | @@ -155,6 +157,85 @@ fn a_picture_inserted_on_a_fresh_page_reads_back_and_can_be_removed() { | ... | @@ -155,6 +157,85 @@ fn a_picture_inserted_on_a_fresh_page_reads_back_and_can_be_removed() { |
| 155 | assert_eq!(body_paragraphs(&mut stored.clone()).len(), 2); | 157 | assert_eq!(body_paragraphs(&mut stored.clone()).len(), 2); |
| 156 | } | 158 | } |
| 157 | 159 | ||
| 160 | fn resize(page: &mut Page, layout: Layout, alt: Option<&str>) -> ExGuid { | ||
| 161 | let mut found = None; | ||
| 162 | for object in &mut page.objects { | ||
| 163 | if let PageObject::Outline(outline) = object { | ||
| 164 | for paragraph in &mut outline.paragraphs { | ||
| 165 | if let ParagraphContent::Image(image) = &mut paragraph.content { | ||
| 166 | image.layout = layout.clone(); | ||
| 167 | image.alt = alt.map(str::to_owned); | ||
| 168 | assert!(found.replace(image.id).is_none()); | ||
| 169 | } | ||
| 170 | } | ||
| 171 | } | ||
| 172 | } | ||
| 173 | found.unwrap() | ||
| 174 | } | ||
| 175 | |||
| 176 | /// `ONESTORE_IMAGE_RESIZE_EXPORT` names a new directory receiving the candidate for a cold reopen. | ||
| 177 | #[test] | ||
| 178 | fn a_native_picture_is_resized_and_described_then_reset() { | ||
| 179 | let (space, native) = first_page(NATIVE); | ||
| 180 | let mut resized = native.clone(); | ||
| 181 | let layout = Layout { | ||
| 182 | max_width: Some(144.0), | ||
| 183 | width_set_by_user: Some(true), | ||
| 184 | max_height: Some(108.0), | ||
| 185 | ..Default::default() | ||
| 186 | }; | ||
| 187 | resize(&mut resized, layout, Some("Resized in Rust")); | ||
| 188 | let written = PreparedEdit::page(NATIVE, space, &resized, AUTHOR).unwrap(); | ||
| 189 | let stored = page_in(written.as_bytes(), space); | ||
| 190 | assert_eq!(stored, resized); | ||
| 191 | assert_eq!( | ||
| 192 | PreparedEdit::page(written.as_bytes(), space, &stored, AUTHOR) | ||
| 193 | .unwrap() | ||
| 194 | .as_bytes(), | ||
| 195 | written.as_bytes() | ||
| 196 | ); | ||
| 197 | if let Some(directory) = std::env::var_os("ONESTORE_IMAGE_RESIZE_EXPORT") { | ||
| 198 | let directory = std::path::PathBuf::from(directory); | ||
| 199 | std::fs::create_dir(&directory).unwrap(); | ||
| 200 | std::fs::write(directory.join("synthetic.one"), written.as_bytes()).unwrap(); | ||
| 201 | let written_store = Store::parse(written.as_bytes()).unwrap(); | ||
| 202 | std::fs::write( | ||
| 203 | directory.join("Open Notebook.onetoc2"), | ||
| 204 | onestore::create_table_of_contents( | ||
| 205 | "Open Notebook.onetoc2", | ||
| 206 | &[("synthetic.one", written_store.header.file_id)], | ||
| 207 | ) | ||
| 208 | .unwrap(), | ||
| 209 | ) | ||
| 210 | .unwrap(); | ||
| 211 | } | ||
| 212 | let mut reset = stored.clone(); | ||
| 213 | resize(&mut reset, Layout::default(), None); | ||
| 214 | let again = PreparedEdit::page(written.as_bytes(), space, &reset, AUTHOR).unwrap(); | ||
| 215 | assert_eq!(page_in(again.as_bytes(), space), native); | ||
| 216 | } | ||
| 217 | |||
| 218 | #[test] | ||
| 219 | fn a_rust_picture_resized_natively_reads_back_with_its_size_and_description() { | ||
| 220 | let (space, page) = first_page(NATIVE_RESIZE); | ||
| 221 | let found = pictures(&page); | ||
| 222 | let [picture] = found.as_slice() else { | ||
| 223 | panic!("{page:?}"); | ||
| 224 | }; | ||
| 225 | assert_eq!(picture.bytes.as_deref(), Some(PNG)); | ||
| 226 | assert_eq!(picture.size, Some([0.75, 0.75])); | ||
| 227 | assert_eq!(picture.layout.max_width, Some(144.0)); | ||
| 228 | assert_eq!(picture.layout.max_height, Some(108.0)); | ||
| 229 | assert_eq!(picture.layout.width_set_by_user, Some(true)); | ||
| 230 | assert_eq!(picture.alt.as_deref(), Some("Resized by OneNote")); | ||
| 231 | let mut wider = page.clone(); | ||
| 232 | let mut layout = picture.layout.clone(); | ||
| 233 | layout.max_width = Some(200.0); | ||
| 234 | resize(&mut wider, layout, Some("Wider in Rust")); | ||
| 235 | let written = PreparedEdit::page(NATIVE_RESIZE, space, &wider, AUTHOR).unwrap(); | ||
| 236 | assert_eq!(page_in(written.as_bytes(), space), wider); | ||
| 237 | } | ||
| 238 | |||
| 158 | #[test] | 239 | #[test] |
| 159 | fn pictures_need_a_recognised_payload_and_stored_ones_stay_fixed() { | 240 | fn pictures_need_a_recognised_payload_and_stored_ones_stay_fixed() { |
| 160 | let source = onestore::create_section("pictures.one", "Text", "Author").unwrap(); | 241 | let source = onestore::create_section("pictures.one", "Text", "Author").unwrap(); |
tools/native/picture-edit.ps1 created+49| ... | @@ -0,0 +1,49 @@ | ||
| 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 | $images = @($page.SelectNodes('//*[local-name()="Image"]')) | ||
| 28 | if ($images.Count -ne 1) { throw 'Expected one picture on the page.' } | ||
| 29 | [IO.File]::WriteAllText((Join-Path $Root 'before.xml'), $content, [Text.Encoding]::UTF8) | ||
| 30 | $image = $images[0] | ||
| 31 | $image.SetAttribute('alt', 'Resized by OneNote') | ||
| 32 | $existing = $image.SelectSingleNode('*[local-name()="Size"]') | ||
| 33 | if ($existing) { [void]$image.RemoveChild($existing) } | ||
| 34 | $size = $page.CreateElement('one', 'Size', $namespace) | ||
| 35 | $size.SetAttribute('width', '144') | ||
| 36 | $size.SetAttribute('height', '108') | ||
| 37 | $size.SetAttribute('isSetByUser', 'true') | ||
| 38 | [void]$image.PrependChild($size) | ||
| 39 | [IO.File]::WriteAllText((Join-Path $Root 'update.xml'), $page.OuterXml, [Text.Encoding]::UTF8) | ||
| 40 | $app.UpdatePageContent($page.OuterXml, [DateTime]::MinValue, 1, $false) | ||
| 41 | $app.SyncHierarchy($notebookId) | ||
| 42 | $app.CloseNotebook($notebookId, $false) | ||
| 43 | $notebookId = '' | ||
| 44 | } finally { | ||
| 45 | if ($notebookId) { $app.CloseNotebook($notebookId, $false) } | ||
| 46 | [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) | ||
| 47 | [GC]::Collect() | ||
| 48 | [GC]::WaitForPendingFinalizers() | ||
| 49 | } | ||
tools/test_picture_edit.py+31-7| ... | @@ -14,20 +14,44 @@ FIXTURE = ROOT / 'corpus/picture-edit' | ... | @@ -14,20 +14,44 @@ FIXTURE = ROOT / 'corpus/picture-edit' |
| 14 | compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] | 14 | compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] |
| 15 | 15 | ||
| 16 | 16 | ||
| 17 | def native_page(notebook, cold): | ||
| 18 | with TemporaryDirectory() as temporary: | ||
| 19 | read = Path(temporary) / 'read' | ||
| 20 | shutil.copytree(cold / 'read', read) | ||
| 21 | compare(notebook, read) | ||
| 22 | page, = (ET.parse(path).getroot() for path in sorted((cold / 'read').glob('page-*.xml'))) | ||
| 23 | return page | ||
| 24 | |||
| 25 | |||
| 26 | def texts(page): | ||
| 27 | return [re.sub(r'<[^>]*>', '', t.text or '') for t in page.findall('.//one:OE/one:T', ns)] | ||
| 28 | |||
| 29 | |||
| 17 | class PictureEditTest(unittest.TestCase): | 30 | class PictureEditTest(unittest.TestCase): |
| 18 | def test_a_rust_inserted_picture_renders_natively_with_its_payload(self): | 31 | def test_a_rust_inserted_picture_renders_natively_with_its_payload(self): |
| 19 | with TemporaryDirectory() as temporary: | 32 | page = native_page(FIXTURE / 'inserted/candidate', FIXTURE / 'inserted/cold') |
| 20 | read = Path(temporary) / 'read' | ||
| 21 | shutil.copytree(FIXTURE / 'cold/read', read) | ||
| 22 | compare(FIXTURE / 'candidate', read) | ||
| 23 | page, = (ET.parse(path).getroot() for path in sorted((FIXTURE / 'cold/read').glob('page-*.xml'))) | ||
| 24 | image, = page.iter('{%s}Image' % ns['one']) | 33 | image, = page.iter('{%s}Image' % ns['one']) |
| 25 | self.assertEqual(image.get('format'), 'png') | 34 | self.assertEqual(image.get('format'), 'png') |
| 26 | payload = base64.b64decode(image.find('one:Data', ns).text) | 35 | payload = base64.b64decode(image.find('one:Data', ns).text) |
| 27 | self.assertEqual(payload[:8], b'\x89PNG\r\n\x1a\n') | 36 | self.assertEqual(payload[:8], b'\x89PNG\r\n\x1a\n') |
| 28 | self.assertEqual(len(payload), 69) | 37 | self.assertEqual(len(payload), 69) |
| 29 | texts = [re.sub(r'<[^>]*>', '', t.text or '') for t in page.findall('.//one:OE/one:T', ns)] | 38 | self.assertIsNone(image.find('one:Size', ns)) |
| 30 | self.assertEqual(texts, ['Before the picture', 'After the picture']) | 39 | self.assertEqual(texts(page), ['Before the picture', 'After the picture']) |
| 40 | |||
| 41 | def test_onenote_resizes_a_rust_inserted_picture_through_its_layout(self): | ||
| 42 | page = native_page(FIXTURE / 'native-resize/notebook', FIXTURE / 'native-resize') | ||
| 43 | image, = page.iter('{%s}Image' % ns['one']) | ||
| 44 | size = image.find('one:Size', ns) | ||
| 45 | self.assertEqual((size.get('width'), size.get('height'), size.get('isSetByUser')), ('144.0', '108.0', 'true')) | ||
| 46 | self.assertEqual(image.get('alt'), 'Resized by OneNote') | ||
| 47 | self.assertEqual(len(base64.b64decode(image.find('one:Data', ns).text)), 69) | ||
| 48 | |||
| 49 | def test_a_rust_resized_native_picture_renders_with_its_size_and_description(self): | ||
| 50 | page = native_page(FIXTURE / 'resized/candidate', FIXTURE / 'resized/cold') | ||
| 51 | image, = page.iter('{%s}Image' % ns['one']) | ||
| 52 | size = image.find('one:Size', ns) | ||
| 53 | self.assertEqual((size.get('width'), size.get('height'), size.get('isSetByUser')), ('144.0', '108.0', 'true')) | ||
| 54 | self.assertEqual(image.get('alt'), 'Resized in Rust') | ||
| 31 | 55 | ||
| 32 | 56 | ||
| 33 | if __name__ == '__main__': | 57 | if __name__ == '__main__': |