diff --git a/corpus/attachment-edit/README.md b/corpus/attachment-edit/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..c6a769a2c5b96c0d89d5c8a0ac19476a575f437d
--- /dev/null
+++ b/corpus/attachment-edit/README.md
@@ -0,0 +1,23 @@
+# Rust attachment authoring
+
+`plain/candidate` and `icon/candidate` are the page writer's output for
+`an_attachment_inserted_on_a_fresh_page_reads_back_and_can_be_removed` in
+`crates/onestore/tests/page_attachments.rs`: on a section created in Rust, a
+paragraph between two text paragraphs becomes an inserted file
+(`notes 🦀.txt`, 28 bytes), stored the way OneNote stores one
+(`corpus/native/cold-05-06-attachment`): the payload embedded in the
+section's file-data store list, an embedded-file object declaring it by
+identity and extension, and an attachment object with the file name, the
+24 pt icon size and the flags every native attachment carries. `icon/` also
+embeds the native fixture's 724-byte icon PNG as the preview picture;
+`plain/` omits it, which OneNote accepts (it renders its own icon).
+OneNote's integrity check refuses an attachment object without
+`0x1c001d61` (twenty bytes: 16, 1, zeros), so the writer always stores it.
+
+`*/cold` are fresh OneNote 2010 reads: the file reads back as an
+`InsertedFile` with the preferred name and the exact payload bytes, between
+the two text paragraphs. `tools/test_attachment_edit.py` checks both captures
+without a VM. Regenerate with `ONESTORE_ATTACHMENT_EXPORT` set to a directory
+while running the test (it writes `plain/` and `icon/`), then cold-open each
+with `tools/native_runner.py OUTPUT/plain COLD --expected-pages 1
+--collect-notebook`.
diff --git a/corpus/attachment-edit/icon/candidate/Open Notebook.onetoc2 b/corpus/attachment-edit/icon/candidate/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..b1e75d18a4b4afe2e1578f6984ce26245e06e601
Binary files /dev/null and b/corpus/attachment-edit/icon/candidate/Open Notebook.onetoc2 differ
diff --git a/corpus/attachment-edit/icon/candidate/files.one b/corpus/attachment-edit/icon/candidate/files.one
new file mode 100644
index 0000000000000000000000000000000000000000..f2a7bc271a6845ace199cadb3a064020c8629d30
Binary files /dev/null and b/corpus/attachment-edit/icon/candidate/files.one differ
diff --git a/corpus/attachment-edit/icon/cold/commands.jsonl b/corpus/attachment-edit/icon/cold/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..9def35e2e711d7d73fc98cc696645b981257650c
--- /dev/null
+++ b/corpus/attachment-edit/icon/cold/commands.jsonl
@@ -0,0 +1,3 @@
+{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-5534DCBB -ExpectedPages 1", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
diff --git a/corpus/attachment-edit/icon/cold/machine.json b/corpus/attachment-edit/icon/cold/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..37d696a7cd52402b04269e5713066036b300a606
--- /dev/null
+++ b/corpus/attachment-edit/icon/cold/machine.json
@@ -0,0 +1 @@
+{"name": "m6-5534dcbb", "hostname": "ONE-M6-5534DCBB"}
diff --git a/corpus/attachment-edit/icon/cold/notebook/Open Notebook.onetoc2 b/corpus/attachment-edit/icon/cold/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..1257f97ca743e3779ae9999ec188b339781cdd82
Binary files /dev/null and b/corpus/attachment-edit/icon/cold/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/attachment-edit/icon/cold/notebook/files.one b/corpus/attachment-edit/icon/cold/notebook/files.one
new file mode 100644
index 0000000000000000000000000000000000000000..59abe526cb0c8505a61bd396e7d02205f436c264
Binary files /dev/null and b/corpus/attachment-edit/icon/cold/notebook/files.one differ
diff --git a/corpus/attachment-edit/icon/cold/read/b20a14e044e33eac39b4665e7d79813b8a3672dbbd27ed9d2e4ac866c13ec4b6.attachment b/corpus/attachment-edit/icon/cold/read/b20a14e044e33eac39b4665e7d79813b8a3672dbbd27ed9d2e4ac866c13ec4b6.attachment
new file mode 100644
index 0000000000000000000000000000000000000000..ae0d23c6d54266cd83b053571137ccdab0adb80a
--- /dev/null
+++ b/corpus/attachment-edit/icon/cold/read/b20a14e044e33eac39b4665e7d79813b8a3672dbbd27ed9d2e4ac866c13ec4b6.attachment
@@ -0,0 +1 @@
+Rust wrote this attachment.
diff --git a/corpus/attachment-edit/icon/cold/read/environment.json b/corpus/attachment-edit/icon/cold/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..9949345eafe711d01410767db91d603343d39ad9
--- /dev/null
+++ b/corpus/attachment-edit/icon/cold/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-5534DCBB",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/attachment-edit/icon/cold/read/hierarchy.xml b/corpus/attachment-edit/icon/cold/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..06673c4211d0b0a40ffe481d1618adc9e451a38c
--- /dev/null
+++ b/corpus/attachment-edit/icon/cold/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/attachment-edit/icon/cold/read/page-000.xml b/corpus/attachment-edit/icon/cold/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..74db9382bd8c0e7dc06d47345b1bb207d22dc70a
--- /dev/null
+++ b/corpus/attachment-edit/icon/cold/read/page-000.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/attachment-edit/icon/cold/read/payloads.json b/corpus/attachment-edit/icon/cold/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..e23523cda593af0e8951098f8ad12dae2c5a28b5
--- /dev/null
+++ b/corpus/attachment-edit/icon/cold/read/payloads.json
@@ -0,0 +1,10 @@
+[
+ {
+ "sha256": "b20a14e044e33eac39b4665e7d79813b8a3672dbbd27ed9d2e4ac866c13ec4b6",
+ "name": "notes 🦀.txt",
+ "object": "{7FE89054-6937-03E5-03EB-E0B7A346CCCA}{0}{B0}",
+ "kind": "InsertedFile",
+ "page": "{650CBE9C-812A-0909-10A6-1E17B31C633C}{1}{B0}",
+ "bytes": 28
+ }
+]
\ No newline at end of file
diff --git a/corpus/attachment-edit/icon/cold/run.json b/corpus/attachment-edit/icon/cold/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..32b105282d2e3956b9ec59b399b5e5eae8fe081d
--- /dev/null
+++ b/corpus/attachment-edit/icon/cold/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/private/tmp/m4/files/icon",
+ "expected_pages": 1,
+ "author": null,
+ "author_timeout_seconds": 600,
+ "inspect": false,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
+ }
+}
diff --git a/corpus/attachment-edit/icon/cold/scripts/cold.ps1 b/corpus/attachment-edit/icon/cold/scripts/cold.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f
--- /dev/null
+++ b/corpus/attachment-edit/icon/cold/scripts/cold.ps1
@@ -0,0 +1,27 @@
+param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
+$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
+if ($CloneHost) {
+ if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
+ throw 'The disposable clone hostname does not match this machine.'
+ }
+ New-Item "$key\Options\Paths" -Force | Out-Null
+ New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
+} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
+ -not (Test-Path 'C:\one-tests\profile-original-cache')) {
+ throw 'Park the personal OneNote profile before resetting the test cache.'
+}
+$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
+$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
+if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
+New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
+if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
+if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
+New-Item "$key\OpenNotebooks" | Out-Null
+New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
diff --git a/corpus/attachment-edit/icon/cold/scripts/read.ps1 b/corpus/attachment-edit/icon/cold/scripts/read.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b
--- /dev/null
+++ b/corpus/attachment-edit/icon/cold/scripts/read.ps1
@@ -0,0 +1,142 @@
+param(
+ [Parameter(Mandatory=$true)][string]$Root,
+ [int]$ExpectedPages = -1,
+ [switch]$UseCurrentCache,
+ [switch]$Pdf,
+ [switch]$KeepOpen,
+ [string]$CloneHost = ''
+)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+$notebook = Join-Path $root 'notebook'
+$output = Join-Path $root 'read'
+if (Test-Path $output) { throw 'Choose a new read destination.' }
+if ($UseCurrentCache) {
+ if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
+ throw 'Park the personal OneNote profile before reading test notebooks.'
+ }
+} else {
+ & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
+}
+New-Item -ItemType Directory -Path $output | Out-Null
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+$failure = $null
+try {
+ $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
+ $process = Get-Process ONENOTE
+ @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
+ powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
+ ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
+ $sections = @()
+ foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
+ $id = ''
+ $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
+ $sections += $id
+ }
+ $deadline = [DateTime]::UtcNow.AddSeconds(300)
+ $previous = ''
+ $lastChange = ''
+ $stableSince = [DateTime]::UtcNow
+ $settled = $false
+ do {
+ $pages = @{}
+ foreach ($section in $sections) {
+ $hierarchy = ''
+ $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
+ [xml]$xml = $hierarchy
+ foreach ($node in $xml.SelectNodes('//*[@path]')) {
+ if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'OneNote opened a section outside the copied notebook.'
+ }
+ }
+ foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
+ $id = $node.GetAttribute('ID')
+ $content = ''
+ $app.GetPageContent($id, [ref]$content, 1, 1)
+ $pages[$id] = $content
+ }
+ }
+ $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
+ if ($signature -ne $previous) {
+ $lastChange = $previous
+ $previous = $signature
+ $stableSince = [DateTime]::UtcNow
+ }
+ if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
+ ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
+ ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
+ Start-Sleep -Milliseconds 250
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
+ [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
+ $index = 0
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ $index++
+ }
+ $hierarchy = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
+ [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
+ throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
+ }
+ $index = 0
+ $payloads = @()
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ if ($Pdf) {
+ $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
+ $app.NavigateTo($id, '', $false)
+ $app.Publish($id, $pdfPath, 3, '')
+ if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
+ }
+ [xml]$page = $pages[$id]
+ foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
+ $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
+ $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
+ [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
+ $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
+ kind = $file.LocalName; name = $file.GetAttribute('preferredName');
+ sha256 = $hash; bytes = $bytes.Length }
+ }
+ $index++
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
+ $all = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
+ [xml]$finalTree = $all
+ $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
+ if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
+ throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
+ Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
+} catch {
+ $failure = $_
+ [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ throw
+} finally {
+ try {
+ try {
+ if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
+ if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
+ $app.CloseNotebook($notebookId, $false)
+ }
+ } catch {
+ if ($null -eq $failure) { throw }
+ [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ }
+ } finally {
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ }
+ $app = $null
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+ if (-not $UseCurrentCache -and -not $CloneHost) {
+ Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
+ }
+}
diff --git a/corpus/attachment-edit/icon/cold/source.json b/corpus/attachment-edit/icon/cold/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..96577bee44aed9761dff291cf70c0c23e2df240b
--- /dev/null
+++ b/corpus/attachment-edit/icon/cold/source.json
@@ -0,0 +1,14 @@
+[
+ {
+ "path": "Open Notebook.onetoc2",
+ "bytes": 1604,
+ "sha256": "98f31fe15497a403a6e2896c8ae2e6566824c0971e541187046a1581c4612721",
+ "mtime_ns": 1789246495204529692
+ },
+ {
+ "path": "files.one",
+ "bytes": 8288,
+ "sha256": "473e6527a585dc98d178343cf53ba9e08354b3d4d53a4f5ce984c0036ecfd76a",
+ "mtime_ns": 1789246495204285026
+ }
+]
diff --git a/corpus/attachment-edit/icon/cold/teardown.json b/corpus/attachment-edit/icon/cold/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/attachment-edit/icon/cold/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/corpus/attachment-edit/plain/candidate/Open Notebook.onetoc2 b/corpus/attachment-edit/plain/candidate/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..55dbe4e743b89d2e8f6dca8226453e4eaaeab88a
Binary files /dev/null and b/corpus/attachment-edit/plain/candidate/Open Notebook.onetoc2 differ
diff --git a/corpus/attachment-edit/plain/candidate/files.one b/corpus/attachment-edit/plain/candidate/files.one
new file mode 100644
index 0000000000000000000000000000000000000000..f03ffb8cb2275239d0303743363eb292ff26f8da
Binary files /dev/null and b/corpus/attachment-edit/plain/candidate/files.one differ
diff --git a/corpus/attachment-edit/plain/cold/commands.jsonl b/corpus/attachment-edit/plain/cold/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..5c48c3229dfca20efedbed19f4ab4d103d7b830c
--- /dev/null
+++ b/corpus/attachment-edit/plain/cold/commands.jsonl
@@ -0,0 +1,3 @@
+{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-B7D04704 -ExpectedPages 1", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
diff --git a/corpus/attachment-edit/plain/cold/machine.json b/corpus/attachment-edit/plain/cold/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..60691db38b35f7f5adab927b7fec5830c7ccab7c
--- /dev/null
+++ b/corpus/attachment-edit/plain/cold/machine.json
@@ -0,0 +1 @@
+{"name": "m6-b7d04704", "hostname": "ONE-M6-B7D04704"}
diff --git a/corpus/attachment-edit/plain/cold/notebook/Open Notebook.onetoc2 b/corpus/attachment-edit/plain/cold/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..4a5546bae6531fe237ee901e9dd7dacb7796e1e5
Binary files /dev/null and b/corpus/attachment-edit/plain/cold/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/attachment-edit/plain/cold/notebook/files.one b/corpus/attachment-edit/plain/cold/notebook/files.one
new file mode 100644
index 0000000000000000000000000000000000000000..7a04015d191c758afaef2704218fd28f00b7ddeb
Binary files /dev/null and b/corpus/attachment-edit/plain/cold/notebook/files.one differ
diff --git a/corpus/attachment-edit/plain/cold/read/b20a14e044e33eac39b4665e7d79813b8a3672dbbd27ed9d2e4ac866c13ec4b6.attachment b/corpus/attachment-edit/plain/cold/read/b20a14e044e33eac39b4665e7d79813b8a3672dbbd27ed9d2e4ac866c13ec4b6.attachment
new file mode 100644
index 0000000000000000000000000000000000000000..ae0d23c6d54266cd83b053571137ccdab0adb80a
--- /dev/null
+++ b/corpus/attachment-edit/plain/cold/read/b20a14e044e33eac39b4665e7d79813b8a3672dbbd27ed9d2e4ac866c13ec4b6.attachment
@@ -0,0 +1 @@
+Rust wrote this attachment.
diff --git a/corpus/attachment-edit/plain/cold/read/environment.json b/corpus/attachment-edit/plain/cold/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..62364ddd944733ee96ed6015a8b9dcc18a160a55
--- /dev/null
+++ b/corpus/attachment-edit/plain/cold/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-B7D04704",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/attachment-edit/plain/cold/read/hierarchy.xml b/corpus/attachment-edit/plain/cold/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..5a6ff174d2ecb518f85080680083a89384fab38b
--- /dev/null
+++ b/corpus/attachment-edit/plain/cold/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/attachment-edit/plain/cold/read/page-000.xml b/corpus/attachment-edit/plain/cold/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..566f4cd5092d9427a2b0dad6d075f474df0328fb
--- /dev/null
+++ b/corpus/attachment-edit/plain/cold/read/page-000.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/attachment-edit/plain/cold/read/payloads.json b/corpus/attachment-edit/plain/cold/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..ce18cfb2962b0c7f8313c6ce191010486f5a00b0
--- /dev/null
+++ b/corpus/attachment-edit/plain/cold/read/payloads.json
@@ -0,0 +1,10 @@
+[
+ {
+ "sha256": "b20a14e044e33eac39b4665e7d79813b8a3672dbbd27ed9d2e4ac866c13ec4b6",
+ "name": "notes 🦀.txt",
+ "object": "{6DE415DD-4090-0E54-15BB-41A8635DE4EB}{0}{B0}",
+ "kind": "InsertedFile",
+ "page": "{62772D92-5840-014B-3B34-46BA7FAC9472}{1}{B0}",
+ "bytes": 28
+ }
+]
\ No newline at end of file
diff --git a/corpus/attachment-edit/plain/cold/run.json b/corpus/attachment-edit/plain/cold/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..b95bfc1392795e5d9175e140691e211da4b0151f
--- /dev/null
+++ b/corpus/attachment-edit/plain/cold/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/private/tmp/m4/files/plain",
+ "expected_pages": 1,
+ "author": null,
+ "author_timeout_seconds": 600,
+ "inspect": false,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
+ }
+}
diff --git a/corpus/attachment-edit/plain/cold/scripts/cold.ps1 b/corpus/attachment-edit/plain/cold/scripts/cold.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f
--- /dev/null
+++ b/corpus/attachment-edit/plain/cold/scripts/cold.ps1
@@ -0,0 +1,27 @@
+param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
+$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
+if ($CloneHost) {
+ if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
+ throw 'The disposable clone hostname does not match this machine.'
+ }
+ New-Item "$key\Options\Paths" -Force | Out-Null
+ New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
+} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
+ -not (Test-Path 'C:\one-tests\profile-original-cache')) {
+ throw 'Park the personal OneNote profile before resetting the test cache.'
+}
+$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
+$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
+if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
+New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
+if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
+if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
+New-Item "$key\OpenNotebooks" | Out-Null
+New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
diff --git a/corpus/attachment-edit/plain/cold/scripts/read.ps1 b/corpus/attachment-edit/plain/cold/scripts/read.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b
--- /dev/null
+++ b/corpus/attachment-edit/plain/cold/scripts/read.ps1
@@ -0,0 +1,142 @@
+param(
+ [Parameter(Mandatory=$true)][string]$Root,
+ [int]$ExpectedPages = -1,
+ [switch]$UseCurrentCache,
+ [switch]$Pdf,
+ [switch]$KeepOpen,
+ [string]$CloneHost = ''
+)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+$notebook = Join-Path $root 'notebook'
+$output = Join-Path $root 'read'
+if (Test-Path $output) { throw 'Choose a new read destination.' }
+if ($UseCurrentCache) {
+ if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
+ throw 'Park the personal OneNote profile before reading test notebooks.'
+ }
+} else {
+ & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
+}
+New-Item -ItemType Directory -Path $output | Out-Null
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+$failure = $null
+try {
+ $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
+ $process = Get-Process ONENOTE
+ @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
+ powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
+ ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
+ $sections = @()
+ foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
+ $id = ''
+ $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
+ $sections += $id
+ }
+ $deadline = [DateTime]::UtcNow.AddSeconds(300)
+ $previous = ''
+ $lastChange = ''
+ $stableSince = [DateTime]::UtcNow
+ $settled = $false
+ do {
+ $pages = @{}
+ foreach ($section in $sections) {
+ $hierarchy = ''
+ $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
+ [xml]$xml = $hierarchy
+ foreach ($node in $xml.SelectNodes('//*[@path]')) {
+ if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'OneNote opened a section outside the copied notebook.'
+ }
+ }
+ foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
+ $id = $node.GetAttribute('ID')
+ $content = ''
+ $app.GetPageContent($id, [ref]$content, 1, 1)
+ $pages[$id] = $content
+ }
+ }
+ $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
+ if ($signature -ne $previous) {
+ $lastChange = $previous
+ $previous = $signature
+ $stableSince = [DateTime]::UtcNow
+ }
+ if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
+ ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
+ ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
+ Start-Sleep -Milliseconds 250
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
+ [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
+ $index = 0
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ $index++
+ }
+ $hierarchy = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
+ [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
+ throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
+ }
+ $index = 0
+ $payloads = @()
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ if ($Pdf) {
+ $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
+ $app.NavigateTo($id, '', $false)
+ $app.Publish($id, $pdfPath, 3, '')
+ if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
+ }
+ [xml]$page = $pages[$id]
+ foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
+ $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
+ $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
+ [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
+ $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
+ kind = $file.LocalName; name = $file.GetAttribute('preferredName');
+ sha256 = $hash; bytes = $bytes.Length }
+ }
+ $index++
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
+ $all = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
+ [xml]$finalTree = $all
+ $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
+ if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
+ throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
+ Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
+} catch {
+ $failure = $_
+ [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ throw
+} finally {
+ try {
+ try {
+ if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
+ if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
+ $app.CloseNotebook($notebookId, $false)
+ }
+ } catch {
+ if ($null -eq $failure) { throw }
+ [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ }
+ } finally {
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ }
+ $app = $null
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+ if (-not $UseCurrentCache -and -not $CloneHost) {
+ Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
+ }
+}
diff --git a/corpus/attachment-edit/plain/cold/source.json b/corpus/attachment-edit/plain/cold/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..42d2b6dca1bd65182f68a4f513f9e57b21d7cb89
--- /dev/null
+++ b/corpus/attachment-edit/plain/cold/source.json
@@ -0,0 +1,14 @@
+[
+ {
+ "path": "Open Notebook.onetoc2",
+ "bytes": 1604,
+ "sha256": "a0aa6bb1b80a7e9dd300ffef5d547ea08b685b53cbc91abde9829ea8b6054489",
+ "mtime_ns": 1789246495204137318
+ },
+ {
+ "path": "files.one",
+ "bytes": 7160,
+ "sha256": "cdb9b28f8bd716d612675321ad8bb752349c118735331e9d323c9b8e876aea73",
+ "mtime_ns": 1789246495203833985
+ }
+]
diff --git a/corpus/attachment-edit/plain/cold/teardown.json b/corpus/attachment-edit/plain/cold/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/attachment-edit/plain/cold/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/corpus/picture-edit/README.md b/corpus/picture-edit/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..d4793d321000015fda144dd5745d11cbf10c2985
--- /dev/null
+++ b/corpus/picture-edit/README.md
@@ -0,0 +1,20 @@
+# Rust picture authoring
+
+`candidate/` is the page writer's output for
+`a_picture_inserted_on_a_fresh_page_reads_back_and_can_be_removed` in
+`crates/onestore/tests/page_images.rs`: on a section created in Rust, a
+paragraph between two text paragraphs becomes a picture, stored the way
+OneNote stores an inserted picture (`corpus/native/cold-05-05-image`): the
+PNG embedded in the section's file-data store list as a file-data store
+object, a picture-container file object declaring it by identity and
+extension, and a picture object with the displayed size that the paragraph
+holds as content. OneNote's own picture objects also carry a DPAPI-protected
+blob (`0x1c001dfb`) that only the authoring Windows user can decrypt; the
+writer omits it.
+
+`cold/` is a fresh OneNote 2010 read: the image reads back as `format="png"`
+with the exact payload bytes, between the two text paragraphs.
+`tools/test_picture_edit.py` checks this without a VM. Regenerate with
+`ONESTORE_IMAGE_EXPORT` set to a new absolute directory while running the
+test, then cold-open it with `tools/native_runner.py OUTPUT COLD
+--expected-pages 1 --collect-notebook`.
diff --git a/corpus/picture-edit/candidate/Open Notebook.onetoc2 b/corpus/picture-edit/candidate/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..79f8f955db3b58cd2ae471c00ae2b66c95f81f62
Binary files /dev/null and b/corpus/picture-edit/candidate/Open Notebook.onetoc2 differ
diff --git a/corpus/picture-edit/candidate/pictures.one b/corpus/picture-edit/candidate/pictures.one
new file mode 100644
index 0000000000000000000000000000000000000000..af4169c68f7c76ead1f32f25685131438a9fc7a1
Binary files /dev/null and b/corpus/picture-edit/candidate/pictures.one differ
diff --git a/corpus/picture-edit/cold/commands.jsonl b/corpus/picture-edit/cold/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..f077978e124b796017ac989959a7b5e48d27684c
--- /dev/null
+++ b/corpus/picture-edit/cold/commands.jsonl
@@ -0,0 +1,3 @@
+{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-A5A8705B -ExpectedPages 1", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
diff --git a/corpus/picture-edit/cold/machine.json b/corpus/picture-edit/cold/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..80a47e2a0dd156babc8d6e6c895da3a3144fd28a
--- /dev/null
+++ b/corpus/picture-edit/cold/machine.json
@@ -0,0 +1 @@
+{"name": "m6-a5a8705b", "hostname": "ONE-M6-A5A8705B"}
diff --git a/corpus/picture-edit/cold/notebook/Open Notebook.onetoc2 b/corpus/picture-edit/cold/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..836282818b6fe532b91bf32edf78fba4fe160b7a
Binary files /dev/null and b/corpus/picture-edit/cold/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/picture-edit/cold/notebook/pictures.one b/corpus/picture-edit/cold/notebook/pictures.one
new file mode 100644
index 0000000000000000000000000000000000000000..0dd87dcf4b41be4ca1b5b4b921d9462358aae3b1
Binary files /dev/null and b/corpus/picture-edit/cold/notebook/pictures.one differ
diff --git a/corpus/picture-edit/cold/read/environment.json b/corpus/picture-edit/cold/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..f1032fa5ff0c8dddcee05b60f81366ec2671b1f9
--- /dev/null
+++ b/corpus/picture-edit/cold/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-A5A8705B",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/picture-edit/cold/read/hierarchy.xml b/corpus/picture-edit/cold/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..dce86fad6744777127a6c19a6146cadb358f799f
--- /dev/null
+++ b/corpus/picture-edit/cold/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/picture-edit/cold/read/page-000.xml b/corpus/picture-edit/cold/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..aa8840aa5f2fb3e14fe2cb7223dc8638d73f1a92
--- /dev/null
+++ b/corpus/picture-edit/cold/read/page-000.xml
@@ -0,0 +1,4 @@
+
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w
+AAAAAElFTkSuQmCC
+
diff --git a/corpus/picture-edit/cold/read/payloads.json b/corpus/picture-edit/cold/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843
--- /dev/null
+++ b/corpus/picture-edit/cold/read/payloads.json
@@ -0,0 +1,3 @@
+[
+
+]
\ No newline at end of file
diff --git a/corpus/picture-edit/cold/run.json b/corpus/picture-edit/cold/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..6e98c47fb0e8f3f15888fbc77851de46e6db6301
--- /dev/null
+++ b/corpus/picture-edit/cold/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/private/tmp/m4/pictures",
+ "expected_pages": 1,
+ "author": null,
+ "author_timeout_seconds": 600,
+ "inspect": false,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
+ }
+}
diff --git a/corpus/picture-edit/cold/scripts/cold.ps1 b/corpus/picture-edit/cold/scripts/cold.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f
--- /dev/null
+++ b/corpus/picture-edit/cold/scripts/cold.ps1
@@ -0,0 +1,27 @@
+param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
+$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
+if ($CloneHost) {
+ if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
+ throw 'The disposable clone hostname does not match this machine.'
+ }
+ New-Item "$key\Options\Paths" -Force | Out-Null
+ New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
+} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
+ -not (Test-Path 'C:\one-tests\profile-original-cache')) {
+ throw 'Park the personal OneNote profile before resetting the test cache.'
+}
+$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
+$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
+if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
+New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
+if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
+if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
+New-Item "$key\OpenNotebooks" | Out-Null
+New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
diff --git a/corpus/picture-edit/cold/scripts/read.ps1 b/corpus/picture-edit/cold/scripts/read.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b
--- /dev/null
+++ b/corpus/picture-edit/cold/scripts/read.ps1
@@ -0,0 +1,142 @@
+param(
+ [Parameter(Mandatory=$true)][string]$Root,
+ [int]$ExpectedPages = -1,
+ [switch]$UseCurrentCache,
+ [switch]$Pdf,
+ [switch]$KeepOpen,
+ [string]$CloneHost = ''
+)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+$notebook = Join-Path $root 'notebook'
+$output = Join-Path $root 'read'
+if (Test-Path $output) { throw 'Choose a new read destination.' }
+if ($UseCurrentCache) {
+ if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
+ throw 'Park the personal OneNote profile before reading test notebooks.'
+ }
+} else {
+ & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
+}
+New-Item -ItemType Directory -Path $output | Out-Null
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+$failure = $null
+try {
+ $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
+ $process = Get-Process ONENOTE
+ @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
+ powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
+ ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
+ $sections = @()
+ foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
+ $id = ''
+ $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
+ $sections += $id
+ }
+ $deadline = [DateTime]::UtcNow.AddSeconds(300)
+ $previous = ''
+ $lastChange = ''
+ $stableSince = [DateTime]::UtcNow
+ $settled = $false
+ do {
+ $pages = @{}
+ foreach ($section in $sections) {
+ $hierarchy = ''
+ $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
+ [xml]$xml = $hierarchy
+ foreach ($node in $xml.SelectNodes('//*[@path]')) {
+ if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'OneNote opened a section outside the copied notebook.'
+ }
+ }
+ foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
+ $id = $node.GetAttribute('ID')
+ $content = ''
+ $app.GetPageContent($id, [ref]$content, 1, 1)
+ $pages[$id] = $content
+ }
+ }
+ $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
+ if ($signature -ne $previous) {
+ $lastChange = $previous
+ $previous = $signature
+ $stableSince = [DateTime]::UtcNow
+ }
+ if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
+ ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
+ ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
+ Start-Sleep -Milliseconds 250
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
+ [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
+ $index = 0
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ $index++
+ }
+ $hierarchy = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
+ [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
+ throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
+ }
+ $index = 0
+ $payloads = @()
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ if ($Pdf) {
+ $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
+ $app.NavigateTo($id, '', $false)
+ $app.Publish($id, $pdfPath, 3, '')
+ if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
+ }
+ [xml]$page = $pages[$id]
+ foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
+ $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
+ $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
+ [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
+ $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
+ kind = $file.LocalName; name = $file.GetAttribute('preferredName');
+ sha256 = $hash; bytes = $bytes.Length }
+ }
+ $index++
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
+ $all = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
+ [xml]$finalTree = $all
+ $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
+ if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
+ throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
+ Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
+} catch {
+ $failure = $_
+ [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ throw
+} finally {
+ try {
+ try {
+ if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
+ if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
+ $app.CloseNotebook($notebookId, $false)
+ }
+ } catch {
+ if ($null -eq $failure) { throw }
+ [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ }
+ } finally {
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ }
+ $app = $null
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+ if (-not $UseCurrentCache -and -not $CloneHost) {
+ Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
+ }
+}
diff --git a/corpus/picture-edit/cold/source.json b/corpus/picture-edit/cold/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..006d1122e0e6b26381b4b3c32834b6beb5866625
--- /dev/null
+++ b/corpus/picture-edit/cold/source.json
@@ -0,0 +1,14 @@
+[
+ {
+ "path": "Open Notebook.onetoc2",
+ "bytes": 1604,
+ "sha256": "0eddd691b52d26bac222cf7e52add3c9909446647b452fb193f03d49e273426a",
+ "mtime_ns": 1789243635388246438
+ },
+ {
+ "path": "pictures.one",
+ "bytes": 7064,
+ "sha256": "3076880e02cf74eb85400852c133060ab8ed50383c6b9df8a07bde8389891a70",
+ "mtime_ns": 1789243635387879438
+ }
+]
diff --git a/corpus/picture-edit/cold/teardown.json b/corpus/picture-edit/cold/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/picture-edit/cold/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/crates/canvas/src/document.rs b/crates/canvas/src/document.rs
index e5bb2edb513e7607dabd6e5c527cfd4a81550c28..54682ff839128bd574e3f89169fe638450fda89b 100644
--- a/crates/canvas/src/document.rs
+++ b/crates/canvas/src/document.rs
@@ -153,7 +153,11 @@ pub(crate) fn validate_nodes(
return Err(EditError::InvalidStructure);
}
}
- ParagraphContent::Unsupported(_) => return Err(EditError::UnsupportedContent),
+ ParagraphContent::Image(_)
+ | ParagraphContent::Attachment(_)
+ | ParagraphContent::Unsupported(_) => {
+ return Err(EditError::UnsupportedContent);
+ }
ParagraphContent::Table(table) => {
if !ids.insert(table.id)
|| table.rows.is_empty()
diff --git a/crates/canvas/src/editor.rs b/crates/canvas/src/editor.rs
index 3d1a811ea53147e1a523b61498127267119c2447..d783b2c35fd8d4f7daa9b53025f73e5b2774cd3c 100644
--- a/crates/canvas/src/editor.rs
+++ b/crates/canvas/src/editor.rs
@@ -455,7 +455,9 @@ impl TextOutline {
pending.push(visible_paragraphs(cell.paragraphs.iter()));
}
}
- onestore::page::ParagraphContent::Unsupported(_) => {
+ onestore::page::ParagraphContent::Image(_)
+ | onestore::page::ParagraphContent::Attachment(_)
+ | onestore::page::ParagraphContent::Unsupported(_) => {
return Err(EditError::UnsupportedContent);
}
}
@@ -504,7 +506,9 @@ impl TextOutline {
.rect[1]
- 1.86
}
- onestore::page::ParagraphContent::Unsupported(_) => {
+ onestore::page::ParagraphContent::Image(_)
+ | onestore::page::ParagraphContent::Attachment(_)
+ | onestore::page::ParagraphContent::Unsupported(_) => {
return Err(EditError::UnsupportedContent);
}
};
@@ -1690,7 +1694,9 @@ impl CanvasEditor {
.ok_or(EditError::InvalidStructure)?;
f64::from(cell.rect[3]) + 1.68
}
- onestore::page::ParagraphContent::Unsupported(_) => {
+ onestore::page::ParagraphContent::Image(_)
+ | onestore::page::ParagraphContent::Attachment(_)
+ | onestore::page::ParagraphContent::Unsupported(_) => {
return Err(EditError::UnsupportedContent.into());
}
} + f64::from(outline.origin()[1]);
@@ -4348,6 +4354,7 @@ mod tests {
(120.0, 80.0, true),
]
.map(|(x, y, background)| Image {
+ size: None,
id: onestore::page::text::new_id().unwrap(),
layout: onestore::document::Layout {
x: Some(x),
diff --git a/crates/canvas/src/gpu/page.rs b/crates/canvas/src/gpu/page.rs
index 12d41d8c471aec7b13c03692db6d08b709690127..3067f71a5fe3bc961d2ca6f91f66c745203a5733 100644
--- a/crates/canvas/src/gpu/page.rs
+++ b/crates/canvas/src/gpu/page.rs
@@ -907,6 +907,7 @@ mod tests {
(120.0, 60.0, false),
] {
page.objects.push(PageObject::Image(Image {
+ size: None,
id: onestore::page::text::new_id().unwrap(),
bytes: Some(image_bytes.clone()),
layout: Layout {
@@ -1230,6 +1231,7 @@ mod tests {
unsupported: vec![unknown.clone()],
}),
PageObject::Image(Image {
+ size: None,
id: ExGuid::default(),
layout: Layout {
y: Some(150.0),
@@ -1388,6 +1390,7 @@ mod tests {
objects: vec![
outline(2.0, "first"),
PageObject::Image(Image {
+ size: None,
id: ExGuid::default(),
layout: Layout {
x: Some(5.0),
@@ -1506,6 +1509,7 @@ mod tests {
margin_origin: [0.0; 2],
definitions: BTreeMap::new(),
objects: vec![PageObject::Image(Image {
+ size: None,
id: ExGuid::default(),
layout: Layout {
x: Some(2.0),
diff --git a/crates/canvas/src/outline.rs b/crates/canvas/src/outline.rs
index 55eabfb43753088f994672cc08a8c27597b69b7f..c7a989f8c440673f9a6c9ed0d2246b0303c405cf 100644
--- a/crates/canvas/src/outline.rs
+++ b/crates/canvas/src/outline.rs
@@ -374,7 +374,11 @@ impl OutlineLayout {
bottom += f64::from(child.size[1]);
result.append(child, [x, y]);
}
- ParagraphContent::Unsupported(_) => return Err(LayoutError::UnsupportedContent),
+ ParagraphContent::Image(_)
+ | ParagraphContent::Attachment(_)
+ | ParagraphContent::Unsupported(_) => {
+ return Err(LayoutError::UnsupportedContent);
+ }
}
if !(bottom as f32).is_finite() || !result.size[0].is_finite() {
return Err(LayoutError::InvalidSpacing);
diff --git a/crates/notebook/README.md b/crates/notebook/README.md
index 3d4db5159b4d900edd52c2e189a7583132f6cb7f..d2fb85623a5db93250338f7956b8880ced069b87 100644
--- a/crates/notebook/README.md
+++ b/crates/notebook/README.md
@@ -14,7 +14,7 @@ diffs the supplied model against the page stored in the supplied local snapshot,
writes the difference into the working image and queues one
`Operation::Page(PageIntent { before, after, author })`; `before` is the page the
edit started from and is the precondition reconciliation checks. Text, styles,
-paragraph formatting, hyperlinks, bullets, numbering, note tags, table rows and columns, paragraph structure,
+paragraph formatting, hyperlinks, bullets, numbering, note tags, table rows and columns, pictures and attachments, paragraph structure,
outline layout, insertions and deletions are all differences between `before` and
`after`; the library never sees editor operations.
diff --git a/crates/onestore/README.md b/crates/onestore/README.md
index ee8a2d030c56a902c6e51c434e892dfd289f815e..784e3a7dee3818756ef49d8d618e946c16045bd1 100644
--- a/crates/onestore/README.md
+++ b/crates/onestore/README.md
@@ -57,7 +57,7 @@ harness also accepts `--client-profile release`.
| `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules |
| `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs |
| `OutlineEdit`, `PreparedEdit::outline` | Change ordinary outline position/width or a paragraph's saved expansion default, preserving identities and content |
-| `PreparedEdit::page` | Publish an edited `page::Page` as one revision per changed space: text, character and paragraph formatting, hyperlinks, bullets and numbering, note tags, table rows and columns, paragraph insertion/split/join/move/deletion, outline insertion/deletion/position/width and saved collapse state, lowered onto the typed writers with the model's identities |
+| `PreparedEdit::page` | Publish an edited `page::Page` as one revision per changed space: text, character and paragraph formatting, hyperlinks, bullets and numbering, note tags, table rows and columns, inserted pictures 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 |
| `TreeEdit`, `PreparedEdit::tree` | Move or delete a subtree on one page, normalize surviving containers, and replace an emptied table cell's paragraph atomically |
| `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter |
| `read_file` | Read a snapshot under whole-file exclusion |
diff --git a/crates/onestore/src/document.rs b/crates/onestore/src/document.rs
index a640f59089200170ccbb8c09eaa1f35dcfbf9356..9f1c193fbed44e228e23bc2e5fe466369f610d3a 100644
--- a/crates/onestore/src/document.rs
+++ b/crates/onestore/src/document.rs
@@ -374,6 +374,9 @@ pub enum Kind<'a> {
source_path: Option,
recording_id: Option<[u8; 16]>,
recording_type: Option,
+ /// Displayed icon width and height in points.
+ icon_width: Option,
+ icon_height: Option,
},
File {
reference: FileDataReference,
@@ -1161,6 +1164,8 @@ impl<'a> Element<'a> {
source_path: f.text(0x1c001d9d)?,
recording_id: f.fixed(0x1c001c97)?,
recording_type: f.u32(0x14001d24)?,
+ icon_width: f.float(0x140034cd, 36.0)?,
+ icon_height: f.float(0x140034ce, 36.0)?,
},
0x120001 => Kind::Author {
name: f.text(0x1c001d75)?,
diff --git a/crates/onestore/src/page/mod.rs b/crates/onestore/src/page/mod.rs
index ec2d744bbc0825eb4b8e498357f3e99610d48bb9..8322927c7389448b2edc12a9272822548c5a7fef 100644
--- a/crates/onestore/src/page/mod.rs
+++ b/crates/onestore/src/page/mod.rs
@@ -109,9 +109,81 @@ pub struct PageParagraph {
pub enum ParagraphContent {
Text(TextObject),
Table(Table),
+ /// A picture inside the paragraph, as OneNote inserts pictures into outlines.
+ Image(Image),
+ /// An embedded file, as OneNote inserts attachments into outlines.
+ Attachment(Attachment),
Unsupported(Unsupported),
}
+/// Payload bytes are identified by `id`, so equality and serialization leave them out.
+#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
+pub struct Attachment {
+ pub id: ExGuid,
+ /// The name OneNote shows and saves the file under.
+ pub filename: String,
+ /// Where the file was inserted from, when recorded.
+ pub source_path: Option,
+ /// The icon OneNote rendered for the file, when present.
+ pub size: Option<[f32; 2]>,
+ #[serde(skip)]
+ pub bytes: Option>,
+ #[serde(skip)]
+ pub preview: Option>,
+}
+
+impl PartialEq for Attachment {
+ fn eq(&self, other: &Self) -> bool {
+ self.id == other.id
+ && self.filename == other.filename
+ && self.source_path == other.source_path
+ && self.size == other.size
+ }
+}
+
+impl Attachment {
+ fn read(
+ revision: &Revision<'_>,
+ id: ExGuid,
+ node: &crate::document::Element<'_>,
+ ) -> Result {
+ let invalid = |message| Error { offset: 0, message };
+ let Kind::Attachment {
+ container,
+ preview,
+ filename,
+ source_path,
+ icon_width,
+ icon_height,
+ ..
+ } = &node.kind
+ else {
+ unreachable!()
+ };
+ let payload = |target: &Option| -> Result