From b6b6e903d65a5b876c5c009ba390df25d9842040 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 11 Sep 2026 04:01:57 -0700 Subject: [PATCH] feat: write paragraph alignment, direction and spacing through the page model The page writer lowers paragraph-level formatting from the model: alignment, right-to-left direction and the space before, after and between lines. A value applies when every run of the paragraph carries it and the stored paragraph differs; alignment also refreshes the layout alignment bits, and ancestors take the modification time. Alignment outside left, centre and right, negative, non-finite or out-of-range spacing, runs that disagree on a paragraph value, and generated title fields are refused before any write. corpus/paragraph-format holds a native fixture of six paragraphs (defaults, explicit zeros, centred spacing, right-aligned RTL, an inherited named style and the same style overridden), the writer's candidate that centres every paragraph with 0/2/16 pt spacing, and a fresh OneNote 2010 read of both. The cold read shows each paragraph centred with the written spacing, the zero space before omitted, RTL and quick styles retained and the bold/italic runs unchanged; tools/test_paragraph_format.py checks this without a VM, and the notebook suite covers reopen, rebase against a remote text edit and a lost publication reply for each attribute. Assisted-by: gpt-6-astra Assisted-by: claude-fable-5.1 --- corpus/paragraph-format/README.md | 29 ++++ .../before-capture/machine.json | 1 + .../paragraph-format/before-capture/run.json | 18 +++ .../before-capture/source.json | 14 ++ .../before-capture/teardown.json | 1 + .../before/Open Notebook.onetoc2 | Bin 0 -> 3780 bytes .../before/read/environment.json | 7 + .../before/read/hierarchy.xml | 2 + .../paragraph-format/before/read/page-000.xml | 10 ++ .../paragraph-format/before/read/page-001.xml | 2 + .../before/read/payloads.json | 3 + corpus/paragraph-format/before/synthetic.one | Bin 0 -> 14196 bytes .../candidate/Open Notebook.onetoc2 | Bin 0 -> 1612 bytes .../paragraph-format/candidate/synthetic.one | Bin 0 -> 16488 bytes corpus/paragraph-format/cold/commands.jsonl | 3 + corpus/paragraph-format/cold/machine.json | 1 + .../cold/notebook/Open Notebook.onetoc2 | Bin 0 -> 3780 bytes .../cold/notebook/synthetic.one | Bin 0 -> 18200 bytes .../cold/read/environment.json | 7 + .../paragraph-format/cold/read/hierarchy.xml | 2 + .../paragraph-format/cold/read/page-000.xml | 2 + .../paragraph-format/cold/read/page-001.xml | 10 ++ .../paragraph-format/cold/read/payloads.json | 3 + corpus/paragraph-format/cold/run.json | 18 +++ corpus/paragraph-format/cold/scripts/cold.ps1 | 27 ++++ corpus/paragraph-format/cold/scripts/read.ps1 | 142 ++++++++++++++++++ corpus/paragraph-format/cold/source.json | 14 ++ corpus/paragraph-format/cold/teardown.json | 1 + crates/notebook/tests/sync_formatting.rs | 41 +++++ crates/onestore/src/formatting.rs | 33 ++-- crates/onestore/src/page/write.rs | 115 ++++++++++++++ crates/onestore/tests/paragraph_formatting.rs | 125 +++++++++++++++ tools/test_paragraph_format.py | 57 +++++++ 33 files changed, 677 insertions(+), 11 deletions(-) create mode 100644 corpus/paragraph-format/README.md create mode 100644 corpus/paragraph-format/before-capture/machine.json create mode 100644 corpus/paragraph-format/before-capture/run.json create mode 100644 corpus/paragraph-format/before-capture/source.json create mode 100644 corpus/paragraph-format/before-capture/teardown.json create mode 100644 corpus/paragraph-format/before/Open Notebook.onetoc2 create mode 100644 corpus/paragraph-format/before/read/environment.json create mode 100644 corpus/paragraph-format/before/read/hierarchy.xml create mode 100644 corpus/paragraph-format/before/read/page-000.xml create mode 100644 corpus/paragraph-format/before/read/page-001.xml create mode 100644 corpus/paragraph-format/before/read/payloads.json create mode 100644 corpus/paragraph-format/before/synthetic.one create mode 100644 corpus/paragraph-format/candidate/Open Notebook.onetoc2 create mode 100644 corpus/paragraph-format/candidate/synthetic.one create mode 100644 corpus/paragraph-format/cold/commands.jsonl create mode 100644 corpus/paragraph-format/cold/machine.json create mode 100644 corpus/paragraph-format/cold/notebook/Open Notebook.onetoc2 create mode 100644 corpus/paragraph-format/cold/notebook/synthetic.one create mode 100644 corpus/paragraph-format/cold/read/environment.json create mode 100644 corpus/paragraph-format/cold/read/hierarchy.xml create mode 100644 corpus/paragraph-format/cold/read/page-000.xml create mode 100644 corpus/paragraph-format/cold/read/page-001.xml create mode 100644 corpus/paragraph-format/cold/read/payloads.json create mode 100644 corpus/paragraph-format/cold/run.json create mode 100644 corpus/paragraph-format/cold/scripts/cold.ps1 create mode 100644 corpus/paragraph-format/cold/scripts/read.ps1 create mode 100644 corpus/paragraph-format/cold/source.json create mode 100644 corpus/paragraph-format/cold/teardown.json create mode 100644 crates/onestore/tests/paragraph_formatting.rs create mode 100644 tools/test_paragraph_format.py diff --git a/corpus/paragraph-format/README.md b/corpus/paragraph-format/README.md new file mode 100644 index 0000000000000000000000000000000000000000..c7eedf3c43a9fb4a62d850c6e97a79d473f55564 --- /dev/null +++ b/corpus/paragraph-format/README.md @@ -0,0 +1,29 @@ +# Native paragraph formatting controls + +`tools/native/paragraph-formatting.ps1` authored one page, "Paragraph +formatting", with six paragraphs of mixed bold/italic/plain runs: native +defaults, explicit zero values, centred spacing, right-aligned RTL, a named +style (`h1`, Georgia 18 bold with spacing) inherited, and the same style with +paragraph overrides. `before/` holds the authored notebook and its native read +(`before/read`, captured with `--expected-pages 2 --collect-notebook`; +`before-capture/` retains that run's records). OneNote reports the style's +inherited `spaceBefore="8"`/`spaceAfter="4"` as 288/144 in the read, while +explicit paragraph values read back in points. + +`candidate/` is the page writer's output for the model edit in +`crates/onestore/tests/paragraph_formatting.rs`: every paragraph set to centre +alignment, zero space before, 2 pt after and 16 pt line spacing, with runs, +direction and quick styles untouched. `cold/` is its fresh OneNote 2010 read: +each paragraph is centred with `spaceAfter="2.0"` and `spaceBetween="16.0"`, +the zero space before is omitted, `RTL="true"` survives on the right-aligned +case, `quickStyleIndex` and the bold/italic runs match `before/read`, and +`tools/verify-document.py` finds no differences. `tools/test_paragraph_format.py` +checks these without a VM. + +Regenerate the candidate with `ONESTORE_PARAGRAPH_FORMAT_EXPORT` set to a new +absolute directory while running +`paragraph_properties_preserve_native_mixed_runs_and_shared_styles`, then +cold-open it with `tools/native_runner.py OUTPUT COLD --expected-pages 2 +--collect-notebook`. Regenerate `before/` by running the runner on +`corpus/create-notebook` style input with `--author +tools/native/paragraph-formatting.ps1 --expected-pages 2 --collect-notebook`. diff --git a/corpus/paragraph-format/before-capture/machine.json b/corpus/paragraph-format/before-capture/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..07b48c4660662ead1d0344e1e69b5cba0cdba7e7 --- /dev/null +++ b/corpus/paragraph-format/before-capture/machine.json @@ -0,0 +1 @@ +{"name": "m6-b1f8fbb5", "hostname": "ONE-M6-B1F8FBB5"} diff --git a/corpus/paragraph-format/before-capture/run.json b/corpus/paragraph-format/before-capture/run.json new file mode 100644 index 0000000000000000000000000000000000000000..c3b92a543ca6031bd27a5bc9766eb73779656a95 --- /dev/null +++ b/corpus/paragraph-format/before-capture/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/Users/clo/dev/one/corpus/paragraph-format/before", + "expected_pages": 2, + "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/paragraph-format/before-capture/source.json b/corpus/paragraph-format/before-capture/source.json new file mode 100644 index 0000000000000000000000000000000000000000..ff53d5ef8f2484eb62e816310fcd4ee28be2b15f --- /dev/null +++ b/corpus/paragraph-format/before-capture/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 3780, + "sha256": "bb68c91caf0c082536d5c6ad2cd970a1fe5e68592c528633f97ef8cf406e7915", + "mtime_ns": 1789187746810790205 + }, + { + "path": "synthetic.one", + "bytes": 14196, + "sha256": "780e169db47f5ec5561e7998052763445bca00635174561b76bcb2bb711951b2", + "mtime_ns": 1789187746811006329 + } +] diff --git a/corpus/paragraph-format/before-capture/teardown.json b/corpus/paragraph-format/before-capture/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/paragraph-format/before-capture/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/paragraph-format/before/Open Notebook.onetoc2 b/corpus/paragraph-format/before/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..7628ea95335c191e02cd7ea2d65ca221f3b6fbf6 GIT binary patch literal 3780 zcmZ3e|KIuM`!b(-j|5(YE0x9X>1BJVbFjy4d;66MQ{T^-#()Ow@6HmCp7_;s=>_qL zZ%?edAq|vA#xUjofdIs2f(bxrm^hj+oV&8{RRx>_WGJvfcsfAp04s!@0Hi?XfB;D3 z2p@<VvDU1t4@_XKm43$PdHV zRg8*`hQN>yfd&?aDY6VQJ7pL|vVrtIAT0p&6jM&L`0wuZ{);xc>ozEhonQot$pCRN zLnT8VLkU9$Ln@HWWJqSvW5@@Jqypt&Ig)_^sJej*NH8*dlVMN+>4ktJU%r3cSQWNJ z5Xu)RU}%t@RJ^D4$U?8xv$iiP-X*mbrds+1Lqm_tKkeED{H~qTOAoX8y*~?+gPDg; zL*x}j7#bK<5`Y8{pc@Yo2PtEz+FyU3e}V5lHi4UYE`g#N0t^j$6$~8=EFfJB3=u#K z@dAhk(gBi!+hX2D@6HuFL81o9Nf zy`ZujWDkf2xpCRH`YA_1k{|$aYXkPQRA+TqwxXWLdrsYsuouU6`hj#4pO%33fzuMm zDkgAx0;N%qE4IwL2P9!(3UVvRWa$?T4HLf9ywVNX<+5~s@zbVo#ypTXwzRb2josUP zr@M?Gc?JeWkp@t@0TMvaAEL3UDL&q(>He0QiEk!7RyBZ#gDeKIL0UjqL!hCd2dL-F zNA>p|?;KrsZ%CD|zEslr!JJWIGz3ON02%_wSp`-Gg7Up65Q8$D=G+e%P^F{PXb6mkzz7Hd0F{VC Apa1{> literal 0 HcmV?d00001 diff --git a/corpus/paragraph-format/before/read/environment.json b/corpus/paragraph-format/before/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..d6e3363bfb7d4607c588c1f5b784323aa6b203f2 --- /dev/null +++ b/corpus/paragraph-format/before/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-B1F8FBB5", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/paragraph-format/before/read/hierarchy.xml b/corpus/paragraph-format/before/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..d4f0bbb976093360e64c40c63f211fd451a39941 --- /dev/null +++ b/corpus/paragraph-format/before/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/paragraph-format/before/read/page-000.xml b/corpus/paragraph-format/before/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..88db6e9b394996465a4ae23a95e82f87aacc0717 --- /dev/null +++ b/corpus/paragraph-format/before/read/page-000.xml @@ -0,0 +1,10 @@ + +bold italic plain]]>bold italic plain]]>bold italic plain]]>bold italic plain]]>Style inherited: bold italic plain]]>Style overridden: bold italic plain]]> diff --git a/corpus/paragraph-format/before/read/page-001.xml b/corpus/paragraph-format/before/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..328d4f6fa2ebf439b24f7b46f27ac953de6ae9bb --- /dev/null +++ b/corpus/paragraph-format/before/read/page-001.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/paragraph-format/before/read/payloads.json b/corpus/paragraph-format/before/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/paragraph-format/before/read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/paragraph-format/before/synthetic.one b/corpus/paragraph-format/before/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..20f3a15cef7d036acaeeb665961d8e7c90666daa GIT binary patch literal 14196 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 literal 0 HcmV?d00001 diff --git a/corpus/paragraph-format/candidate/synthetic.one b/corpus/paragraph-format/candidate/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..39e83362b177fe86bea07c8a07ebdcd965ccf9b4 GIT binary patch literal 16488 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 NLjPY-bYaThe*kH8$w~kK literal 0 HcmV?d00001 diff --git a/corpus/paragraph-format/cold/commands.jsonl b/corpus/paragraph-format/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..0d2c75fe788178a57fff8734dcd1cdb3f817c9df --- /dev/null +++ b/corpus/paragraph-format/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-6A989DFC -ExpectedPages 2", "exit": 0, "stdout": "Read 1 sections and 2 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/paragraph-format/cold/machine.json b/corpus/paragraph-format/cold/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..cd43464ea90acb7909b6205b068cffd367395804 --- /dev/null +++ b/corpus/paragraph-format/cold/machine.json @@ -0,0 +1 @@ +{"name": "m6-6a989dfc", "hostname": "ONE-M6-6A989DFC"} diff --git a/corpus/paragraph-format/cold/notebook/Open Notebook.onetoc2 b/corpus/paragraph-format/cold/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..79bfedd2168b054cafb54f21916d067c8c939f5c GIT binary patch literal 3780 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 + diff --git a/corpus/paragraph-format/cold/read/page-000.xml b/corpus/paragraph-format/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..eb00e7aa4439f770a60468d3253691090e71eabb --- /dev/null +++ b/corpus/paragraph-format/cold/read/page-000.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/paragraph-format/cold/read/page-001.xml b/corpus/paragraph-format/cold/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..d959576a83374a5dea55f7dd4415ba060f78ae08 --- /dev/null +++ b/corpus/paragraph-format/cold/read/page-001.xml @@ -0,0 +1,10 @@ + +bold italic plain]]>bold italic plain]]>bold italic plain]]>bold italic plain]]>Style inherited: bold italic plain]]>Style overridden: bold italic plain]]> diff --git a/corpus/paragraph-format/cold/read/payloads.json b/corpus/paragraph-format/cold/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/paragraph-format/cold/read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/paragraph-format/cold/run.json b/corpus/paragraph-format/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..e0b4d8979eec0e16cd8d1be47fd5c1fdc56bdddf --- /dev/null +++ b/corpus/paragraph-format/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/m4/pf", + "expected_pages": 2, + "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/paragraph-format/cold/scripts/cold.ps1 b/corpus/paragraph-format/cold/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/paragraph-format/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/paragraph-format/cold/scripts/read.ps1 b/corpus/paragraph-format/cold/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b --- /dev/null +++ b/corpus/paragraph-format/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/paragraph-format/cold/source.json b/corpus/paragraph-format/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..159315657a6ba601e29611184f2c11886c677453 --- /dev/null +++ b/corpus/paragraph-format/cold/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 1612, + "sha256": "a2c5dbdebb4884003d98144259b2c376ed380fce1b4d3605ce404eb03c2879d4", + "mtime_ns": 1789198428127888130 + }, + { + "path": "synthetic.one", + "bytes": 16488, + "sha256": "dd352fed4d9e4aa02655f6500b4dc921e0c8557287aa32b907e1608894e4caac", + "mtime_ns": 1789198428127605129 + } +] diff --git a/corpus/paragraph-format/cold/teardown.json b/corpus/paragraph-format/cold/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/paragraph-format/cold/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/crates/notebook/tests/sync_formatting.rs b/crates/notebook/tests/sync_formatting.rs index a6699e5801e372ac798687a5892f6096a5d3dfeb..a45e04dceafbdd0ca61bca9600abee508d190558 100644 --- a/crates/notebook/tests/sync_formatting.rs +++ b/crates/notebook/tests/sync_formatting.rs @@ -497,3 +497,44 @@ fn a_formatted_save_of_an_unchanged_model_queues_nothing() { assert_eq!(cache.save(OUTLINES, space, &page, AUTHOR).unwrap(), None); assert!(cache.pending().unwrap().is_empty()); } + +#[test] +fn paragraph_formatting_survives_reopen_rebase_and_a_lost_publication_reply() { + let (space, page) = page_titled(OUTLINES, PAGE); + let ids = plain(&page); + let attributes: &[fn(&mut Format)] = &[ + |f| f.alignment = Some(2), + |f| f.rtl = Some(true), + |f| f.space_before = Some(12.0), + |f| f.space_after = Some(6.0), + |f| f.line_spacing = Some(18.0), + ]; + for apply in attributes { + let directory = tempfile::tempdir().unwrap(); + let cache = cache(&directory); + let id = save(&cache, ids[0], |page| restyle(page, ids[0], 0..6, apply)) + .unwrap() + .unwrap(); + let expected = formats(&page_of(&cache.snapshot().unwrap(), space), ids[0]); + drop(cache); + let cache = Replica::open(directory.path().join("cache.sqlite")).unwrap(); + let mut server = remote_with(space, |page| replace_text(page, ids[3], 0..0, "Remote ")); + server.fault = Fault::UnknownAfter; + assert!( + matches!(cache.sync_once(&mut server), Err(notebook::Error::Remote(error)) if error.state == onestore::CommitState::Unknown) + ); + assert!(matches!( + cache.status(id).unwrap(), + Some(EditStatus::AwaitingConfirmation { .. }) + )); + drop(cache); + let cache = Replica::open(directory.path().join("cache.sqlite")).unwrap(); + assert!( + matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id) + ); + assert_eq!(server.publications, 1); + let page = page_of(&server.durable, space); + assert_eq!(formats(&page, ids[0]), expected); + assert!(text_of(&page, ids[3]).starts_with("Remote ")); + } +} diff --git a/crates/onestore/src/formatting.rs b/crates/onestore/src/formatting.rs index 14009351ee40ed34ae53aa12dfbb55fb71792cb8..b6cb67e5f346d7843cb829368c9fa889756d8678 100644 --- a/crates/onestore/src/formatting.rs +++ b/crates/onestore/src/formatting.rs @@ -244,21 +244,32 @@ pub(crate) fn format_text( (0x14001d7a, &modified), ])?; changed.insert(object, target); - let mut pending = parents.get(&object).cloned().unwrap_or_default(); - let mut ancestors = BTreeSet::new(); - while let Some(id) = pending.pop() { - if !ancestors.insert(id) { - continue; - } - let mut ancestor = PropertyObject::from_object(&raw.objects[&id])?; - ancestor.set(&[(0x14001d7a, &modified)])?; - changed.insert(id, ancestor); - pending.extend(parents.get(&id).into_iter().flatten().copied()); - } + touch_ancestors(raw, &parents, object, &modified, &mut changed)?; Ok(changed) }) } +pub(crate) fn touch_ancestors( + raw: &crate::ResolvedRevision<'_>, + parents: &BTreeMap>, + object: ExGuid, + modified: &[u8; 4], + changed: &mut BTreeMap, +) -> Result<(), Error> { + let mut pending = parents.get(&object).cloned().unwrap_or_default(); + let mut ancestors = BTreeSet::new(); + while let Some(id) = pending.pop() { + if !ancestors.insert(id) { + continue; + } + let mut ancestor = PropertyObject::from_object(&raw.objects[&id])?; + ancestor.set(&[(0x14001d7a, modified)])?; + changed.insert(id, ancestor); + pending.extend(parents.get(&id).into_iter().flatten().copied()); + } + Ok(()) +} + pub(crate) fn attribute_values(attributes: &[TextAttribute]) -> Result)>, Error> { if attributes.is_empty() { return Err(invalid("Choose at least one formatting attribute")); diff --git a/crates/onestore/src/page/write.rs b/crates/onestore/src/page/write.rs index 350cb23d36dbd13cd949cff29e7b49e443326ccb..d4b5399efa9a1bde1bcae3c9e2c6993ac9ca3d66 100644 --- a/crates/onestore/src/page/write.rs +++ b/crates/onestore/src/page/write.rs @@ -206,6 +206,7 @@ impl Lowering<'_> { self.place(&old, &new, &placed, &page_order)?; self.delete(&old, &new, &consumed)?; self.edit_text(&new)?; + self.edit_paragraph_formatting(&new)?; self.edit_formatting(&new)?; self.edit_layout(&old, &new)?; Ok(()) @@ -658,6 +659,120 @@ impl Lowering<'_> { Ok(()) } + fn edit_paragraph_formatting(&mut self, new: &View<'_>) -> Result<(), Error> { + let current = self.current()?; + let current = View::new(¤t)?; + for (id, paragraph) in &new.paragraphs { + let Some(text) = paragraph.text() else { + continue; + }; + let stored = current + .text(self.id(*id)) + .ok_or_else(|| invalid("A paragraph is missing after text edits"))?; + let mut values: Vec<(u32, Vec)> = Vec::new(); + macro_rules! field { + ($field:ident, $value:ident, $encode:block) => { + let $value = format_in(&text.text, 0)?.$field.unwrap_or_default(); + if text + .text + .spans() + .iter() + .all(|span| span.format.$field.unwrap_or_default() == $value) + && stored + .text + .spans() + .iter() + .any(|span| span.format.$field.unwrap_or_default() != $value) + { + values.push($encode); + } + }; + } + field!(alignment, value, { + if value > 2 { + return Err(invalid("Paragraph alignment must be left, center or right")); + } + (0x0c003477, vec![value]) + }); + field!(rtl, value, { + (0x08003476 | (u32::from(value) << 31), Vec::new()) + }); + macro_rules! spacing { + ($field:ident, $property:expr) => { + field!($field, value, { + let stored = value / 36.0; + if !stored.is_finite() || !(0.0..=27777.777).contains(&stored) { + return Err(invalid("Paragraph spacing is outside the document range")); + } + ($property, stored.to_le_bytes().to_vec()) + }); + }; + } + spacing!(space_before, 0x1400342e); + spacing!(space_after, 0x1400342f); + spacing!(line_spacing, 0x14003430); + if values.is_empty() { + continue; + } + if text.date_field.is_some() { + return Err(invalid( + "Generated title fields cannot be formatted as ordinary text", + )); + } + let (space, object) = (self.space, stored.id); + self.apply(|image| { + let store = Store::parse(image)?; + let index = RevisionIndex::parse(&store)?; + let document = Document::parse(&index)?; + let parents = crate::edit::editable_parents( + document.active(space)?, + &document.pages_in(space)?, + object, + )?; + let modified = crate::create::current_timestamps()?.0.to_le_bytes(); + crate::write::write_revision(image, space, |raw| { + let mut target = PropertyObject::from_object(&raw.objects[&object])?; + target.set( + &values + .iter() + .map(|(id, bytes)| (*id, bytes.as_slice())) + .collect::>(), + )?; + if let Some((_, alignment)) = values.iter().find(|(id, _)| *id == 0x0c003477) { + for property in [0x14001c3e, 0x14001c84] { + let fields = PropertySets::parse(&target.bytes)?; + let previous = fields.sets[0] + .iter() + .find(|field| field.id == property) + .map(|field| match field.value { + Value::Bytes(bytes) => bytes + .try_into() + .map(u32::from_le_bytes) + .map_err(|_| invalid("Invalid paragraph layout alignment")), + _ => Err(invalid("Invalid paragraph layout alignment")), + }) + .transpose()? + .unwrap_or(0); + let value = (previous & !7) | (u32::from(alignment[0]) + 1); + target.set(&[(property, &value.to_le_bytes())])?; + } + } + target.set(&[(0x14001d7a, &modified)])?; + let mut changed = BTreeMap::from([(object, target)]); + crate::formatting::touch_ancestors( + raw, + &parents, + object, + &modified, + &mut changed, + )?; + Ok(changed) + }) + })?; + } + Ok(()) + } + fn edit_formatting(&mut self, new: &View<'_>) -> Result<(), Error> { let current = self.current()?; let current = View::new(¤t)?; diff --git a/crates/onestore/tests/paragraph_formatting.rs b/crates/onestore/tests/paragraph_formatting.rs new file mode 100644 index 0000000000000000000000000000000000000000..2ef24da7969f4fa40082a3f0734871722c379972 --- /dev/null +++ b/crates/onestore/tests/paragraph_formatting.rs @@ -0,0 +1,125 @@ +use onestore::{ + ExGuid, PreparedEdit, RevisionIndex, Store, + document::{Document, Kind}, + page::{Page, PageObject, Paragraph}, +}; + +const SOURCE: &[u8] = include_bytes!("../../../corpus/paragraph-format/before/synthetic.one"); + +fn page(bytes: &[u8]) -> (ExGuid, Page) { + let store = Store::parse(bytes).unwrap(); + assert!(store.checksum_mismatches.is_empty()); + let index = RevisionIndex::parse(&store).unwrap(); + index.validate_current().unwrap(); + let document = Document::parse(&index).unwrap(); + document + .pages() + .unwrap() + .into_iter() + .find_map(|(space, _)| { + let page = Page::from_space(&document, space).unwrap(); + (page.title == "Paragraph formatting").then_some((space, page)) + }) + .unwrap() +} + +#[test] +fn paragraph_properties_preserve_native_mixed_runs_and_shared_styles() { + let (space, before) = page(SOURCE); + let mut after = before.clone(); + let mut count = 0; + for object in &mut after.objects { + if let PageObject::Outline(outline) = object { + for paragraph in &mut outline.paragraphs { + let text = paragraph.text_mut().unwrap(); + let mut start = 0; + text.text = Paragraph::from_runs(text.text.spans().iter().map(|span| { + let value = text.text.text()[start..span.end].to_owned(); + start = span.end; + let mut format = span.format.clone(); + format.alignment = Some(1); + format.space_before = Some(0.0); + format.space_after = Some(2.0); + format.line_spacing = Some(16.0); + (value, format) + })); + count += 1; + } + } + } + assert_eq!(count, 6); + let prepared = PreparedEdit::page(SOURCE, space, &after, "Format author").unwrap(); + assert_eq!(page(prepared.as_bytes()).1, after); + let before_store = Store::parse(SOURCE).unwrap(); + let before_index = RevisionIndex::parse(&before_store).unwrap(); + let before_doc = Document::parse(&before_index).unwrap(); + let after_store = Store::parse(prepared.as_bytes()).unwrap(); + let after_index = RevisionIndex::parse(&after_store).unwrap(); + let old = before_index.resolve_active(space).unwrap(); + let new = after_index.resolve_active(space).unwrap(); + for (id, node) in &before_doc.active(space).unwrap().nodes { + if matches!(node.kind, Kind::Style { .. }) { + assert_eq!(old.objects[id].data, new.objects[id].data); + } + } + if let Some(directory) = std::env::var_os("ONESTORE_PARAGRAPH_FORMAT_EXPORT") { + let directory = std::path::PathBuf::from(directory); + std::fs::create_dir(&directory).unwrap(); + std::fs::write(directory.join("synthetic.one"), prepared.as_bytes()).unwrap(); + std::fs::write( + directory.join("Open Notebook.onetoc2"), + onestore::create_table_of_contents( + "Open Notebook.onetoc2", + &[("synthetic.one", after_store.header.file_id)], + ) + .unwrap(), + ) + .unwrap(); + } +} + +#[test] +fn paragraph_formatting_rejects_invalid_values_and_conflicting_run_settings() { + let (space, before) = page(SOURCE); + let invalid: &[fn(&mut onestore::document::Format)] = &[ + |format| format.alignment = Some(3), + |format| format.space_before = Some(-1.0), + |format| format.space_after = Some(f32::NAN), + |format| format.line_spacing = Some(f32::INFINITY), + |format| format.space_after = Some(1_000_100.0), + ]; + for change in invalid { + let mut after = before.clone(); + let text = after + .objects + .iter_mut() + .find_map(|object| match object { + PageObject::Outline(outline) => { + outline.paragraphs.iter_mut().find_map(|p| p.text_mut()) + } + _ => None, + }) + .unwrap(); + let mut format = text.text.format_at(0).unwrap().clone(); + change(&mut format); + text.text = Paragraph::new(text.text.text().to_owned(), format); + assert!(PreparedEdit::page(SOURCE, space, &after, "Author").is_err()); + } + let mut after = before.clone(); + let text = after + .objects + .iter_mut() + .find_map(|object| match object { + PageObject::Outline(outline) => { + outline.paragraphs.iter_mut().find_map(|p| p.text_mut()) + } + _ => None, + }) + .unwrap(); + let mut left = text.text.format_at(0).unwrap().clone(); + let mut right = left.clone(); + left.alignment = Some(1); + right.alignment = Some(2); + text.text = Paragraph::from_runs([("Left".into(), left), ("Right".into(), right)]); + assert!(PreparedEdit::page(SOURCE, space, &after, "Author").is_err()); +} diff --git a/tools/test_paragraph_format.py b/tools/test_paragraph_format.py new file mode 100644 index 0000000000000000000000000000000000000000..e67aac2c995baf7378f3c893dd0f6b6a8623e2ec --- /dev/null +++ b/tools/test_paragraph_format.py @@ -0,0 +1,57 @@ +from pathlib import Path +import runpy +import shutil +from tempfile import TemporaryDirectory +import unittest +import xml.etree.ElementTree as ET + +from native_format import native_characters +from native_xml import ns + +ROOT = Path(__file__).resolve().parent.parent +FIXTURE = ROOT / 'corpus/paragraph-format' +compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] +IGNORED = {'objectID', 'lastModifiedTime', 'creationTime'} + + +def paragraphs(read): + for path in sorted(read.glob('page-*.xml')): + root = ET.parse(path).getroot() + if root.get('name') == 'Paragraph formatting': + outline, = root.findall('one:Outline', ns) + return [({k: v for k, v in oe.attrib.items() if k not in IGNORED}, oe) + for oe in outline.findall('one:OEChildren/one:OE', ns)], root + raise AssertionError('The paragraph formatting page is missing') + + +class ParagraphFormatTest(unittest.TestCase): + def test_native_controls_and_rust_paragraph_properties_reopen_natively(self): + with TemporaryDirectory() as temporary: + for notebook, read in [(FIXTURE / 'before', FIXTURE / 'before/read'), + (FIXTURE / 'candidate', FIXTURE / 'cold/read')]: + folder = Path(temporary) / notebook.name + shutil.copytree(read, folder / 'read') + compare(notebook, folder / 'read') + before, before_page = paragraphs(FIXTURE / 'before/read') + after, after_page = paragraphs(FIXTURE / 'cold/read') + self.assertEqual(len(before), 6) + self.assertEqual([attributes for attributes, _ in before], [ + {'alignment': 'left', 'quickStyleIndex': '0'}, + {'alignment': 'left', 'quickStyleIndex': '0'}, + {'alignment': 'center', 'spaceBefore': '12.0', 'spaceAfter': '6.0', 'spaceBetween': '20.0', 'quickStyleIndex': '0'}, + {'RTL': 'true', 'alignment': 'right', 'spaceBefore': '3.0', 'spaceAfter': '9.0', 'spaceBetween': '18.0', 'quickStyleIndex': '0'}, + {'alignment': 'left', 'spaceBefore': '288.0', 'spaceAfter': '144.0', 'quickStyleIndex': '1'}, + {'alignment': 'center', 'spaceBefore': '288.0', 'spaceAfter': '2.0', 'spaceBetween': '16.0', 'quickStyleIndex': '1'}, + ]) + for index, ((original, _), (attributes, _)) in enumerate(zip(before, after, strict=True)): + expected = {'alignment': 'center', 'spaceAfter': '2.0', 'spaceBetween': '16.0', + 'quickStyleIndex': original['quickStyleIndex']} + if original.get('RTL') == 'true': + expected['RTL'] = 'true' + self.assertEqual(attributes, expected, index) + self.assertEqual(native_characters(after_page, after_page.findall('one:Outline', ns)), + native_characters(before_page, before_page.findall('one:Outline', ns))) + + +if __name__ == '__main__': + unittest.main() -- 2.54.0