diff --git a/corpus/picture-edit/README.md b/corpus/picture-edit/README.md
index dff677ee50977d55b8098d7c17b27ee03bdb848d..65049dd9c62515def388a5bdc746c406e5ae0c6e 100644
--- a/corpus/picture-edit/README.md
+++ b/corpus/picture-edit/README.md
@@ -28,9 +28,18 @@ container under a native identity with its DPAPI blob and hash.
layout width, height, user flag and alternative text on the picture object.
`resized/cold` is its cold read with the size and description.
-`tools/test_picture_edit.py` checks all three without a VM. Regenerate the
-candidates with `ONESTORE_IMAGE_EXPORT` and `ONESTORE_IMAGE_RESIZE_EXPORT`
-set to new absolute directories while running the tests, then cold-open
-them with `tools/native_runner.py OUTPUT COLD --expected-pages 1
---collect-notebook`; regenerate `native-resize` with `--author
-tools/native/picture-edit.ps1` on `inserted/candidate`.
+`native-page-level` is OneNote 2010 placing a second picture directly on
+the page of `inserted/candidate` through the COM API
+(`tools/native/page-picture.ps1`): the picture object is a direct page
+child with a position, a user-set size and alternative text, otherwise
+stored like a paragraph picture. `page-level/candidate` is the writer doing
+the same on a fresh section (`a_page_level_picture_is_inserted_moved_and_removed`)
+and `page-level/cold` its cold read with position, size and description.
+
+`tools/test_picture_edit.py` checks all five without a VM. Regenerate the
+candidates with `ONESTORE_IMAGE_EXPORT`, `ONESTORE_IMAGE_RESIZE_EXPORT` and
+`ONESTORE_PAGE_IMAGE_EXPORT` set to new absolute directories while running
+the tests, then cold-open them with `tools/native_runner.py OUTPUT COLD
+--expected-pages 1 --collect-notebook`; regenerate `native-resize` and
+`native-page-level` with `--author tools/native/picture-edit.ps1` or
+`tools/native/page-picture.ps1` on `inserted/candidate`.
diff --git a/corpus/picture-edit/native-page-level/before.xml b/corpus/picture-edit/native-page-level/before.xml
new file mode 100644
index 0000000000000000000000000000000000000000..431c0d90b07c1cd06fa4a0447580414a3ec767f9
--- /dev/null
+++ b/corpus/picture-edit/native-page-level/before.xml
@@ -0,0 +1,4 @@
+
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w
+AAAAAElFTkSuQmCC
+
diff --git a/corpus/picture-edit/native-page-level/commands.jsonl b/corpus/picture-edit/native-page-level/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..5d635b05ee20ad2ec36994fce9d183c069fed877
--- /dev/null
+++ b/corpus/picture-edit/native-page-level/commands.jsonl
@@ -0,0 +1,4 @@
+{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\author.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-AEEC289C", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-AEEC289C -ExpectedPages 1 -UseCurrentCache", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
diff --git a/corpus/picture-edit/native-page-level/machine.json b/corpus/picture-edit/native-page-level/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..82b03cffda7526178cfc483a77475de268f4cd8e
--- /dev/null
+++ b/corpus/picture-edit/native-page-level/machine.json
@@ -0,0 +1 @@
+{"name": "m6-aeec289c", "hostname": "ONE-M6-AEEC289C"}
diff --git a/corpus/picture-edit/native-page-level/notebook/Open Notebook.onetoc2 b/corpus/picture-edit/native-page-level/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..a7bcb6b4cf63c11f7984c722d32485a2ba522578
Binary files /dev/null and b/corpus/picture-edit/native-page-level/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/picture-edit/native-page-level/notebook/pictures.one b/corpus/picture-edit/native-page-level/notebook/pictures.one
new file mode 100644
index 0000000000000000000000000000000000000000..63960ef8c80421a99919771bf8b17b4ba8f454a8
Binary files /dev/null and b/corpus/picture-edit/native-page-level/notebook/pictures.one differ
diff --git a/corpus/picture-edit/native-page-level/read/environment.json b/corpus/picture-edit/native-page-level/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..0427c085badb9ac00fa1dba3eb7e34abde74d7d5
--- /dev/null
+++ b/corpus/picture-edit/native-page-level/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-AEEC289C",
+ "cold": false,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/picture-edit/native-page-level/read/hierarchy.xml b/corpus/picture-edit/native-page-level/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..083badcde1ead5fe15ec3685417359b488be0a5b
--- /dev/null
+++ b/corpus/picture-edit/native-page-level/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/picture-edit/native-page-level/read/page-000.xml b/corpus/picture-edit/native-page-level/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..121594d4b29b5442a36d7737ea810ad485a9b2c9
--- /dev/null
+++ b/corpus/picture-edit/native-page-level/read/page-000.xml
@@ -0,0 +1,6 @@
+
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w
+AAAAAElFTkSuQmCC
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w
+AAAAAElFTkSuQmCC
+
diff --git a/corpus/picture-edit/native-page-level/read/payloads.json b/corpus/picture-edit/native-page-level/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843
--- /dev/null
+++ b/corpus/picture-edit/native-page-level/read/payloads.json
@@ -0,0 +1,3 @@
+[
+
+]
\ No newline at end of file
diff --git a/corpus/picture-edit/native-page-level/run.json b/corpus/picture-edit/native-page-level/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..96eb07c254c2b21a68d68f5e82a188f45ca54c8c
--- /dev/null
+++ b/corpus/picture-edit/native-page-level/run.json
@@ -0,0 +1,19 @@
+{
+ "notebook": "/Users/clo/dev/one/corpus/picture-edit/inserted/candidate",
+ "expected_pages": 1,
+ "author": "tools/native/page-picture.ps1",
+ "author_timeout_seconds": 600,
+ "inspect": false,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "author.ps1": "c1a1f13ab4890f968dad99000cc24dd076e3676e9944c49fe3b0107d75273414",
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
+ }
+}
diff --git a/corpus/picture-edit/native-page-level/scripts/author.ps1 b/corpus/picture-edit/native-page-level/scripts/author.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..83021a116d4982bea1706f7ca290799c7443f512
--- /dev/null
+++ b/corpus/picture-edit/native-page-level/scripts/author.ps1
@@ -0,0 +1,56 @@
+param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost
+$namespace = 'http://schemas.microsoft.com/office/onenote/2010/onenote'
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+try {
+ $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0)
+ $sectionId = ''
+ $app.OpenHierarchy('pictures.one', $notebookId, [ref]$sectionId, 0)
+ $deadline = [DateTime]::UtcNow.AddSeconds(30)
+ $page = $null
+ do {
+ $hierarchy = ''
+ $app.GetHierarchy($sectionId, 4, [ref]$hierarchy, 1)
+ [xml]$tree = $hierarchy
+ $pages = @($tree.SelectNodes('//*[local-name()="Page"]'))
+ if ($pages.Count -eq 1) {
+ $content = ''
+ $app.GetPageContent($pages[0].GetAttribute('ID'), [ref]$content, 3, 1)
+ [xml]$page = $content
+ if (@($page.SelectNodes('//*[local-name()="Image"]')).Count -eq 1) { break }
+ }
+ Start-Sleep -Milliseconds 250
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if (@($page.SelectNodes('//*[local-name()="Image"]')).Count -ne 1) { throw 'Expected one picture on the page.' }
+ [IO.File]::WriteAllText((Join-Path $Root 'before.xml'), $content, [Text.Encoding]::UTF8)
+ $data = $page.SelectSingleNode('//*[local-name()="Image"]/*[local-name()="Data"]').InnerText
+ $image = $page.CreateElement('one', 'Image', $namespace)
+ $image.SetAttribute('format', 'png')
+ $image.SetAttribute('alt', 'Page-level picture')
+ $position = $page.CreateElement('one', 'Position', $namespace)
+ $position.SetAttribute('x', '360')
+ $position.SetAttribute('y', '240')
+ [void]$image.AppendChild($position)
+ $size = $page.CreateElement('one', 'Size', $namespace)
+ $size.SetAttribute('width', '96')
+ $size.SetAttribute('height', '72')
+ $size.SetAttribute('isSetByUser', 'true')
+ [void]$image.AppendChild($size)
+ $payload = $page.CreateElement('one', 'Data', $namespace)
+ $payload.InnerText = $data
+ [void]$image.AppendChild($payload)
+ [void]$page.DocumentElement.AppendChild($image)
+ [IO.File]::WriteAllText((Join-Path $Root 'update.xml'), $page.OuterXml, [Text.Encoding]::UTF8)
+ $app.UpdatePageContent($page.OuterXml, [DateTime]::MinValue, 1, $false)
+ $app.SyncHierarchy($notebookId)
+ $app.CloseNotebook($notebookId, $false)
+ $notebookId = ''
+} finally {
+ if ($notebookId) { $app.CloseNotebook($notebookId, $false) }
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+}
diff --git a/corpus/picture-edit/native-page-level/scripts/cold.ps1 b/corpus/picture-edit/native-page-level/scripts/cold.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f
--- /dev/null
+++ b/corpus/picture-edit/native-page-level/scripts/cold.ps1
@@ -0,0 +1,27 @@
+param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
+$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
+if ($CloneHost) {
+ if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
+ throw 'The disposable clone hostname does not match this machine.'
+ }
+ New-Item "$key\Options\Paths" -Force | Out-Null
+ New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
+} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
+ -not (Test-Path 'C:\one-tests\profile-original-cache')) {
+ throw 'Park the personal OneNote profile before resetting the test cache.'
+}
+$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
+$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
+if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
+New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
+if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
+if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
+New-Item "$key\OpenNotebooks" | Out-Null
+New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
diff --git a/corpus/picture-edit/native-page-level/scripts/read.ps1 b/corpus/picture-edit/native-page-level/scripts/read.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b
--- /dev/null
+++ b/corpus/picture-edit/native-page-level/scripts/read.ps1
@@ -0,0 +1,142 @@
+param(
+ [Parameter(Mandatory=$true)][string]$Root,
+ [int]$ExpectedPages = -1,
+ [switch]$UseCurrentCache,
+ [switch]$Pdf,
+ [switch]$KeepOpen,
+ [string]$CloneHost = ''
+)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+$notebook = Join-Path $root 'notebook'
+$output = Join-Path $root 'read'
+if (Test-Path $output) { throw 'Choose a new read destination.' }
+if ($UseCurrentCache) {
+ if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
+ throw 'Park the personal OneNote profile before reading test notebooks.'
+ }
+} else {
+ & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
+}
+New-Item -ItemType Directory -Path $output | Out-Null
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+$failure = $null
+try {
+ $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
+ $process = Get-Process ONENOTE
+ @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
+ powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
+ ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
+ $sections = @()
+ foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
+ $id = ''
+ $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
+ $sections += $id
+ }
+ $deadline = [DateTime]::UtcNow.AddSeconds(300)
+ $previous = ''
+ $lastChange = ''
+ $stableSince = [DateTime]::UtcNow
+ $settled = $false
+ do {
+ $pages = @{}
+ foreach ($section in $sections) {
+ $hierarchy = ''
+ $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
+ [xml]$xml = $hierarchy
+ foreach ($node in $xml.SelectNodes('//*[@path]')) {
+ if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'OneNote opened a section outside the copied notebook.'
+ }
+ }
+ foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
+ $id = $node.GetAttribute('ID')
+ $content = ''
+ $app.GetPageContent($id, [ref]$content, 1, 1)
+ $pages[$id] = $content
+ }
+ }
+ $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
+ if ($signature -ne $previous) {
+ $lastChange = $previous
+ $previous = $signature
+ $stableSince = [DateTime]::UtcNow
+ }
+ if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
+ ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
+ ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
+ Start-Sleep -Milliseconds 250
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
+ [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
+ $index = 0
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ $index++
+ }
+ $hierarchy = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
+ [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
+ throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
+ }
+ $index = 0
+ $payloads = @()
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ if ($Pdf) {
+ $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
+ $app.NavigateTo($id, '', $false)
+ $app.Publish($id, $pdfPath, 3, '')
+ if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
+ }
+ [xml]$page = $pages[$id]
+ foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
+ $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
+ $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
+ [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
+ $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
+ kind = $file.LocalName; name = $file.GetAttribute('preferredName');
+ sha256 = $hash; bytes = $bytes.Length }
+ }
+ $index++
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
+ $all = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
+ [xml]$finalTree = $all
+ $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
+ if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
+ throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
+ Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
+} catch {
+ $failure = $_
+ [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ throw
+} finally {
+ try {
+ try {
+ if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
+ if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
+ $app.CloseNotebook($notebookId, $false)
+ }
+ } catch {
+ if ($null -eq $failure) { throw }
+ [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ }
+ } finally {
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ }
+ $app = $null
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+ if (-not $UseCurrentCache -and -not $CloneHost) {
+ Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
+ }
+}
diff --git a/corpus/picture-edit/native-page-level/source.json b/corpus/picture-edit/native-page-level/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..0613aac0d637eacd23e38a986eb8d4aed828b657
--- /dev/null
+++ b/corpus/picture-edit/native-page-level/source.json
@@ -0,0 +1,14 @@
+[
+ {
+ "path": "Open Notebook.onetoc2",
+ "bytes": 1604,
+ "sha256": "0eddd691b52d26bac222cf7e52add3c9909446647b452fb193f03d49e273426a",
+ "mtime_ns": 1789243969388188834
+ },
+ {
+ "path": "pictures.one",
+ "bytes": 7064,
+ "sha256": "3076880e02cf74eb85400852c133060ab8ed50383c6b9df8a07bde8389891a70",
+ "mtime_ns": 1789243969387957378
+ }
+]
diff --git a/corpus/picture-edit/native-page-level/teardown.json b/corpus/picture-edit/native-page-level/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/picture-edit/native-page-level/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/corpus/picture-edit/native-page-level/update.xml b/corpus/picture-edit/native-page-level/update.xml
new file mode 100644
index 0000000000000000000000000000000000000000..4611a85ede77d365c50791dcffd90d140145fcba
--- /dev/null
+++ b/corpus/picture-edit/native-page-level/update.xml
@@ -0,0 +1,5 @@
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w
+AAAAAElFTkSuQmCC
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w
+AAAAAElFTkSuQmCC
+
\ No newline at end of file
diff --git a/corpus/picture-edit/page-level/candidate/Open Notebook.onetoc2 b/corpus/picture-edit/page-level/candidate/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..52d19e53c867b3228411ceec37a5747fb72aea4d
Binary files /dev/null and b/corpus/picture-edit/page-level/candidate/Open Notebook.onetoc2 differ
diff --git a/corpus/picture-edit/page-level/candidate/pictures.one b/corpus/picture-edit/page-level/candidate/pictures.one
new file mode 100644
index 0000000000000000000000000000000000000000..b656ae835d49ab68c65a0aa4629b82385df4ffd0
Binary files /dev/null and b/corpus/picture-edit/page-level/candidate/pictures.one differ
diff --git a/corpus/picture-edit/page-level/cold/commands.jsonl b/corpus/picture-edit/page-level/cold/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..73b9bd3014f117c57e27064b4131c0747326f7c2
--- /dev/null
+++ b/corpus/picture-edit/page-level/cold/commands.jsonl
@@ -0,0 +1,3 @@
+{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-3E57E5E3 -ExpectedPages 1", "exit": 0, "stdout": "Read 1 sections and 1 pages.\r\n", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -Command \"Compress-Archive -Force -Path C:\\one-tests\\runs\\capture\\* -DestinationPath C:\\one-tests\\captured.zip\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
diff --git a/corpus/picture-edit/page-level/cold/machine.json b/corpus/picture-edit/page-level/cold/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..c8cb9f4c9ef121e59a02fbd224d6cdbf81a47347
--- /dev/null
+++ b/corpus/picture-edit/page-level/cold/machine.json
@@ -0,0 +1 @@
+{"name": "m6-3e57e5e3", "hostname": "ONE-M6-3E57E5E3"}
diff --git a/corpus/picture-edit/page-level/cold/notebook/Open Notebook.onetoc2 b/corpus/picture-edit/page-level/cold/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..b9e7f4815ce3a65ccfb1f0e432e3d5ae94af1a2b
Binary files /dev/null and b/corpus/picture-edit/page-level/cold/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/picture-edit/page-level/cold/notebook/pictures.one b/corpus/picture-edit/page-level/cold/notebook/pictures.one
new file mode 100644
index 0000000000000000000000000000000000000000..c608c2f7e561582e3f90bc3facd1ed5518724bc2
Binary files /dev/null and b/corpus/picture-edit/page-level/cold/notebook/pictures.one differ
diff --git a/corpus/picture-edit/page-level/cold/read/environment.json b/corpus/picture-edit/page-level/cold/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ffd2b6d1693fdcc135f535dbe81348c8567f201
--- /dev/null
+++ b/corpus/picture-edit/page-level/cold/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-3E57E5E3",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/picture-edit/page-level/cold/read/hierarchy.xml b/corpus/picture-edit/page-level/cold/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..cad5c63cbd32f13ff9454a9f0ce2fd2377b0f149
--- /dev/null
+++ b/corpus/picture-edit/page-level/cold/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/picture-edit/page-level/cold/read/page-000.xml b/corpus/picture-edit/page-level/cold/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..6f9dbd9dc67dad811b4f892a4fe7f8853d43762e
--- /dev/null
+++ b/corpus/picture-edit/page-level/cold/read/page-000.xml
@@ -0,0 +1,4 @@
+
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w
+AAAAAElFTkSuQmCC
+
diff --git a/corpus/picture-edit/page-level/cold/read/payloads.json b/corpus/picture-edit/page-level/cold/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843
--- /dev/null
+++ b/corpus/picture-edit/page-level/cold/read/payloads.json
@@ -0,0 +1,3 @@
+[
+
+]
\ No newline at end of file
diff --git a/corpus/picture-edit/page-level/cold/run.json b/corpus/picture-edit/page-level/cold/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..8efa63abedb2bef67a9d90bff430c1a013d862e2
--- /dev/null
+++ b/corpus/picture-edit/page-level/cold/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/private/tmp/m4/pagelevel",
+ "expected_pages": 1,
+ "author": null,
+ "author_timeout_seconds": 600,
+ "inspect": false,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
+ }
+}
diff --git a/corpus/picture-edit/page-level/cold/scripts/cold.ps1 b/corpus/picture-edit/page-level/cold/scripts/cold.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f
--- /dev/null
+++ b/corpus/picture-edit/page-level/cold/scripts/cold.ps1
@@ -0,0 +1,27 @@
+param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
+$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
+if ($CloneHost) {
+ if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
+ throw 'The disposable clone hostname does not match this machine.'
+ }
+ New-Item "$key\Options\Paths" -Force | Out-Null
+ New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
+} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
+ -not (Test-Path 'C:\one-tests\profile-original-cache')) {
+ throw 'Park the personal OneNote profile before resetting the test cache.'
+}
+$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
+$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
+if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
+New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
+if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
+if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
+New-Item "$key\OpenNotebooks" | Out-Null
+New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
diff --git a/corpus/picture-edit/page-level/cold/scripts/read.ps1 b/corpus/picture-edit/page-level/cold/scripts/read.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b
--- /dev/null
+++ b/corpus/picture-edit/page-level/cold/scripts/read.ps1
@@ -0,0 +1,142 @@
+param(
+ [Parameter(Mandatory=$true)][string]$Root,
+ [int]$ExpectedPages = -1,
+ [switch]$UseCurrentCache,
+ [switch]$Pdf,
+ [switch]$KeepOpen,
+ [string]$CloneHost = ''
+)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+$notebook = Join-Path $root 'notebook'
+$output = Join-Path $root 'read'
+if (Test-Path $output) { throw 'Choose a new read destination.' }
+if ($UseCurrentCache) {
+ if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
+ throw 'Park the personal OneNote profile before reading test notebooks.'
+ }
+} else {
+ & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
+}
+New-Item -ItemType Directory -Path $output | Out-Null
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+$failure = $null
+try {
+ $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
+ $process = Get-Process ONENOTE
+ @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
+ powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
+ ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
+ $sections = @()
+ foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
+ $id = ''
+ $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
+ $sections += $id
+ }
+ $deadline = [DateTime]::UtcNow.AddSeconds(300)
+ $previous = ''
+ $lastChange = ''
+ $stableSince = [DateTime]::UtcNow
+ $settled = $false
+ do {
+ $pages = @{}
+ foreach ($section in $sections) {
+ $hierarchy = ''
+ $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
+ [xml]$xml = $hierarchy
+ foreach ($node in $xml.SelectNodes('//*[@path]')) {
+ if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'OneNote opened a section outside the copied notebook.'
+ }
+ }
+ foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
+ $id = $node.GetAttribute('ID')
+ $content = ''
+ $app.GetPageContent($id, [ref]$content, 1, 1)
+ $pages[$id] = $content
+ }
+ }
+ $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
+ if ($signature -ne $previous) {
+ $lastChange = $previous
+ $previous = $signature
+ $stableSince = [DateTime]::UtcNow
+ }
+ if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
+ ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
+ ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
+ Start-Sleep -Milliseconds 250
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
+ [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
+ $index = 0
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ $index++
+ }
+ $hierarchy = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
+ [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
+ throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
+ }
+ $index = 0
+ $payloads = @()
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ if ($Pdf) {
+ $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
+ $app.NavigateTo($id, '', $false)
+ $app.Publish($id, $pdfPath, 3, '')
+ if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
+ }
+ [xml]$page = $pages[$id]
+ foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
+ $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
+ $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
+ [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
+ $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
+ kind = $file.LocalName; name = $file.GetAttribute('preferredName');
+ sha256 = $hash; bytes = $bytes.Length }
+ }
+ $index++
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
+ $all = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
+ [xml]$finalTree = $all
+ $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
+ if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
+ throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
+ Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
+} catch {
+ $failure = $_
+ [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ throw
+} finally {
+ try {
+ try {
+ if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
+ if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
+ $app.CloseNotebook($notebookId, $false)
+ }
+ } catch {
+ if ($null -eq $failure) { throw }
+ [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ }
+ } finally {
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ }
+ $app = $null
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+ if (-not $UseCurrentCache -and -not $CloneHost) {
+ Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
+ }
+}
diff --git a/corpus/picture-edit/page-level/cold/source.json b/corpus/picture-edit/page-level/cold/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..8791fc92eea165f04e91d51b94af79d1b470412c
--- /dev/null
+++ b/corpus/picture-edit/page-level/cold/source.json
@@ -0,0 +1,14 @@
+[
+ {
+ "path": "Open Notebook.onetoc2",
+ "bytes": 1604,
+ "sha256": "3df6c788afbfe6ecda97d63cdbb3f42b269d61107e78dd0b3babaf55ae567389",
+ "mtime_ns": 1789249104766253246
+ },
+ {
+ "path": "pictures.one",
+ "bytes": 5656,
+ "sha256": "b6c0a2e604c038a2d0fa93a6096c3fecfa08f370f268ad010d7419ee87dd1f02",
+ "mtime_ns": 1789249104766006120
+ }
+]
diff --git a/corpus/picture-edit/page-level/cold/teardown.json b/corpus/picture-edit/page-level/cold/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/picture-edit/page-level/cold/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/crates/notebook/README.md b/crates/notebook/README.md
index 136c30cd4e9c4cff1989b0a3e532432d396e83d4..cef70b51ababe2a8ec269f1d2196f7e8cec75249 100644
--- a/crates/notebook/README.md
+++ b/crates/notebook/README.md
@@ -14,7 +14,7 @@ diffs the supplied model against the page stored in the supplied local snapshot,
writes the difference into the working image and queues one
`Operation::Page(PageIntent { before, after, author })`; `before` is the page the
edit started from and is the precondition reconciliation checks. Text, styles,
-paragraph formatting, hyperlinks, bullets, numbering, note tags, table rows and columns, pictures (insertion, size, description) and attachments, paragraph structure,
+paragraph formatting, hyperlinks, bullets, numbering, note tags, table rows and columns, pictures (insertion in paragraphs or on the page, position, size, description) and attachments, paragraph structure,
outline layout, insertions and deletions are all differences between `before` and
`after`; the library never sees editor operations.
diff --git a/crates/onestore/README.md b/crates/onestore/README.md
index 2952cbffa9748b6362fef6a86437ee71eb4c5398..8d2e8936738494415ad65f951bb198b66ea11a96 100644
--- a/crates/onestore/README.md
+++ b/crates/onestore/README.md
@@ -57,7 +57,7 @@ harness also accepts `--client-profile release`.
| `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules |
| `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs |
| `OutlineEdit`, `PreparedEdit::outline` | Change ordinary outline position/width or a paragraph's saved expansion default, preserving identities and content |
-| `PreparedEdit::page` | Publish an edited `page::Page` as one revision per changed space: text, character and paragraph formatting, hyperlinks, bullets and numbering, note tags, table rows and columns, inserted pictures and attachments, picture size and description, paragraph insertion/split/join/move/deletion, outline insertion/deletion/position/width and saved collapse state, lowered onto the typed writers with the model's identities |
+| `PreparedEdit::page` | Publish an edited `page::Page` as one revision per changed space: text, character and paragraph formatting, hyperlinks, bullets and numbering, note tags, table rows and columns, inserted pictures (in paragraphs or on the page) and attachments, picture position, size and description, paragraph insertion/split/join/move/deletion, outline insertion/deletion/position/width and saved collapse state, lowered onto the typed writers with the model's identities |
| `TreeEdit`, `PreparedEdit::tree` | Move or delete a subtree on one page, normalize surviving containers, and replace an emptied table cell's paragraph atomically |
| `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter |
| `read_file` | Read a snapshot under whole-file exclusion |
diff --git a/crates/onestore/src/page/write.rs b/crates/onestore/src/page/write.rs
index 2cddf00e9329d72913ab52318a62b45054a7c370..276698f1ccd1f69648b3a387612dc61882c88504 100644
--- a/crates/onestore/src/page/write.rs
+++ b/crates/onestore/src/page/write.rs
@@ -28,6 +28,73 @@ fn payload_reference(guid: [u8; 16]) -> String {
format!("{}", id.split(',').next().unwrap())
}
+fn page_images(page: &Page) -> impl Iterator- {
+ page.objects.iter().filter_map(|object| match object {
+ PageObject::Image(image) => Some((image.id, image)),
+ _ => None,
+ })
+}
+
+fn picture_fixed_fields(stored: &Image, image: &Image) -> Result<(), Error> {
+ if stored.id != image.id
+ || stored.bytes != image.bytes
+ || stored.size != image.size
+ || stored.background != image.background
+ {
+ return Err(invalid(
+ "A stored picture keeps its payload, intrinsic size and background state",
+ ));
+ }
+ Ok(())
+}
+
+/// Position and displayed-size properties that take `layout` from `stored`, and the ones
+/// to remove.
+fn layout_values(
+ stored: &crate::document::Layout,
+ layout: &crate::document::Layout,
+) -> Result<(Values, Vec), Error> {
+ let mut values = Values::new();
+ let mut removed = Vec::new();
+ if (layout.x, layout.y) != (stored.x, stored.y) {
+ let (Some(x), Some(y)) = (layout.x, layout.y) else {
+ return Err(invalid("A picture position needs both coordinates"));
+ };
+ if !(x.is_finite() && y.is_finite()) {
+ return Err(invalid("A picture position must be finite"));
+ }
+ values.push((0x14001c14, (x / 36.0).to_le_bytes().to_vec()));
+ values.push((0x14001c15, (y / 36.0).to_le_bytes().to_vec()));
+ }
+ if (
+ layout.max_width,
+ layout.max_height,
+ layout.width_set_by_user,
+ ) != (
+ stored.max_width,
+ stored.max_height,
+ stored.width_set_by_user,
+ ) {
+ match (layout.max_width, layout.max_height) {
+ (Some(width), Some(height)) => {
+ if !(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0) {
+ return Err(invalid("Picture size must be positive"));
+ }
+ values.push((0x14001c1b, (width / 36.0).to_le_bytes().to_vec()));
+ values.push((0x14001c1c, (height / 36.0).to_le_bytes().to_vec()));
+ let user_set = u32::from(layout.width_set_by_user == Some(true));
+ values.push((0x08001cbd | (user_set << 31), Vec::new()));
+ }
+ (None, None) => removed.extend([0x14001c1b, 0x14001c1c, 0x08001cbd]),
+ _ => return Err(invalid("A picture size needs both dimensions")),
+ }
+ }
+ if layout.reserved_width != stored.reserved_width {
+ return Err(invalid("A picture has no reserved width"));
+ }
+ Ok((values, removed))
+}
+
fn measurement_bytes(values: &[f32], header: usize) -> Result, Error> {
let count = u8::try_from(values.len())
.map_err(|_| invalid("A measurement array exceeds the document range"))?;
@@ -298,10 +365,6 @@ impl Lowering<'_> {
page.objects
.iter()
.filter_map(|object| match object {
- PageObject::Image(image) => Some(format!(
- "{:?} {:?} {:?} {:?}",
- image.id, image.layout, image.alt, image.background
- )),
PageObject::Unsupported(unsupported) => Some(format!("{unsupported:?}")),
PageObject::Title(title) => Some(format!(
"{:?} {:?} {:?} {:?}",
@@ -310,7 +373,7 @@ impl Lowering<'_> {
title.layout,
title.outlines.iter().map(|o| o.id).collect::>()
)),
- PageObject::Outline(_) => None,
+ PageObject::Outline(_) | PageObject::Image(_) => None,
})
.collect()
};
@@ -320,7 +383,7 @@ impl Lowering<'_> {
after_fixed.sort();
if before_fixed != after_fixed {
return Err(invalid(
- "Images, titles and unsupported objects cannot be edited through the page model",
+ "Titles and unsupported objects cannot be edited through the page model",
));
}
for (id, outline) in &new.outlines {
@@ -386,15 +449,9 @@ impl Lowering<'_> {
}
(ParagraphContent::Unsupported(a), ParagraphContent::Unsupported(b)) if a == b => {}
(ParagraphContent::Image(a), ParagraphContent::Image(b)) => {
- if a.id != b.id
- || a.bytes != b.bytes
- || a.size != b.size
- || a.background != b.background
- || (a.layout.x, a.layout.y) != (b.layout.x, b.layout.y)
- {
- return Err(invalid(
- "A stored picture keeps its payload, intrinsic size, background state and position",
- ));
+ picture_fixed_fields(a, b)?;
+ if (a.layout.x, a.layout.y) != (b.layout.x, b.layout.y) {
+ return Err(invalid("A paragraph picture has no position of its own"));
}
}
(ParagraphContent::Attachment(a), ParagraphContent::Attachment(b)) => {
@@ -580,6 +637,7 @@ impl Lowering<'_> {
.iter()
.any(|object| matches!(object, PageObject::Title(title) if title.id == id))
};
+ self.edit_page_images(old, new)?;
let survivors: Vec = page_order
.iter()
.copied()
@@ -764,79 +822,138 @@ impl Lowering<'_> {
return Err(invalid("Paragraph content type cannot change"));
};
if stored != image {
- self.resize_image(stored, image)?;
+ self.edit_image(stored, image)?;
}
continue;
}
- let Some(bytes) = &image.bytes else {
- return Err(invalid("A new picture needs its payload"));
- };
- let extension = match bytes.as_ref() {
- [0x89, b'P', b'N', b'G', ..] => ".png",
- [0xff, 0xd8, 0xff, ..] => ".jpg",
- [b'G', b'I', b'F', b'8', ..] => ".gif",
- [b'B', b'M', ..] => ".bmp",
- _ => return Err(invalid("Choose a PNG, JPEG, GIF or BMP picture")),
- };
- let image_id = self.allocate(image.id)?;
- let file_id = ExGuid {
- guid: crate::write::fresh_guid()?,
- n: 1,
- };
- let payload_guid = crate::write::fresh_guid()?;
- let reference = payload_reference(payload_guid);
- let modified = crate::create::current_timestamps()?.0.to_le_bytes();
- let mut values: Values = vec![(0x14001d7a, modified.to_vec())];
- if let Some([width, height]) = image.size {
- if !(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0) {
- return Err(invalid("Picture size must be positive"));
- }
- values.push((0x140034cd, (width / 36.0).to_le_bytes().to_vec()));
- values.push((0x140034ce, (height / 36.0).to_le_bytes().to_vec()));
+ if image.layout.x.is_some() || image.layout.y.is_some() {
+ return Err(invalid("A paragraph picture has no position of its own"));
}
- if let Some(alt) = &image.alt {
- values.push((0x1c001e58, crate::create::string(alt)));
- }
- if image.background {
- values.push((0x08001d13 | (1 << 31), Vec::new()));
- }
- values.push((0x08001d85, Vec::new()));
let holder = self.id(*paragraph_id);
- let space = self.space;
- let payload: &[u8] = bytes;
- self.apply(|current| {
- crate::write::write_revision_with_payloads(
- current,
- space,
- &[(payload_guid, payload)],
- |raw| {
- let mut changed = BTreeMap::new();
- let file = PropertyObject::file(file_id, &reference, extension)?;
- changed.insert(file_id, file);
- let mut picture = PropertyObject {
- jcid: 0x60011,
- bytes: crate::create::properties(&values)?,
- global_ids: std::sync::Arc::new(BTreeMap::from([(0, image_id.guid)])),
- };
- picture.reference(image_id)?;
- let container = picture.reference(file_id)?;
- picture.set(&[(0x20001c3f, &container)])?;
- changed.insert(image_id, picture);
- let mut object = PropertyObject::from_object(&raw.objects[&holder])?;
- let content = object.reference(image_id)?;
- object.set(&[(0x24001c1f, &content), (0x14001d7a, &modified)])?;
- changed.insert(holder, object);
- Ok(changed)
- },
- )
- })?;
+ self.insert_image(image, Some(holder))?;
}
Ok(())
}
- /// Writes a displayed size and description the way OneNote stores a resized picture:
- /// the layout width and height with the user flag, leaving the intrinsic size alone.
- fn resize_image(&mut self, stored: &Image, image: &Image) -> Result<(), Error> {
+ /// Page-level pictures are direct page children, as OneNote stores a picture placed
+ /// outside any outline: new ones are appended for the placement pass to order, changed
+ /// ones are moved, resized or described, and removed ones are deleted in the delete pass.
+ fn edit_page_images(&mut self, old: &View<'_>, new: &View<'_>) -> Result<(), Error> {
+ let stored: BTreeMap = page_images(old.page).collect();
+ for (id, image) in page_images(new.page) {
+ match stored.get(&id) {
+ Some(previous) => {
+ if *previous != image {
+ self.edit_image(previous, image)?;
+ }
+ }
+ None => {
+ if image.layout.x.is_none() || image.layout.y.is_none() {
+ return Err(invalid("A new page-level picture needs a position"));
+ }
+ self.insert_image(image, None)?;
+ }
+ }
+ }
+ Ok(())
+ }
+
+ /// Gives a new picture what OneNote stores for an inserted one: the payload embedded
+ /// in the section's file-data store, a file-data object declaring it by identity and
+ /// extension, and a picture object that a paragraph holds as content or the page
+ /// lists as a child.
+ fn insert_image(&mut self, image: &Image, holder: Option) -> Result<(), Error> {
+ let Some(bytes) = &image.bytes else {
+ return Err(invalid("A new picture needs its payload"));
+ };
+ let extension = match bytes.as_ref() {
+ [0x89, b'P', b'N', b'G', ..] => ".png",
+ [0xff, 0xd8, 0xff, ..] => ".jpg",
+ [b'G', b'I', b'F', b'8', ..] => ".gif",
+ [b'B', b'M', ..] => ".bmp",
+ _ => return Err(invalid("Choose a PNG, JPEG, GIF or BMP picture")),
+ };
+ let image_id = self.allocate(image.id)?;
+ let file_id = ExGuid {
+ guid: crate::write::fresh_guid()?,
+ n: 1,
+ };
+ let payload_guid = crate::write::fresh_guid()?;
+ let reference = payload_reference(payload_guid);
+ let modified = crate::create::current_timestamps()?.0.to_le_bytes();
+ let mut values: Values = vec![(0x14001d7a, modified.to_vec())];
+ if let Some([width, height]) = image.size {
+ if !(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0) {
+ return Err(invalid("Picture size must be positive"));
+ }
+ values.push((0x140034cd, (width / 36.0).to_le_bytes().to_vec()));
+ values.push((0x140034ce, (height / 36.0).to_le_bytes().to_vec()));
+ }
+ values.extend(layout_values(&Default::default(), &image.layout)?.0);
+ if let Some(alt) = &image.alt {
+ values.push((0x1c001e58, crate::create::string(alt)));
+ }
+ if image.background {
+ values.push((0x08001d13 | (1 << 31), Vec::new()));
+ }
+ values.push((0x08001d85, Vec::new()));
+ let (space, page) = (self.space, self.page);
+ let payload: &[u8] = bytes;
+ self.apply(|current| {
+ crate::write::write_revision_with_payloads(
+ current,
+ space,
+ &[(payload_guid, payload)],
+ |raw| {
+ let mut changed = BTreeMap::new();
+ let file = PropertyObject::file(file_id, &reference, extension)?;
+ changed.insert(file_id, file);
+ let mut picture = PropertyObject {
+ jcid: 0x60011,
+ bytes: crate::create::properties(&values)?,
+ global_ids: std::sync::Arc::new(BTreeMap::from([(0, image_id.guid)])),
+ };
+ picture.reference(image_id)?;
+ let container = picture.reference(file_id)?;
+ picture.set(&[(0x20001c3f, &container)])?;
+ changed.insert(image_id, picture);
+ match holder {
+ Some(holder) => {
+ let mut object = PropertyObject::from_object(&raw.objects[&holder])?;
+ let content = object.reference(image_id)?;
+ object.set(&[(0x24001c1f, &content), (0x14001d7a, &modified)])?;
+ changed.insert(holder, object);
+ }
+ None => {
+ let mut object = PropertyObject::from_object(&raw.objects[&page])?;
+ let properties = crate::PropertySets::parse(&object.bytes)?;
+ let mut children = match properties.sets[0]
+ .iter()
+ .find(|p| p.id == 0x24001c20)
+ .map(|p| &p.value)
+ {
+ Some(crate::Value::References { compact_ids, .. }) => {
+ compact_ids.to_vec()
+ }
+ None => Vec::new(),
+ _ => return Err(invalid("The page has an invalid child list")),
+ };
+ children.extend(object.reference(image_id)?);
+ object.set(&[(0x24001c20, &children), (0x14001d7a, &modified)])?;
+ changed.insert(page, object);
+ }
+ }
+ Ok(changed)
+ },
+ )
+ })
+ }
+
+ /// Writes a moved, resized or described picture the way OneNote stores one: the
+ /// position, the layout width and height with the user flag and the description on
+ /// the picture object, leaving the intrinsic size alone.
+ fn edit_image(&mut self, stored: &Image, image: &Image) -> Result<(), Error> {
+ picture_fixed_fields(stored, image)?;
let mut values: Values = vec![(
0x14001d7a,
crate::create::current_timestamps()?
@@ -844,22 +961,8 @@ impl Lowering<'_> {
.to_le_bytes()
.to_vec(),
)];
- let mut removed = Vec::new();
- if image.layout != stored.layout {
- match (image.layout.max_width, image.layout.max_height) {
- (Some(width), Some(height)) => {
- if !(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0) {
- return Err(invalid("Picture size must be positive"));
- }
- values.push((0x14001c1b, (width / 36.0).to_le_bytes().to_vec()));
- values.push((0x14001c1c, (height / 36.0).to_le_bytes().to_vec()));
- let user_set = u32::from(image.layout.width_set_by_user == Some(true));
- values.push((0x08001cbd | (user_set << 31), Vec::new()));
- }
- (None, None) => removed.extend([0x14001c1b, 0x14001c1c, 0x08001cbd]),
- _ => return Err(invalid("A picture size needs both dimensions")),
- }
- }
+ let (layout, mut removed) = layout_values(&stored.layout, &image.layout)?;
+ values.extend(layout);
if image.alt != stored.alt {
match &image.alt {
Some(alt) => values.push((0x1c001e58, crate::create::string(alt))),
@@ -1165,6 +1268,14 @@ impl Lowering<'_> {
let space = self.space;
self.apply(|image| edit.apply(image, space))?;
}
+ let kept: BTreeSet = page_images(new.page).map(|(id, _)| id).collect();
+ for (id, _) in page_images(old.page) {
+ if !kept.contains(&id) {
+ let edit = TreeEdit::delete(id, self.author)?;
+ let space = self.space;
+ self.apply(|image| edit.apply(image, space))?;
+ }
+ }
for id in old.outlines.keys() {
if removed_outline(*id) {
if old.title_outlines.contains(id) {
diff --git a/crates/onestore/src/tree.rs b/crates/onestore/src/tree.rs
index 34aff9ec5cfa65598dd2d0e4dd600b06fd033893..73822329e127c179e4e62d33ef1af3292ced9e47 100644
--- a/crates/onestore/src/tree.rs
+++ b/crates/onestore/src/tree.rs
@@ -115,7 +115,7 @@ impl TreeEdit {
.get(1)
.ok_or_else(|| invalid("Select a paragraph or ordinary page outline"))?;
let compatible = |parent: ExGuid| match view.nodes[&self.object].kind {
- Kind::Outline { .. } => parent == *page,
+ Kind::Outline { .. } | Kind::Image { .. } => parent == *page,
Kind::Paragraph { .. } => matches!(
view.nodes[&parent].kind,
Kind::Outline { .. }
diff --git a/crates/onestore/tests/page_images.rs b/crates/onestore/tests/page_images.rs
index 5d32d6225b666a61ce6f11ddd7e6453303b53b71..ad0f14d9bd2b0bff181adbfd3f888f28cc7a8182 100644
--- a/crates/onestore/tests/page_images.rs
+++ b/crates/onestore/tests/page_images.rs
@@ -9,6 +9,8 @@ const NATIVE: &[u8] =
include_bytes!("../../../corpus/native/cold-05-05-image/notebook/synthetic.one");
const NATIVE_RESIZE: &[u8] =
include_bytes!("../../../corpus/picture-edit/native-resize/notebook/pictures.one");
+const NATIVE_PAGE_LEVEL: &[u8] =
+ include_bytes!("../../../corpus/picture-edit/native-page-level/notebook/pictures.one");
const AUTHOR: &str = "Picture author";
/// A one-pixel PNG.
const PNG: &[u8] = &[
@@ -236,6 +238,113 @@ fn a_rust_picture_resized_natively_reads_back_with_its_size_and_description() {
assert_eq!(page_in(written.as_bytes(), space), wider);
}
+fn page_pictures(page: &Page) -> Vec<&Image> {
+ page.objects
+ .iter()
+ .filter_map(|object| match object {
+ PageObject::Image(image) => Some(image),
+ _ => None,
+ })
+ .collect()
+}
+
+#[test]
+fn a_page_level_picture_placed_natively_reads_as_a_page_object() {
+ let (space, page) = first_page(NATIVE_PAGE_LEVEL);
+ assert_eq!(pictures(&page).len(), 1);
+ let found = page_pictures(&page);
+ let [picture] = found.as_slice() else {
+ panic!("{page:?}");
+ };
+ assert_eq!(picture.bytes.as_deref(), Some(PNG));
+ assert_eq!(
+ (picture.layout.x, picture.layout.y),
+ (Some(360.0), Some(240.0))
+ );
+ assert_eq!(picture.layout.width_set_by_user, Some(true));
+ assert_eq!(picture.alt.as_deref(), Some("Page-level picture"));
+ let mut moved = page.clone();
+ for object in &mut moved.objects {
+ if let PageObject::Image(image) = object {
+ image.layout.x = Some(72.0);
+ image.layout.y = Some(400.0);
+ }
+ }
+ let written = PreparedEdit::page(NATIVE_PAGE_LEVEL, space, &moved, AUTHOR).unwrap();
+ assert_eq!(page_in(written.as_bytes(), space), moved);
+}
+
+/// `ONESTORE_PAGE_IMAGE_EXPORT` names a new directory receiving the candidate for a cold reopen.
+#[test]
+fn a_page_level_picture_is_inserted_moved_and_removed() {
+ let source = onestore::create_section("pictures.one", "Beside the picture", "Author").unwrap();
+ let (space, before) = first_page(&source);
+ let mut after = before.clone();
+ let image = Image {
+ id: new_id().unwrap(),
+ layout: Layout {
+ x: Some(360.0),
+ y: Some(240.0),
+ max_width: Some(96.0),
+ width_set_by_user: Some(true),
+ max_height: Some(72.0),
+ reserved_width: None,
+ },
+ size: Some([0.75, 0.75]),
+ bytes: Some(Arc::from(PNG)),
+ alt: Some("Placed in Rust".into()),
+ background: false,
+ };
+ after.objects.push(PageObject::Image(image.clone()));
+ let written = PreparedEdit::page(&source, space, &after, AUTHOR).unwrap();
+ let stored = page_in(written.as_bytes(), space);
+ let mut expected = after.clone();
+ expected.title = stored.title.clone();
+ assert_eq!(stored, expected);
+ assert_eq!(page_pictures(&stored)[0].bytes.as_deref(), Some(PNG));
+ assert_eq!(
+ PreparedEdit::page(written.as_bytes(), space, &stored, AUTHOR)
+ .unwrap()
+ .as_bytes(),
+ written.as_bytes()
+ );
+ if let Some(directory) = std::env::var_os("ONESTORE_PAGE_IMAGE_EXPORT") {
+ let directory = std::path::PathBuf::from(directory);
+ std::fs::create_dir(&directory).unwrap();
+ std::fs::write(directory.join("pictures.one"), written.as_bytes()).unwrap();
+ let written_store = Store::parse(written.as_bytes()).unwrap();
+ std::fs::write(
+ directory.join("Open Notebook.onetoc2"),
+ onestore::create_table_of_contents(
+ "Open Notebook.onetoc2",
+ &[("pictures.one", written_store.header.file_id)],
+ )
+ .unwrap(),
+ )
+ .unwrap();
+ }
+ let mut moved = stored.clone();
+ for object in &mut moved.objects {
+ if let PageObject::Image(image) = object {
+ image.layout.x = Some(36.0);
+ image.alt = None;
+ }
+ }
+ let again = PreparedEdit::page(written.as_bytes(), space, &moved, AUTHOR).unwrap();
+ assert_eq!(page_in(again.as_bytes(), space), moved);
+ let mut removed = moved.clone();
+ removed.objects.retain(|object| object.id() != image.id);
+ let last = PreparedEdit::page(again.as_bytes(), space, &removed, AUTHOR).unwrap();
+ let stored = page_in(last.as_bytes(), space);
+ assert!(page_pictures(&stored).is_empty());
+ assert_eq!(stored, removed);
+ let mut unplaced = stored.clone();
+ let mut nowhere = image.clone();
+ nowhere.layout = Layout::default();
+ unplaced.objects.push(PageObject::Image(nowhere));
+ assert!(PreparedEdit::page(last.as_bytes(), space, &unplaced, AUTHOR).is_err());
+}
+
#[test]
fn pictures_need_a_recognised_payload_and_stored_ones_stay_fixed() {
let source = onestore::create_section("pictures.one", "Text", "Author").unwrap();
diff --git a/tools/native/page-picture.ps1 b/tools/native/page-picture.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..83021a116d4982bea1706f7ca290799c7443f512
--- /dev/null
+++ b/tools/native/page-picture.ps1
@@ -0,0 +1,56 @@
+param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost
+$namespace = 'http://schemas.microsoft.com/office/onenote/2010/onenote'
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+try {
+ $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebookId, 0)
+ $sectionId = ''
+ $app.OpenHierarchy('pictures.one', $notebookId, [ref]$sectionId, 0)
+ $deadline = [DateTime]::UtcNow.AddSeconds(30)
+ $page = $null
+ do {
+ $hierarchy = ''
+ $app.GetHierarchy($sectionId, 4, [ref]$hierarchy, 1)
+ [xml]$tree = $hierarchy
+ $pages = @($tree.SelectNodes('//*[local-name()="Page"]'))
+ if ($pages.Count -eq 1) {
+ $content = ''
+ $app.GetPageContent($pages[0].GetAttribute('ID'), [ref]$content, 3, 1)
+ [xml]$page = $content
+ if (@($page.SelectNodes('//*[local-name()="Image"]')).Count -eq 1) { break }
+ }
+ Start-Sleep -Milliseconds 250
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if (@($page.SelectNodes('//*[local-name()="Image"]')).Count -ne 1) { throw 'Expected one picture on the page.' }
+ [IO.File]::WriteAllText((Join-Path $Root 'before.xml'), $content, [Text.Encoding]::UTF8)
+ $data = $page.SelectSingleNode('//*[local-name()="Image"]/*[local-name()="Data"]').InnerText
+ $image = $page.CreateElement('one', 'Image', $namespace)
+ $image.SetAttribute('format', 'png')
+ $image.SetAttribute('alt', 'Page-level picture')
+ $position = $page.CreateElement('one', 'Position', $namespace)
+ $position.SetAttribute('x', '360')
+ $position.SetAttribute('y', '240')
+ [void]$image.AppendChild($position)
+ $size = $page.CreateElement('one', 'Size', $namespace)
+ $size.SetAttribute('width', '96')
+ $size.SetAttribute('height', '72')
+ $size.SetAttribute('isSetByUser', 'true')
+ [void]$image.AppendChild($size)
+ $payload = $page.CreateElement('one', 'Data', $namespace)
+ $payload.InnerText = $data
+ [void]$image.AppendChild($payload)
+ [void]$page.DocumentElement.AppendChild($image)
+ [IO.File]::WriteAllText((Join-Path $Root 'update.xml'), $page.OuterXml, [Text.Encoding]::UTF8)
+ $app.UpdatePageContent($page.OuterXml, [DateTime]::MinValue, 1, $false)
+ $app.SyncHierarchy($notebookId)
+ $app.CloseNotebook($notebookId, $false)
+ $notebookId = ''
+} finally {
+ if ($notebookId) { $app.CloseNotebook($notebookId, $false) }
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+}
diff --git a/tools/test_picture_edit.py b/tools/test_picture_edit.py
index 3abb872b16f7e0240e121825c56f6901a52feda8..ec611bf754827ab4c02997b874e005851471e88c 100644
--- a/tools/test_picture_edit.py
+++ b/tools/test_picture_edit.py
@@ -53,6 +53,26 @@ class PictureEditTest(unittest.TestCase):
self.assertEqual((size.get('width'), size.get('height'), size.get('isSetByUser')), ('144.0', '108.0', 'true'))
self.assertEqual(image.get('alt'), 'Resized in Rust')
+ def test_onenote_places_a_picture_directly_on_a_page_with_a_rust_picture(self):
+ page = native_page(FIXTURE / 'native-page-level/notebook', FIXTURE / 'native-page-level')
+ images = list(page.iter('{%s}Image' % ns['one']))
+ self.assertEqual(len(images), 2)
+ placed = page.find('one:Image', ns)
+ self.assertEqual(placed.get('alt'), 'Page-level picture')
+ position = placed.find('one:Position', ns)
+ self.assertEqual((position.get('x'), position.get('y')), ('360.0', '240.0'))
+
+ def test_a_rust_page_level_picture_renders_with_its_position_size_and_description(self):
+ page = native_page(FIXTURE / 'page-level/candidate', FIXTURE / 'page-level/cold')
+ placed = page.find('one:Image', ns)
+ self.assertIsNotNone(placed)
+ position = placed.find('one:Position', ns)
+ self.assertEqual((position.get('x'), position.get('y')), ('360.0', '240.0'))
+ size = placed.find('one:Size', ns)
+ self.assertEqual((size.get('width'), size.get('height'), size.get('isSetByUser')), ('96.00000762939453', '71.99998474121093', 'true'))
+ self.assertEqual(placed.get('alt'), 'Placed in Rust')
+ self.assertEqual(texts(page), ['Beside the picture'])
+
if __name__ == '__main__':
unittest.main()