diff --git a/corpus/table-edit/README.md b/corpus/table-edit/README.md
index d96fe5fce55b69f51424cb25b55ee36881fb0316..e3d23c4df2c2041d42362d04e84651ee6e6b46af 100644
--- a/corpus/table-edit/README.md
+++ b/corpus/table-edit/README.md
@@ -16,13 +16,20 @@ of three cells. `edited/cold` is its cold read: the native columns keep their
widths and locks, OneNote sizes the unlocked column to its content, and the
new row and cells read back with their text.
+`nested/candidate` is the writer's output for
+`a_table_nests_inside_a_cell_and_is_removed_again` on the same tree fixture:
+the second cell of the native table gains a paragraph holding a one-column,
+two-row table with locked 72 pt column and visible borders. `nested/cold` is
+its cold read with both tables.
+
Native rows and cells are plain containers; the writer creates them with the
modification time, the indent array copied from a sibling cell, and the flags
every native cell carries, rebuilds each table's row and cell order to the
model's, and writes the column count, widths, locks and border flag. New
tables start as an empty paragraph whose content the structure pass replaces.
An emptied cell keeps a replacement paragraph, as the tree writer provides.
-`tools/test_table_edit.py` checks both captures without a VM. Regenerate with
-`ONESTORE_TABLE_EXPORT` and `ONESTORE_TABLE_EDIT_EXPORT` set to new absolute
-directories while running the tests, then cold-open them with
-`tools/native_runner.py OUTPUT COLD --expected-pages 1|12 --collect-notebook`.
+`tools/test_table_edit.py` checks all three captures without a VM. Regenerate
+with `ONESTORE_TABLE_EXPORT`, `ONESTORE_TABLE_EDIT_EXPORT` and
+`ONESTORE_NESTED_TABLE_EXPORT` set to new absolute directories while running
+the tests, then cold-open them with `tools/native_runner.py OUTPUT COLD
+--expected-pages 1|12 --collect-notebook`.
diff --git a/corpus/table-edit/nested/candidate/Open Notebook.onetoc2 b/corpus/table-edit/nested/candidate/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..ac6404565fb5d7dc4eecd7d50f52a80fe56ea39d
Binary files /dev/null and b/corpus/table-edit/nested/candidate/Open Notebook.onetoc2 differ
diff --git a/corpus/table-edit/nested/candidate/synthetic.one b/corpus/table-edit/nested/candidate/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..266fcc28db50f2d19f54b1f2b89daa011b440cdb
Binary files /dev/null and b/corpus/table-edit/nested/candidate/synthetic.one differ
diff --git a/corpus/table-edit/nested/cold/commands.jsonl b/corpus/table-edit/nested/cold/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..ca3daf4c4d9f68f3eb732da5aa3784363c6c7bc6
--- /dev/null
+++ b/corpus/table-edit/nested/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-8E63A852 -ExpectedPages 12", "exit": 0, "stdout": "Read 1 sections and 12 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/table-edit/nested/cold/machine.json b/corpus/table-edit/nested/cold/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..af2731f4d9ddcf0999ea29a58d48135326fae541
--- /dev/null
+++ b/corpus/table-edit/nested/cold/machine.json
@@ -0,0 +1 @@
+{"name": "m6-8e63a852", "hostname": "ONE-M6-8E63A852"}
diff --git a/corpus/table-edit/nested/cold/notebook/Open Notebook.onetoc2 b/corpus/table-edit/nested/cold/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..dd056869ac900f0254b6015f73f69cec7ce64d2d
Binary files /dev/null and b/corpus/table-edit/nested/cold/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/table-edit/nested/cold/notebook/synthetic.one b/corpus/table-edit/nested/cold/notebook/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..b1ade032e1a1d530a0dbf143c386c72784717362
Binary files /dev/null and b/corpus/table-edit/nested/cold/notebook/synthetic.one differ
diff --git a/corpus/table-edit/nested/cold/read/environment.json b/corpus/table-edit/nested/cold/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..97826260818ae575e97f3e893c184ac96d2da42d
--- /dev/null
+++ b/corpus/table-edit/nested/cold/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-8E63A852",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/table-edit/nested/cold/read/hierarchy.xml b/corpus/table-edit/nested/cold/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..30951414a1ed8f58e0f0bfa909f5dea2302e146e
--- /dev/null
+++ b/corpus/table-edit/nested/cold/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/table-edit/nested/cold/read/page-000.xml b/corpus/table-edit/nested/cold/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..abe8e889db0e49cfacbcfec754d6dd8fe93ef84b
--- /dev/null
+++ b/corpus/table-edit/nested/cold/read/page-000.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/table-edit/nested/cold/read/page-001.xml b/corpus/table-edit/nested/cold/read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..73fc668530cfe3c925ec3e9899ca78cb17d8e79c
--- /dev/null
+++ b/corpus/table-edit/nested/cold/read/page-001.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/table-edit/nested/cold/read/page-002.xml b/corpus/table-edit/nested/cold/read/page-002.xml
new file mode 100644
index 0000000000000000000000000000000000000000..33249a4a63862188d11be1dc660fb502d1046764
--- /dev/null
+++ b/corpus/table-edit/nested/cold/read/page-002.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/table-edit/nested/cold/read/page-003.xml b/corpus/table-edit/nested/cold/read/page-003.xml
new file mode 100644
index 0000000000000000000000000000000000000000..803070c943bb4c15bbde899ce815025e07ef30fb
--- /dev/null
+++ b/corpus/table-edit/nested/cold/read/page-003.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/table-edit/nested/cold/read/page-004.xml b/corpus/table-edit/nested/cold/read/page-004.xml
new file mode 100644
index 0000000000000000000000000000000000000000..1abeded6ec7686112d3fbf0321ee6a4523ed75e4
--- /dev/null
+++ b/corpus/table-edit/nested/cold/read/page-004.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/table-edit/nested/cold/read/page-005.xml b/corpus/table-edit/nested/cold/read/page-005.xml
new file mode 100644
index 0000000000000000000000000000000000000000..ab5eb2ff035e76901c4cb67a0a0c7c6650232807
--- /dev/null
+++ b/corpus/table-edit/nested/cold/read/page-005.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/table-edit/nested/cold/read/page-006.xml b/corpus/table-edit/nested/cold/read/page-006.xml
new file mode 100644
index 0000000000000000000000000000000000000000..f7e7c61fc66b3a05392b7a669554659eac7d9de7
--- /dev/null
+++ b/corpus/table-edit/nested/cold/read/page-006.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/table-edit/nested/cold/read/page-007.xml b/corpus/table-edit/nested/cold/read/page-007.xml
new file mode 100644
index 0000000000000000000000000000000000000000..5dce83b05f4dbb9134aa1f99de1655186742b0b5
--- /dev/null
+++ b/corpus/table-edit/nested/cold/read/page-007.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/table-edit/nested/cold/read/page-008.xml b/corpus/table-edit/nested/cold/read/page-008.xml
new file mode 100644
index 0000000000000000000000000000000000000000..5404646b65f0848851705698b95df7575d6e1307
--- /dev/null
+++ b/corpus/table-edit/nested/cold/read/page-008.xml
@@ -0,0 +1,4 @@
+
+Target 🦀 é Link label]]>
diff --git a/corpus/table-edit/nested/cold/read/page-009.xml b/corpus/table-edit/nested/cold/read/page-009.xml
new file mode 100644
index 0000000000000000000000000000000000000000..6f2d0a40c64f6c9f9d6708983f8a22623c0a07af
--- /dev/null
+++ b/corpus/table-edit/nested/cold/read/page-009.xml
@@ -0,0 +1,5 @@
+
+Target 🦀 é Link label]]>Child]]>
diff --git a/corpus/table-edit/nested/cold/read/page-010.xml b/corpus/table-edit/nested/cold/read/page-010.xml
new file mode 100644
index 0000000000000000000000000000000000000000..0ab9708e41484ef59f437b8d7c3809e8eba84602
--- /dev/null
+++ b/corpus/table-edit/nested/cold/read/page-010.xml
@@ -0,0 +1,20 @@
+
+Bold 🦀 italic é color 東京
+End]]>Fictitious: café, 東京, مرحبا]]>
diff --git a/corpus/table-edit/nested/cold/read/page-011.xml b/corpus/table-edit/nested/cold/read/page-011.xml
new file mode 100644
index 0000000000000000000000000000000000000000..edc9181409326c6c543bce336887d83568b3c897
--- /dev/null
+++ b/corpus/table-edit/nested/cold/read/page-011.xml
@@ -0,0 +1,4 @@
+
+Target 🦀 é Link label]]>
diff --git a/corpus/table-edit/nested/cold/read/payloads.json b/corpus/table-edit/nested/cold/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843
--- /dev/null
+++ b/corpus/table-edit/nested/cold/read/payloads.json
@@ -0,0 +1,3 @@
+[
+
+]
\ No newline at end of file
diff --git a/corpus/table-edit/nested/cold/run.json b/corpus/table-edit/nested/cold/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..07af38adfa7635acd5034a97c61a9f59db1580a0
--- /dev/null
+++ b/corpus/table-edit/nested/cold/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/private/tmp/m4/nested",
+ "expected_pages": 12,
+ "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/table-edit/nested/cold/scripts/cold.ps1 b/corpus/table-edit/nested/cold/scripts/cold.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f
--- /dev/null
+++ b/corpus/table-edit/nested/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/table-edit/nested/cold/scripts/read.ps1 b/corpus/table-edit/nested/cold/scripts/read.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b
--- /dev/null
+++ b/corpus/table-edit/nested/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/table-edit/nested/cold/source.json b/corpus/table-edit/nested/cold/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..5e6c84c30728031d026ff42bdbfacfc80a21b384
--- /dev/null
+++ b/corpus/table-edit/nested/cold/source.json
@@ -0,0 +1,14 @@
+[
+ {
+ "path": "Open Notebook.onetoc2",
+ "bytes": 1612,
+ "sha256": "1c7c0e30cc2c2f029f87e20bba0981f0bf8a7f2f6643ad67a24e80834f539991",
+ "mtime_ns": 1789250020753460025
+ },
+ {
+ "path": "synthetic.one",
+ "bytes": 91192,
+ "sha256": "6f3b8737c318834e2958368ed9fd9bc2ff4c7490bbd4a29907fa76e472355b43",
+ "mtime_ns": 1789250020752551397
+ }
+]
diff --git a/corpus/table-edit/nested/cold/teardown.json b/corpus/table-edit/nested/cold/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/table-edit/nested/cold/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/crates/notebook/README.md b/crates/notebook/README.md
index cef70b51ababe2a8ec269f1d2196f7e8cec75249..aaffc0e1b263a86614981ebc249d2c33fd3b278e 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 in paragraphs or on the page, position, size, description) and attachments, paragraph structure,
+paragraph formatting, hyperlinks, bullets, numbering, note tags, table rows, columns, cell shading, nested tables, 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 8d2e8936738494415ad65f951bb198b66ea11a96..3d1e9943f9e4e6cd00c6325acaaf8d5d436ea004 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 (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 |
+| `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, columns, cell shading and indents, nested tables, 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 276698f1ccd1f69648b3a387612dc61882c88504..2435df64b5dee6f2b371230eba86850a3d2d0b3e 100644
--- a/crates/onestore/src/page/write.rs
+++ b/crates/onestore/src/page/write.rs
@@ -438,13 +438,11 @@ impl Lowering<'_> {
.find(|before| before.id == cell.id)
.is_none_or(|before| {
cell.layout == before.layout
- && cell.indents == before.indents
- && cell.shading == before.shading
&& cell.unsupported == before.unsupported
})
});
if !unchanged_cells {
- return Err(invalid("Cell layout, indents and shading cannot be edited"));
+ return Err(invalid("Cell layout cannot be edited"));
}
}
(ParagraphContent::Unsupported(a), ParagraphContent::Unsupported(b)) if a == b => {}
@@ -690,6 +688,7 @@ impl Lowering<'_> {
});
self.place_containers(old, new, placed, &existing)?;
self.edit_table_structure(old, new)?;
+ self.edit_cells(old, new)?;
self.edit_images(old, new)?;
self.edit_attachments(old, new)?;
self.place_containers(old, new, placed, &deferred)?;
@@ -1052,6 +1051,70 @@ impl Lowering<'_> {
/// order to the model's, and writes the column widths, locks and border flag. Native
/// rows and cells are plain containers; a cell carries its indent array and the flags
/// every native cell has.
+ /// Shading (the documented `CellShadingColor`, which OneNote 2010 stores but neither
+ /// renders nor accepts through its COM schema) and indents change in place on a cell.
+ fn edit_cells(&mut self, old: &View<'_>, new: &View<'_>) -> Result<(), Error> {
+ let stored: BTreeMap = old
+ .paragraphs
+ .values()
+ .filter_map(|paragraph| match ¶graph.content {
+ ParagraphContent::Table(table) => Some(table),
+ _ => None,
+ })
+ .flat_map(|table| table.rows.iter().flat_map(|row| &row.cells))
+ .map(|cell| (cell.id, cell))
+ .collect();
+ for paragraph in new.paragraphs.values() {
+ let ParagraphContent::Table(table) = ¶graph.content else {
+ continue;
+ };
+ for cell in table.rows.iter().flat_map(|row| &row.cells) {
+ let Some(previous) = stored.get(&cell.id) else {
+ continue;
+ };
+ if (cell.shading, &cell.indents) == (previous.shading, &previous.indents) {
+ continue;
+ }
+ let mut values: Values = vec![(
+ 0x14001d7a,
+ crate::create::current_timestamps()?
+ .0
+ .to_le_bytes()
+ .to_vec(),
+ )];
+ let mut removed = Vec::new();
+ if cell.shading != previous.shading {
+ match cell.shading {
+ Some(shading) => values.push((0x14001e26, shading.to_le_bytes().to_vec())),
+ None => removed.push(0x14001e26),
+ }
+ }
+ if cell.indents != previous.indents {
+ if cell.indents.is_empty() {
+ removed.push(0x1c001c12);
+ } else {
+ values.push((0x1c001c12, measurement_bytes(&cell.indents, 4)?));
+ }
+ }
+ let (space, object) = (self.space, self.id(cell.id));
+ self.apply(|current| {
+ crate::write::write_revision(current, space, |raw| {
+ let mut object_properties =
+ PropertyObject::from_object(&raw.objects[&object])?;
+ object_properties.remove(&removed)?;
+ let values: Vec<(u32, &[u8])> = values
+ .iter()
+ .map(|(id, bytes)| (*id, bytes.as_slice()))
+ .collect();
+ object_properties.set(&values)?;
+ Ok(BTreeMap::from([(object, object_properties)]))
+ })
+ })?;
+ }
+ }
+ Ok(())
+ }
+
fn edit_table_structure(&mut self, old: &View<'_>, new: &View<'_>) -> Result<(), Error> {
let old_tables: BTreeMap = old
.paragraphs
diff --git a/crates/onestore/tests/page_tables.rs b/crates/onestore/tests/page_tables.rs
index 5c9e8207172193b26225f7724a457c13662d878b..8d4541993d6a4bba2c2f7534d6ff23fcacd5d5a9 100644
--- a/crates/onestore/tests/page_tables.rs
+++ b/crates/onestore/tests/page_tables.rs
@@ -214,6 +214,92 @@ fn column_widths_locks_and_borders_change_in_place() {
assert_same(written.as_bytes(), space, &after);
}
+#[test]
+fn cell_shading_and_indents_change_in_place() {
+ let (space, before) = page_by_title(TREES, "Delete cell subtree");
+ let mut after = before.clone();
+ let table = table_mut(&mut after);
+ let cell = &mut table.rows[0].cells[0];
+ assert_eq!(cell.shading, None);
+ cell.shading = Some(0x0000ffff);
+ let original_indents = cell.indents.clone();
+ cell.indents = vec![18.0, 0.0, 27.0, 27.0];
+ let written = PreparedEdit::page(TREES, space, &after, AUTHOR).unwrap();
+ let stored = assert_same(written.as_bytes(), space, &after);
+ let mut reset = stored.clone();
+ let cell = &mut table_mut(&mut reset).rows[0].cells[0];
+ cell.shading = None;
+ cell.indents = original_indents;
+ let again = PreparedEdit::page(written.as_bytes(), space, &reset, AUTHOR).unwrap();
+ assert_eq!(assert_same(again.as_bytes(), space, &reset), before);
+}
+
+/// `ONESTORE_NESTED_TABLE_EXPORT` names a new directory receiving the candidate for a cold reopen.
+#[test]
+fn a_table_nests_inside_a_cell_and_is_removed_again() {
+ let (space, before) = page_by_title(TREES, "Delete cell subtree");
+ let mut after = before.clone();
+ let template = table_mut(&mut after).rows[0].cells[0].paragraphs[0].clone();
+ let cell = |text: &str| TableCell {
+ id: new_id().unwrap(),
+ layout: Default::default(),
+ indents: Vec::new(),
+ shading: None,
+ paragraphs: vec![cell_paragraph(&template, text)],
+ unsupported: Vec::new(),
+ };
+ let nested = Table {
+ id: new_id().unwrap(),
+ columns: vec![TableColumn {
+ width: 72.0,
+ locked: true,
+ }],
+ rows: vec![
+ TableRow {
+ id: new_id().unwrap(),
+ cells: vec![cell("Inner one")],
+ },
+ TableRow {
+ id: new_id().unwrap(),
+ cells: vec![cell("Inner two")],
+ },
+ ],
+ borders: Some(true),
+ layout: Default::default(),
+ tags: Vec::new(),
+ };
+ let mut holder = cell_paragraph(&template, "");
+ holder.content = ParagraphContent::Table(nested);
+ holder.format = Format::default();
+ let holder_id = holder.id;
+ table_mut(&mut after).rows[0].cells[1]
+ .paragraphs
+ .push(holder);
+ let written = PreparedEdit::page(TREES, space, &after, AUTHOR).unwrap();
+ let stored = assert_same(written.as_bytes(), space, &after);
+ if let Some(directory) = std::env::var_os("ONESTORE_NESTED_TABLE_EXPORT") {
+ let directory = std::path::PathBuf::from(directory);
+ std::fs::create_dir(&directory).unwrap();
+ std::fs::write(directory.join("synthetic.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",
+ &[("synthetic.one", written_store.header.file_id)],
+ )
+ .unwrap(),
+ )
+ .unwrap();
+ }
+ let mut removed = stored.clone();
+ table_mut(&mut removed).rows[0].cells[1]
+ .paragraphs
+ .retain(|paragraph| paragraph.id != holder_id);
+ let again = PreparedEdit::page(written.as_bytes(), space, &removed, AUTHOR).unwrap();
+ assert_eq!(assert_same(again.as_bytes(), space, &removed), before);
+}
+
#[test]
fn inconsistent_tables_are_refused() {
let (space, before) = page_by_title(TREES, "Delete cell subtree");
diff --git a/tools/test_table_edit.py b/tools/test_table_edit.py
index 12305d45cde9b4fa729a8e4cbe9b154042d685b7..8d6af03f2050ad113b65ef0eaeea82b01e43d36f 100644
--- a/tools/test_table_edit.py
+++ b/tools/test_table_edit.py
@@ -53,6 +53,12 @@ class TableEditTest(unittest.TestCase):
self.assertEqual(rows[0][2], ['Third column'])
self.assertEqual(rows[1], [['Second row'], ['Second row, second column'], ['Second row, third column']])
+ def test_a_table_nested_in_a_native_cell_renders_natively(self):
+ self.check('nested')
+ outer, inner = tables(FIXTURE / 'nested/cold/read', 'Delete cell subtree')
+ self.assertEqual(inner, ('true', [('72.0', 'true')], [[['Inner one']], [['Inner two']]]))
+ self.assertEqual(outer[2][0][1], ['Other cell', 'Inner one', 'Inner two'])
+
if __name__ == '__main__':
unittest.main()