diff --git a/corpus/list-edit/README.md b/corpus/list-edit/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..842c511a21959f8baf292ab367ec0f0003a3500b
--- /dev/null
+++ b/corpus/list-edit/README.md
@@ -0,0 +1,23 @@
+# Rust list authoring
+
+`candidate/` is the page writer's output for
+`bullets_numbering_nesting_and_restarts_publish_on_a_fresh_page` in
+`crates/onestore/tests/page_lists.rs`: on a section created in Rust, the first
+paragraph gains a bullet (glyph U+25CB in Courier New, bullet index 4, 11 pt),
+then two numbered paragraphs, a nested numbered child, a numbered paragraph
+restarting at three, a nested plain paragraph and a trailing plain paragraph.
+The numbering definition is the upper-roman `##.` node copied from
+`corpus/outline-edit/tree`. Every list node is written with the model's
+identity after squash, so the reread model equals the saved one.
+
+`cold/` is a fresh OneNote 2010 read: the bullet renders (reported as bullet
+index 3 at 11 pt), the numbered paragraphs read `I.` and `II.`, the nested
+child restarts at `I.`, the restarted paragraph reads `III.`, and the plain
+paragraphs carry no list. `tools/test_list_edit.py` checks this without a VM.
+
+A list node whose compact identity would be the null identity (sequence zero
+at table index zero) fails OneNote's integrity check on open; the writer
+allocates list nodes like other new objects and renames them to the model's
+identities in squash. Regenerate with `ONESTORE_LIST_EXPORT` set to a new
+absolute directory while running the test, then cold-open it with
+`tools/native_runner.py OUTPUT COLD --expected-pages 1 --collect-notebook`.
diff --git a/corpus/list-edit/candidate/Open Notebook.onetoc2 b/corpus/list-edit/candidate/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..e893988f00c2ccf9b7949df6d788ccba7a05b187
Binary files /dev/null and b/corpus/list-edit/candidate/Open Notebook.onetoc2 differ
diff --git a/corpus/list-edit/candidate/lists.one b/corpus/list-edit/candidate/lists.one
new file mode 100644
index 0000000000000000000000000000000000000000..dc52b83ff3427b66e1becbc74478f066fc5e6b87
Binary files /dev/null and b/corpus/list-edit/candidate/lists.one differ
diff --git a/corpus/list-edit/cold/commands.jsonl b/corpus/list-edit/cold/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..8c378dee3e8ec4968f046d6958215d6418da7e32
--- /dev/null
+++ b/corpus/list-edit/cold/commands.jsonl
@@ -0,0 +1,3 @@
+{"command": "powershell -NoProfile -Command \"Expand-Archive -LiteralPath C:\\one-tests\\transfer.zip -DestinationPath C:\\one-tests\\runs\\capture\\notebook\"", "exit": 0, "stdout": "", "stderr": "", "error": null}
+{"command": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\\one-tests\\read-current.ps1 -Root C:\\one-tests\\runs\\capture -CloneHost ONE-M6-17B0A046 -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/list-edit/cold/machine.json b/corpus/list-edit/cold/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..9e20a9c0a32923eebfb5bee45c31bd457048af14
--- /dev/null
+++ b/corpus/list-edit/cold/machine.json
@@ -0,0 +1 @@
+{"name": "m6-17b0a046", "hostname": "ONE-M6-17B0A046"}
diff --git a/corpus/list-edit/cold/notebook/Open Notebook.onetoc2 b/corpus/list-edit/cold/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..ab1de4e83f613d517c06f3f412aa67871090671c
Binary files /dev/null and b/corpus/list-edit/cold/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/list-edit/cold/notebook/lists.one b/corpus/list-edit/cold/notebook/lists.one
new file mode 100644
index 0000000000000000000000000000000000000000..b253fe7242c385affc1fb063461d5a2cccf05577
Binary files /dev/null and b/corpus/list-edit/cold/notebook/lists.one differ
diff --git a/corpus/list-edit/cold/read/environment.json b/corpus/list-edit/cold/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..473105c8c5428be2089b03e1018102b341234b29
--- /dev/null
+++ b/corpus/list-edit/cold/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-17B0A046",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/list-edit/cold/read/hierarchy.xml b/corpus/list-edit/cold/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..357ed888a2478ca70d620e2e3068865c22d1c859
--- /dev/null
+++ b/corpus/list-edit/cold/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/list-edit/cold/read/page-000.xml b/corpus/list-edit/cold/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..b3d70d1c0f12a58781a996ce5c0ea824123f88a9
--- /dev/null
+++ b/corpus/list-edit/cold/read/page-000.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/list-edit/cold/read/payloads.json b/corpus/list-edit/cold/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843
--- /dev/null
+++ b/corpus/list-edit/cold/read/payloads.json
@@ -0,0 +1,3 @@
+[
+
+]
\ No newline at end of file
diff --git a/corpus/list-edit/cold/run.json b/corpus/list-edit/cold/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..b01c766d642cea229452fba5c07433d8c308c98c
--- /dev/null
+++ b/corpus/list-edit/cold/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/private/tmp/m4/lists",
+ "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/list-edit/cold/scripts/cold.ps1 b/corpus/list-edit/cold/scripts/cold.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f
--- /dev/null
+++ b/corpus/list-edit/cold/scripts/cold.ps1
@@ -0,0 +1,27 @@
+param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
+$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
+if ($CloneHost) {
+ if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
+ throw 'The disposable clone hostname does not match this machine.'
+ }
+ New-Item "$key\Options\Paths" -Force | Out-Null
+ New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
+} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
+ -not (Test-Path 'C:\one-tests\profile-original-cache')) {
+ throw 'Park the personal OneNote profile before resetting the test cache.'
+}
+$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
+$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
+if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
+New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
+if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
+if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
+New-Item "$key\OpenNotebooks" | Out-Null
+New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
diff --git a/corpus/list-edit/cold/scripts/read.ps1 b/corpus/list-edit/cold/scripts/read.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..476094ab1e0c47077988a6461db2080cf208642b
--- /dev/null
+++ b/corpus/list-edit/cold/scripts/read.ps1
@@ -0,0 +1,142 @@
+param(
+ [Parameter(Mandatory=$true)][string]$Root,
+ [int]$ExpectedPages = -1,
+ [switch]$UseCurrentCache,
+ [switch]$Pdf,
+ [switch]$KeepOpen,
+ [string]$CloneHost = ''
+)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
+ throw 'Choose a run directly below C:\one-tests\runs.'
+}
+$notebook = Join-Path $root 'notebook'
+$output = Join-Path $root 'read'
+if (Test-Path $output) { throw 'Choose a new read destination.' }
+if ($UseCurrentCache) {
+ if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
+ throw 'Park the personal OneNote profile before reading test notebooks.'
+ }
+} else {
+ & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
+}
+New-Item -ItemType Directory -Path $output | Out-Null
+$app = New-Object -ComObject OneNote.Application
+$notebookId = ''
+$failure = $null
+try {
+ $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
+ $process = Get-Process ONENOTE
+ @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
+ powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
+ ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
+ $sections = @()
+ foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
+ $id = ''
+ $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
+ $sections += $id
+ }
+ $deadline = [DateTime]::UtcNow.AddSeconds(300)
+ $previous = ''
+ $lastChange = ''
+ $stableSince = [DateTime]::UtcNow
+ $settled = $false
+ do {
+ $pages = @{}
+ foreach ($section in $sections) {
+ $hierarchy = ''
+ $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
+ [xml]$xml = $hierarchy
+ foreach ($node in $xml.SelectNodes('//*[@path]')) {
+ if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
+ throw 'OneNote opened a section outside the copied notebook.'
+ }
+ }
+ foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
+ $id = $node.GetAttribute('ID')
+ $content = ''
+ $app.GetPageContent($id, [ref]$content, 1, 1)
+ $pages[$id] = $content
+ }
+ }
+ $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
+ if ($signature -ne $previous) {
+ $lastChange = $previous
+ $previous = $signature
+ $stableSince = [DateTime]::UtcNow
+ }
+ if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
+ ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
+ ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
+ Start-Sleep -Milliseconds 250
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
+ [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
+ $index = 0
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ $index++
+ }
+ $hierarchy = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
+ [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
+ throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
+ }
+ $index = 0
+ $payloads = @()
+ foreach ($id in @($pages.Keys | Sort-Object)) {
+ [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
+ if ($Pdf) {
+ $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
+ $app.NavigateTo($id, '', $false)
+ $app.Publish($id, $pdfPath, 3, '')
+ if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
+ }
+ [xml]$page = $pages[$id]
+ foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
+ $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
+ $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
+ [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
+ $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
+ kind = $file.LocalName; name = $file.GetAttribute('preferredName');
+ sha256 = $hash; bytes = $bytes.Length }
+ }
+ $index++
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
+ $all = ''
+ $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
+ [xml]$finalTree = $all
+ $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
+ if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
+ throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
+ }
+ [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
+ Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
+} catch {
+ $failure = $_
+ [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ throw
+} finally {
+ try {
+ try {
+ if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
+ if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
+ $app.CloseNotebook($notebookId, $false)
+ }
+ } catch {
+ if ($null -eq $failure) { throw }
+ [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
+ }
+ } finally {
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ }
+ $app = $null
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+ if (-not $UseCurrentCache -and -not $CloneHost) {
+ Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
+ }
+}
diff --git a/corpus/list-edit/cold/source.json b/corpus/list-edit/cold/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..f431b9c2223759b278a6a42d5246847003cd5601
--- /dev/null
+++ b/corpus/list-edit/cold/source.json
@@ -0,0 +1,14 @@
+[
+ {
+ "path": "Open Notebook.onetoc2",
+ "bytes": 1604,
+ "sha256": "25824736beb7e6c953922576b8d8309ec64568d08d1c850e71efa80efe2ee069",
+ "mtime_ns": 1789204406442521201
+ },
+ {
+ "path": "lists.one",
+ "bytes": 11328,
+ "sha256": "8ee832fe46e233c22995fd73ed9df9cb9ff345752570a250979aaa26c8f1ef15",
+ "mtime_ns": 1789204406442128119
+ }
+]
diff --git a/corpus/list-edit/cold/teardown.json b/corpus/list-edit/cold/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/list-edit/cold/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/crates/notebook/README.md b/crates/notebook/README.md
index 01cbfca2592e65efc3c102e37665459188671f5a..afca8b056f56a9cb369ad581bb8a5045f0c952b4 100644
--- a/crates/notebook/README.md
+++ b/crates/notebook/README.md
@@ -14,8 +14,9 @@ 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 structure, outline layout, insertions and deletions are all differences
-between `before` and `after`; the library never sees editor operations.
+paragraph formatting, bullets and numbering, paragraph structure, outline
+layout, insertions and deletions are all differences between `before` and
+`after`; the library never sees editor operations.
```no_run
use onestore::{ExGuid, page::Page};
diff --git a/crates/notebook/src/merge.rs b/crates/notebook/src/merge.rs
index 8a70862a15ab61e7e373ac2dd7d68f15a431aa02..136bba9d597c4e21637540a796ab15118c8e26a0 100644
--- a/crates/notebook/src/merge.rs
+++ b/crates/notebook/src/merge.rs
@@ -97,12 +97,29 @@ pub(crate) fn merge(base: &Page, ours: &Page, theirs: &Page) -> Option {
}
result = reorder(result, &our_order);
}
+ // Definitions we added or changed travel with our paragraphs; theirs win elsewhere.
+ let mut definitions = theirs.definitions.clone();
+ for (id, definition) in &ours.definitions {
+ match (base.definitions.get(id), theirs.definitions.get(id)) {
+ (Some(before), Some(after)) if before != definition => {
+ if after != before && after != definition {
+ return None;
+ }
+ definitions.insert(*id, definition.clone());
+ }
+ (None, None) => {
+ definitions.insert(*id, definition.clone());
+ }
+ (None, Some(after)) if after != definition => return None,
+ _ => {}
+ }
+ }
Some(Page {
title: theirs.title.clone(),
created: theirs.created,
margin_origin: theirs.margin_origin,
objects: result,
- definitions: theirs.definitions.clone(),
+ definitions,
})
}
diff --git a/crates/notebook/tests/sync_model.rs b/crates/notebook/tests/sync_model.rs
index f830801de36ec2f510f0714c1cedcfc2a3ba01d2..b22176a9e37953ba0df47945b9557659951ac98d 100644
--- a/crates/notebook/tests/sync_model.rs
+++ b/crates/notebook/tests/sync_model.rs
@@ -532,3 +532,63 @@ fn outline_moves_merge_with_remote_text_edits_but_not_with_remote_moves() {
Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
);
}
+
+#[test]
+fn a_local_bullet_merges_with_a_remote_text_edit_and_publishes_its_definition() {
+ let (space, before) = page_titled(OUTLINES, "Move leaf down");
+ let directory = tempfile::tempdir().unwrap();
+ let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
+ let mut after = before.clone();
+ let bullet = onestore::page::text::new_id().unwrap();
+ after.definitions.insert(
+ bullet,
+ onestore::page::Definition {
+ kind: onestore::document::Kind::List {
+ font: Some("Courier New".into()),
+ format: Some("\u{25cb}".into()),
+ restart: None,
+ bullet: Some(4),
+ },
+ format: onestore::document::Format {
+ font_size: Some(11.0),
+ color: Some(0xff000000),
+ ..Default::default()
+ },
+ },
+ );
+ let outline = after
+ .objects
+ .iter_mut()
+ .find_map(|object| match object {
+ PageObject::Outline(outline) if !outline.title => Some(outline),
+ _ => None,
+ })
+ .unwrap();
+ outline.paragraphs[0].lists = vec![bullet];
+ let id = cache
+ .save(OUTLINES, space, &after, "Model author")
+ .unwrap()
+ .unwrap();
+ let mut server = remote_with(space, |page| edit_paragraph(page, 1, 0..0, "Remote "));
+ assert!(
+ matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)
+ );
+ let published = page_of(&server.durable, space);
+ let outline = published
+ .objects
+ .iter()
+ .find_map(|object| match object {
+ PageObject::Outline(outline) if !outline.title => Some(outline),
+ _ => None,
+ })
+ .unwrap();
+ assert_eq!(outline.paragraphs[0].lists, vec![bullet]);
+ assert!(matches!(
+ published.definitions[&bullet].kind,
+ onestore::document::Kind::List {
+ bullet: Some(4),
+ ..
+ }
+ ));
+ assert!(texts(&published)[1].starts_with("Remote "));
+}
diff --git a/crates/onestore/README.md b/crates/onestore/README.md
index ca543ea889f24a1c7c5d763f3f8792775dbc9881..e796392b892eec81dbcab9c91ba8897dc2b25912 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 formatting, 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, bullets and numbering, 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 d4b5399efa9a1bde1bcae3c9e2c6993ac9ca3d66..a49864f4b3fdf788ad2c86621a22ef06549ea9c2 100644
--- a/crates/onestore/src/page/write.rs
+++ b/crates/onestore/src/page/write.rs
@@ -5,7 +5,7 @@ use super::{Outline, Page, PageObject, PageParagraph, ParagraphContent};
use crate::{
Error, ExGuid, Insertion, ObjectData, OutlineEdit, ParagraphJoin, ParagraphSplit, PropertySets,
RevisionIndex, Store, TextAttribute, TreeEdit, Value,
- document::{Document, Format},
+ document::{Document, Format, Kind},
write::{PropertyObject, RevisionEdit, write_revisions},
};
use std::{
@@ -173,8 +173,10 @@ impl Lowering<'_> {
return Err(invalid("Page creation time and margins cannot be edited"));
}
for (id, definition) in &after.definitions {
- if before.definitions.get(id) != Some(definition) {
- return Err(invalid("List, tag and style definitions cannot be edited"));
+ if before.definitions.get(id) != Some(definition)
+ && !matches!(definition.kind, Kind::List { .. })
+ {
+ return Err(invalid("Tag and style definitions cannot be edited"));
}
}
let old = View::new(before)?;
@@ -206,6 +208,7 @@ impl Lowering<'_> {
self.place(&old, &new, &placed, &page_order)?;
self.delete(&old, &new, &consumed)?;
self.edit_text(&new)?;
+ self.edit_lists(after, &new)?;
self.edit_paragraph_formatting(&new)?;
self.edit_formatting(&new)?;
self.edit_layout(&old, &new)?;
@@ -272,13 +275,12 @@ impl Lowering<'_> {
let Some(previous) = old.paragraphs.get(id) else {
continue;
};
- let same = paragraph.lists == previous.lists
- && paragraph.tags == previous.tags
+ let same = paragraph.tags == previous.tags
&& paragraph.style == previous.style
&& paragraph.format == previous.format;
if !same {
return Err(invalid(
- "Paragraph lists, tags, styles and paragraph formatting cannot be edited",
+ "Paragraph tags, styles and paragraph formatting cannot be edited",
));
}
match (¶graph.content, &previous.content) {
@@ -557,13 +559,11 @@ impl Lowering<'_> {
let text = paragraph
.text()
.filter(|text| text.date_field.is_none() && text.tags.is_empty())
- .filter(|_| {
- paragraph.lists.is_empty()
- && paragraph.tags.is_empty()
- && paragraph.style.is_none()
- })
+ .filter(|_| paragraph.tags.is_empty() && paragraph.style.is_none())
.ok_or_else(|| {
- invalid("New paragraphs contain plain text without lists, tags, fields or styles")
+ invalid(
+ "New paragraphs contain plain text without tags, fields or styles",
+ )
})?;
let insertion = Insertion::paragraph(
self.id(container),
@@ -659,6 +659,185 @@ impl Lowering<'_> {
Ok(())
}
+ /// Gives each paragraph the list nodes its model references: a definition new to the
+ /// section becomes a list node carrying the model's identity, a changed one is rewritten
+ /// in place, a definition another paragraph already owns is copied because native list
+ /// nodes belong to one paragraph, and a dropped reference leaves the node unreferenced.
+ fn edit_lists(&mut self, after: &Page, new: &View<'_>) -> Result<(), Error> {
+ let current = self.current()?;
+ let stored = View::new(¤t)?;
+ let mut owners: BTreeMap = BTreeMap::new();
+ for (id, paragraph) in &stored.paragraphs {
+ for list in ¶graph.lists {
+ owners.insert(*list, *id);
+ }
+ }
+ for (id, paragraph) in &new.paragraphs {
+ let image_id = self.id(*id);
+ let previous = stored
+ .paragraphs
+ .get(&image_id)
+ .ok_or_else(|| invalid("A paragraph is missing after text edits"))?;
+ let same = paragraph.lists.len() == previous.lists.len()
+ && paragraph
+ .lists
+ .iter()
+ .zip(&previous.lists)
+ .all(|(model, node)| {
+ after.definitions.get(model) == current.definitions.get(node)
+ });
+ if same {
+ continue;
+ }
+ let mut nodes = Vec::new();
+ for list in ¶graph.lists {
+ // Writer-allocated identities keep a nonzero sequence number so the node's
+ // compact identity is never the null identity; squash renames them to the
+ // model's. A copy for a second owner keeps its allocated identity.
+ let node_id = match owners.get(list) {
+ Some(owner) if *owner == image_id => *list,
+ Some(_) => ExGuid {
+ guid: crate::write::fresh_guid()?,
+ n: 1,
+ },
+ None => {
+ let written = ExGuid {
+ guid: crate::write::fresh_guid()?,
+ n: 1,
+ };
+ self.alias.insert(*list, written);
+ written
+ }
+ };
+ let definition = after
+ .definitions
+ .get(list)
+ .ok_or_else(|| invalid("A paragraph references a missing list definition"))?;
+ let Kind::List {
+ font,
+ format,
+ restart,
+ bullet,
+ } = &definition.kind
+ else {
+ return Err(invalid("A paragraph list must reference a list definition"));
+ };
+ if bullet.is_some() && (format.is_none() || font.is_none()) {
+ return Err(invalid("A bullet definition names its glyph and font"));
+ }
+ let mut values: Vec<(u32, Vec)> = Vec::new();
+ if let Some(format) = format {
+ let units: Vec = format.encode_utf16().collect();
+ let count = u16::try_from(units.len())
+ .map_err(|_| invalid("List format exceeds the document range"))?;
+ let mut bytes = count.to_le_bytes().to_vec();
+ bytes.extend(units.iter().flat_map(|unit| unit.to_le_bytes()));
+ values.push((0x1c001c1a, bytes));
+ }
+ if let Some(font) = font {
+ values.push((0x1c001c52, crate::create::string(font)));
+ }
+ if let Some(restart) = restart {
+ values.push((0x14001cb7, restart.to_le_bytes().to_vec()));
+ }
+ if let Some(bullet) = bullet {
+ values.push((0x10001d0e, bullet.to_le_bytes().to_vec()));
+ // Every native bullet node carries this cleared flag alongside its index.
+ values.push((0x0c001cc0, vec![0]));
+ }
+ let style = &definition.format;
+ if let Some(font) = &style.font {
+ values.push((0x1c001c0a, crate::create::string(font)));
+ }
+ if let Some(size) = style.font_size {
+ let half = (size * 2.0).round();
+ if !(0.0..=f32::from(u16::MAX)).contains(&half) {
+ return Err(invalid("List font size is outside the document range"));
+ }
+ values.push((0x10001c0b, (half as u16).to_le_bytes().to_vec()));
+ }
+ if let Some(color) = style.color {
+ values.push((0x14001c0c, color.to_le_bytes().to_vec()));
+ }
+ if let Some(language) = style.language {
+ values.push((0x14001c3b, language.to_le_bytes().to_vec()));
+ }
+ for (flag, id) in [(style.bold, 0x08001c04), (style.italic, 0x08001c05)] {
+ if let Some(flag) = flag {
+ values.push((id | (u32::from(flag) << 31), Vec::new()));
+ }
+ }
+ nodes.push((node_id, values));
+ }
+ let (space, object) = (self.space, self.id(*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 changed = BTreeMap::new();
+ let mut target = PropertyObject::from_object(&raw.objects[&object])?;
+ let mut references = Vec::new();
+ for (list, values) in &nodes {
+ let mut node = match raw.objects.get(list) {
+ Some(existing) => {
+ if existing.jcid != 0x60012 {
+ return Err(invalid(
+ "A list definition identity belongs to another object",
+ ));
+ }
+ let mut node = PropertyObject::from_object(existing)?;
+ node.remove(&[
+ 0x1c001c1a, 0x1c001c52, 0x14001cb7, 0x10001d0e, 0x0c001cc0,
+ 0x1c001c0a, 0x10001c0b, 0x14001c0c, 0x14001c3b, 0x08001c04,
+ 0x08001c05,
+ ])?;
+ node
+ }
+ None => PropertyObject {
+ jcid: 0x60012,
+ bytes: crate::create::properties(&[])?,
+ global_ids: std::sync::Arc::new(BTreeMap::from([(0, list.guid)])),
+ },
+ };
+ node.set(
+ &values
+ .iter()
+ .map(|(id, bytes)| (*id, bytes.as_slice()))
+ .collect::>(),
+ )?;
+ node.set(&[(0x14001d7a, &modified)])?;
+ node.reference(*list)?;
+ references.extend_from_slice(&target.reference(*list)?);
+ changed.insert(*list, node);
+ }
+ if references.is_empty() {
+ target.remove(&[0x24001c26])?;
+ } else {
+ target.set(&[(0x24001c26, &references)])?;
+ }
+ target.set(&[(0x14001d7a, &modified)])?;
+ changed.insert(object, target);
+ crate::formatting::touch_ancestors(
+ raw,
+ &parents,
+ object,
+ &modified,
+ &mut changed,
+ )?;
+ Ok(changed)
+ })
+ })?;
+ }
+ Ok(())
+ }
+
fn edit_paragraph_formatting(&mut self, new: &View<'_>) -> Result<(), Error> {
let current = self.current()?;
let current = View::new(¤t)?;
diff --git a/crates/onestore/tests/page_lists.rs b/crates/onestore/tests/page_lists.rs
new file mode 100644
index 0000000000000000000000000000000000000000..af757f3d277fe932987552b273abdd2c83d08bcb
--- /dev/null
+++ b/crates/onestore/tests/page_lists.rs
@@ -0,0 +1,322 @@
+use onestore::{
+ ExGuid, PreparedEdit, RevisionIndex, Store,
+ document::{Document, Format, Kind},
+ page::{
+ Definition, Page, PageObject, PageParagraph, ParagraphContent, TextObject, text::new_id,
+ },
+};
+use std::collections::BTreeMap;
+
+const TREES: &[u8] =
+ include_bytes!("../../../corpus/outline-edit/tree/before/notebook/synthetic.one");
+const AUTHOR: &str = "List author";
+
+fn page_by_title(bytes: &[u8], title: &str) -> (ExGuid, Page) {
+ let store = Store::parse(bytes).unwrap();
+ let index = RevisionIndex::parse(&store).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 == title).then_some((space, page))
+ })
+ .unwrap()
+}
+
+fn page_in(bytes: &[u8], space: ExGuid) -> Page {
+ let store = Store::parse(bytes).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ Page::from_space(&Document::parse(&index).unwrap(), space).unwrap()
+}
+
+fn body_paragraphs(page: &mut Page) -> &mut Vec {
+ page.objects
+ .iter_mut()
+ .find_map(|object| match object {
+ PageObject::Outline(outline) if !outline.title => Some(&mut outline.paragraphs),
+ _ => None,
+ })
+ .unwrap()
+}
+
+fn list_ids(page: &Page) -> Vec> {
+ page.objects
+ .iter()
+ .filter_map(|object| match object {
+ PageObject::Outline(outline) if !outline.title => {
+ Some(outline.paragraphs.iter().map(|p| p.lists.clone()).collect())
+ }
+ _ => None,
+ })
+ .next()
+ .unwrap()
+}
+
+fn raw_object(bytes: &[u8], space: ExGuid, id: ExGuid) -> String {
+ let store = Store::parse(bytes).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ format!(
+ "{:?}",
+ index.resolve_active(space).unwrap().objects[&id].data
+ )
+}
+
+fn bullet_definition() -> Definition {
+ Definition {
+ kind: Kind::List {
+ font: Some("Courier New".into()),
+ format: Some("\u{25cb}".into()),
+ restart: None,
+ bullet: Some(4),
+ },
+ format: Format {
+ font_size: Some(11.0),
+ color: Some(0xff000000),
+ ..Default::default()
+ },
+ }
+}
+
+fn plain_paragraph(template: &PageParagraph, text: &str) -> PageParagraph {
+ let mut paragraph = template.clone();
+ paragraph.id = new_id().unwrap();
+ paragraph.parent = None;
+ paragraph.level = 1;
+ paragraph.lists.clear();
+ paragraph.tags.clear();
+ paragraph.style = None;
+ paragraph.collapsed = false;
+ paragraph.content = ParagraphContent::Text(TextObject {
+ id: new_id().unwrap(),
+ date_field: None,
+ text: onestore::page::Paragraph::new(
+ text.into(),
+ Format {
+ font: Some("Calibri".into()),
+ font_size: Some(11.0),
+ language: Some(1033),
+ ..Default::default()
+ },
+ ),
+ tags: Vec::new(),
+ });
+ paragraph
+}
+
+#[test]
+fn a_numbered_paragraph_becomes_plain_while_its_neighbours_keep_their_nodes() {
+ let (space, before) = page_by_title(TREES, "Move numbered subtree down");
+ let lists = list_ids(&before);
+ let numbered: Vec = (0..lists.len()).filter(|i| !lists[*i].is_empty()).collect();
+ assert!(numbered.len() >= 2, "{lists:?}");
+ let mut after = before.clone();
+ body_paragraphs(&mut after)[numbered[0]].lists.clear();
+ let written = PreparedEdit::page(TREES, space, &after, AUTHOR).unwrap();
+ let stored = page_in(written.as_bytes(), space);
+ let mut expected = after.clone();
+ expected.title = stored.title.clone();
+ expected.definitions.remove(&lists[numbered[0]][0]);
+ assert_eq!(stored, expected);
+ for other in &numbered[1..] {
+ for node in &lists[*other] {
+ assert_eq!(
+ raw_object(written.as_bytes(), space, *node),
+ raw_object(TREES, space, *node)
+ );
+ }
+ }
+ assert_eq!(
+ PreparedEdit::page(written.as_bytes(), space, &stored, AUTHOR)
+ .unwrap()
+ .as_bytes(),
+ written.as_bytes()
+ );
+}
+
+#[test]
+fn bullets_and_numbering_write_from_definitions_and_read_back() {
+ let (space, before) = page_by_title(TREES, "Move numbered subtree down");
+ let lists = list_ids(&before);
+ let numbered = (0..lists.len()).find(|i| !lists[*i].is_empty()).unwrap();
+ let plain = (0..lists.len()).find(|i| lists[*i].is_empty()).unwrap();
+ let mut after = before.clone();
+ let bullet = new_id().unwrap();
+ after.definitions.insert(bullet, bullet_definition());
+ let template = body_paragraphs(&mut after)[plain].clone();
+ body_paragraphs(&mut after)[plain].lists = vec![bullet];
+ let number = new_id().unwrap();
+ let mut numbering = after.definitions[&lists[numbered][0]].clone();
+ let Kind::List { restart, .. } = &mut numbering.kind else {
+ panic!()
+ };
+ *restart = Some(3);
+ after.definitions.insert(number, numbering);
+ let mut fresh = plain_paragraph(&template, "Numbered from three");
+ fresh.lists = vec![number];
+ body_paragraphs(&mut after).push(fresh);
+ let written = PreparedEdit::page(TREES, 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!(
+ PreparedEdit::page(written.as_bytes(), space, &stored, AUTHOR)
+ .unwrap()
+ .as_bytes(),
+ written.as_bytes()
+ );
+}
+
+#[test]
+fn a_list_definition_changes_in_place() {
+ let (space, before) = page_by_title(TREES, "Move numbered subtree down");
+ let lists = list_ids(&before);
+ let numbered = (0..lists.len()).find(|i| !lists[*i].is_empty()).unwrap();
+ let node = lists[numbered][0];
+ let mut after = before.clone();
+ let definition = after.definitions.get_mut(&node).unwrap();
+ let Kind::List { restart, .. } = &mut definition.kind else {
+ panic!()
+ };
+ *restart = Some(7);
+ definition.format.bold = Some(true);
+ let written = PreparedEdit::page(TREES, 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!(list_ids(&stored)[numbered], vec![node]);
+}
+
+#[test]
+fn a_definition_shared_between_paragraphs_becomes_a_node_per_paragraph() {
+ let (space, before) = page_by_title(TREES, "Move numbered subtree down");
+ let lists = list_ids(&before);
+ let numbered = (0..lists.len()).find(|i| !lists[*i].is_empty()).unwrap();
+ let plain = (0..lists.len()).find(|i| lists[*i].is_empty()).unwrap();
+ let mut shared = before.clone();
+ body_paragraphs(&mut shared)[plain].lists = lists[numbered].clone();
+ let written = PreparedEdit::page(TREES, space, &shared, AUTHOR).unwrap();
+ let stored = page_in(written.as_bytes(), space);
+ let after = list_ids(&stored);
+ assert_eq!(after[numbered], lists[numbered]);
+ assert_eq!(after[plain].len(), 1);
+ assert_ne!(after[plain], lists[numbered]);
+ assert_eq!(
+ stored.definitions[&after[plain][0]],
+ before.definitions[&lists[numbered][0]]
+ );
+}
+
+#[test]
+fn missing_and_foreign_list_definitions_are_refused() {
+ let (space, before) = page_by_title(TREES, "Move numbered subtree down");
+ let lists = list_ids(&before);
+ let plain = (0..lists.len()).find(|i| lists[*i].is_empty()).unwrap();
+ let mut missing = before.clone();
+ body_paragraphs(&mut missing)[plain].lists = vec![new_id().unwrap()];
+ assert!(PreparedEdit::page(TREES, space, &missing, AUTHOR).is_err());
+ let mut foreign = before.clone();
+ let paragraph = body_paragraphs(&mut foreign)[plain].id;
+ foreign.definitions.insert(paragraph, bullet_definition());
+ body_paragraphs(&mut foreign)[plain].lists = vec![paragraph];
+ assert!(PreparedEdit::page(TREES, space, &foreign, AUTHOR).is_err());
+ let mut style: BTreeMap = before.definitions.clone();
+ if let Some((id, definition)) = style
+ .iter_mut()
+ .find(|(_, d)| matches!(d.kind, Kind::Style { .. }))
+ {
+ definition.format.bold = Some(true);
+ let mut edited = before.clone();
+ edited.definitions.insert(*id, definition.clone());
+ assert!(PreparedEdit::page(TREES, space, &edited, AUTHOR).is_err());
+ }
+}
+
+/// A fresh page gains a bullet, two numbered paragraphs with a nested numbered child, a
+/// numbering restart and a trailing plain paragraph; `ONESTORE_LIST_EXPORT` names a new
+/// directory receiving the candidate for a cold native reopen.
+#[test]
+fn bullets_numbering_nesting_and_restarts_publish_on_a_fresh_page() {
+ let source = onestore::create_section("lists.one", "Bullet item", "Author").unwrap();
+ let store = Store::parse(&source).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ let document = Document::parse(&index).unwrap();
+ let (space, _) = document.pages().unwrap()[0];
+ let before = Page::from_space(&document, space).unwrap();
+ let (_, trees) = page_by_title(TREES, "Move numbered subtree down");
+ let numbering = trees
+ .definitions
+ .values()
+ .find(|d| {
+ matches!(
+ d.kind,
+ Kind::List {
+ format: Some(_),
+ ..
+ }
+ )
+ })
+ .unwrap()
+ .clone();
+ let scenario = std::env::var("ONESTORE_LIST_SCENARIO").unwrap_or_else(|_| "all".into());
+ let wants = |name: &str| scenario == "all" || scenario == name;
+ let mut after = before.clone();
+ let template = body_paragraphs(&mut after)[0].clone();
+ if wants("bullet") {
+ let bullet = new_id().unwrap();
+ after.definitions.insert(bullet, bullet_definition());
+ body_paragraphs(&mut after)[0].lists = vec![bullet];
+ }
+ for (text, level, restart, name) in [
+ ("First numbered", 1, None, "numbered"),
+ ("Second numbered", 1, None, "numbered"),
+ ("Nested numbered", 2, None, "all"),
+ ("Restarted at three", 1, Some(3), "restart"),
+ ("Nested plain", 2, None, "nested"),
+ ] {
+ if !wants(name) {
+ continue;
+ }
+ let mut paragraph = plain_paragraph(&template, text);
+ if name != "nested" {
+ let id = new_id().unwrap();
+ let mut definition = numbering.clone();
+ let Kind::List { restart: value, .. } = &mut definition.kind else {
+ panic!()
+ };
+ *value = restart;
+ after.definitions.insert(id, definition);
+ paragraph.lists = vec![id];
+ }
+ paragraph.level = level;
+ if level == 2 {
+ paragraph.parent = Some(body_paragraphs(&mut after).last().unwrap().id);
+ }
+ body_paragraphs(&mut after).push(paragraph);
+ }
+ body_paragraphs(&mut after).push(plain_paragraph(&template, "Plain again"));
+ 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);
+ if let Some(directory) = std::env::var_os("ONESTORE_LIST_EXPORT") {
+ let directory = std::path::PathBuf::from(directory);
+ std::fs::create_dir(&directory).unwrap();
+ std::fs::write(directory.join("lists.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",
+ &[("lists.one", written_store.header.file_id)],
+ )
+ .unwrap(),
+ )
+ .unwrap();
+ }
+}
diff --git a/crates/onestore/tests/support/page_edits.rs b/crates/onestore/tests/support/page_edits.rs
index d7d7a88ed58ababcbdeb6302fb86f92cf17a8e9b..0418b9b3d32bb3fe8ceb65d0e7b0d40619838e53 100644
--- a/crates/onestore/tests/support/page_edits.rs
+++ b/crates/onestore/tests/support/page_edits.rs
@@ -91,11 +91,13 @@ fn projection(page: &Page) -> String {
match ¶graph.content {
ParagraphContent::Text(text) => {
out.push_str(&format!(" text {} {:?}", text.id, text.text.text()));
+ // Runs that differ only in attributes the writer leaves as stored,
+ // such as the language tag, project as one span.
+ let mut runs: Vec<(usize, String)> = Vec::new();
for span in text.text.spans() {
let f = &span.format;
- out.push_str(&format!(
- " [{} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?}]",
- span.end,
+ let attributes = format!(
+ "{:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?}",
f.bold.unwrap_or(false),
f.italic.unwrap_or(false),
f.underline.unwrap_or(false),
@@ -106,7 +108,16 @@ fn projection(page: &Page) -> String {
f.font_size,
f.color,
f.highlight
- ));
+ );
+ match runs.last_mut() {
+ Some((end, previous)) if *previous == attributes => {
+ *end = span.end
+ }
+ _ => runs.push((span.end, attributes)),
+ }
+ }
+ for (end, attributes) in runs {
+ out.push_str(&format!(" [{end} {attributes}]"));
}
}
ParagraphContent::Table(table) => {
@@ -188,7 +199,44 @@ fn mutate(page: &mut Page, bytes: &mut Bytes<'_>) {
.filter(|(_, o)| matches!(o, PageObject::Outline(_)))
.map(|(i, _)| i)
.collect();
- match kind % 9 {
+ match kind % 10 {
+ 8 => {
+ let Some(outline) = outlines.first().map(|i| match &mut page.objects[*i] {
+ PageObject::Outline(outline) => outline,
+ _ => unreachable!(),
+ }) else {
+ continue;
+ };
+ let at = usize::from(bytes.next().unwrap_or(0)) % outline.paragraphs.len().max(1);
+ let Some(paragraph) = outline.paragraphs.get_mut(at) else {
+ continue;
+ };
+ if paragraph.text().is_none() {
+ continue;
+ }
+ if paragraph.lists.is_empty() {
+ let id = new_id().unwrap();
+ page.definitions.insert(
+ id,
+ onestore::page::Definition {
+ kind: onestore::document::Kind::List {
+ font: Some("Courier New".into()),
+ format: Some("\u{25cb}".into()),
+ restart: None,
+ bullet: Some(4),
+ },
+ format: onestore::document::Format {
+ font_size: Some(11.0),
+ color: Some(0xff000000),
+ ..Default::default()
+ },
+ },
+ );
+ paragraph.lists = vec![id];
+ } else {
+ paragraph.lists.clear();
+ }
+ }
0 | 1 => {
let Some(o) = bytes.pick(outlines.len()) else {
continue;
diff --git a/tools/test_list_edit.py b/tools/test_list_edit.py
new file mode 100644
index 0000000000000000000000000000000000000000..660eb076c7ca02c0ba8adbd57bdf780d7f5cc0ac
--- /dev/null
+++ b/tools/test_list_edit.py
@@ -0,0 +1,52 @@
+from pathlib import Path
+import re
+import runpy
+import shutil
+from tempfile import TemporaryDirectory
+import unittest
+import xml.etree.ElementTree as ET
+
+from native_xml import ns
+
+ROOT = Path(__file__).resolve().parent.parent
+FIXTURE = ROOT / 'corpus/list-edit'
+compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
+
+
+def items(read):
+ page, = (ET.parse(path).getroot() for path in sorted(read.glob('page-*.xml')))
+ outline, = page.findall('one:Outline', ns)
+
+ def walk(element, depth):
+ marker = element.find('one:List', ns)
+ kind = None
+ if marker is not None:
+ child, = list(marker)
+ kind = (child.tag.split('}')[1], child.get('text'))
+ text = re.sub(r'<[^>]*>', '', element.find('one:T', ns).text or '')
+ yield depth, kind, text
+ for nested in element.findall('one:OEChildren/one:OE', ns):
+ yield from walk(nested, depth + 1)
+
+ return [item for element in outline.findall('one:OEChildren/one:OE', ns) for item in walk(element, 0)]
+
+
+class ListEditTest(unittest.TestCase):
+ def test_rust_bullets_numbering_and_restarts_render_natively(self):
+ with TemporaryDirectory() as temporary:
+ read = Path(temporary) / 'read'
+ shutil.copytree(FIXTURE / 'cold/read', read)
+ compare(FIXTURE / 'candidate', read)
+ self.assertEqual(items(FIXTURE / 'cold/read'), [
+ (0, ('Bullet', None), 'Bullet item'),
+ (0, ('Number', 'I.'), 'First numbered'),
+ (0, ('Number', 'II.'), 'Second numbered'),
+ (1, ('Number', 'I.'), 'Nested numbered'),
+ (0, ('Number', 'III.'), 'Restarted at three'),
+ (1, None, 'Nested plain'),
+ (0, None, 'Plain again'),
+ ])
+
+
+if __name__ == '__main__':
+ unittest.main()