diff --git a/corpus/clipboard/README.md b/corpus/clipboard/README.md
index 8ff98c85062e95364b31be341c4cabe37605e033..37aaccdf89b541ab7e79e1539d80e6d8bb627b9d 100644
--- a/corpus/clipboard/README.md
+++ b/corpus/clipboard/README.md
@@ -9,3 +9,17 @@ built through COM and copied with Ctrl+A, Ctrl+C). Beside it OneNote offered
ANSI text, an enhanced metafile and a device-independent bitmap, but no RTF. The
tag is absent from the HTML. `crates/canvas/src/editor/html.rs` reads it in its
tests.
+
+`word-2010.html` is the `HTML Format` Word 2010 put on the clipboard for a
+document of a bulleted list three levels deep, a plain paragraph, a numbered
+list three levels deep (1., a., i.) and a last plain paragraph, typed with
+AutoFormat and Tab and copied with Ctrl+A, Ctrl+C (lab, 2026-10-02). Word
+writes each item as a paragraph whose `mso-list` style names its list and
+level, with its marker in a `mso-list:Ignore` span inside ``. Pasted into OneNote 2010, each level nested under the item
+above; bullets kept Word's glyph and font (Symbol `·`, Courier New `o`,
+Wingdings `§`) with no gallery index, and numbers took Word's sequence and
+punctuation. OneNote also nested the numbered list under the plain paragraph
+before it, from Word's indents; Snowbound nests by `mso-list` level alone, as
+it reads `ul` and `ol`. `crates/canvas/src/editor/html.rs` and
+`crates/canvas/tests/clip_paste.rs` read it.
diff --git a/corpus/clipboard/word-2010.html b/corpus/clipboard/word-2010.html
new file mode 100644
index 0000000000000000000000000000000000000000..5fbaec2577ea7e54bd7bf9c13db411938fb55165
--- /dev/null
+++ b/corpus/clipboard/word-2010.html
@@ -0,0 +1,691 @@
+Version:1.0
+StartHTML:0000000105
+EndHTML:0000031043
+StartFragment:0000027392
+EndFragment:0000031003
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
·
+Bullet one
+
+
o
+Bullet nested
+
+
§
+Bullet deeper
+
+
·
+Bullet two
+
+
Plain paragraph
+
+
1.
+Number one
+
+
a.
+Number nested
+
+
+i.
+Number deeper
+
+
2.
+Number two
+
+
Last plain
+
+
+
+
+
diff --git a/corpus/word-lists/README.md b/corpus/word-lists/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..aee74a045e921e1a075b107415f925f6ec70bcbb
--- /dev/null
+++ b/corpus/word-lists/README.md
@@ -0,0 +1,12 @@
+# Lists pasted from Word
+
+`candidate/` is `crates/canvas/tests/clip_paste.rs`'s `words_lists_paste_and_store`
+section: Word 2010's copy of three-level bulleted and numbered lists
+(`corpus/clipboard/word-2010.html`) pasted through the editor at a caret on blank page,
+stored as one edit. Bullets keep Word's glyph and font with no gallery index
+(`ListMSAAIndex`), as OneNote 2010 stored the same paste (lab, 2026-10-02).
+`cold/` is a fresh OneNote 2010 read: the bullets read back as the ones OneNote's own
+paste gave (`bullet` 1, 0 and 13), and the numbers as 1., a., i. and 2.
+`tools/test_word_lists.py` checks the capture without a VM. Regenerate with
+`CANVAS_WORD_LIST_EXPORT` set to a directory while running the test, then
+`tools/native_runner.py DIR COLD --expected-pages 1 --collect-notebook --screenshots`.
diff --git a/corpus/word-lists/candidate/Open Notebook.onetoc2 b/corpus/word-lists/candidate/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..8ed3775b60c5e3487c3f34466c71cf51a1bdbc1a
Binary files /dev/null and b/corpus/word-lists/candidate/Open Notebook.onetoc2 differ
diff --git a/corpus/word-lists/candidate/word.one b/corpus/word-lists/candidate/word.one
new file mode 100644
index 0000000000000000000000000000000000000000..396a98399847c80fd93c1cd0816ca90f97a93164
Binary files /dev/null and b/corpus/word-lists/candidate/word.one differ
diff --git a/corpus/word-lists/cold/commands.jsonl b/corpus/word-lists/cold/commands.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..c40af70a6b8a7980cd88ca43c9a1c5e8ecb66ac9
--- /dev/null
+++ b/corpus/word-lists/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-237B5BEB -ExpectedPages 1 -KeepOpen", "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/word-lists/cold/machine.json b/corpus/word-lists/cold/machine.json
new file mode 100644
index 0000000000000000000000000000000000000000..9eb1b0030aa2bf61c7cf8dd1fa251862fb259d54
--- /dev/null
+++ b/corpus/word-lists/cold/machine.json
@@ -0,0 +1 @@
+{"name": "m6-237b5beb", "hostname": "ONE-M6-237B5BEB"}
diff --git a/corpus/word-lists/cold/notebook/Open Notebook.onetoc2 b/corpus/word-lists/cold/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..2a29f9ed537f2461256b6afc54d8aa0ca9f81b38
Binary files /dev/null and b/corpus/word-lists/cold/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/word-lists/cold/notebook/word.one b/corpus/word-lists/cold/notebook/word.one
new file mode 100644
index 0000000000000000000000000000000000000000..0bacbee6752a0c237de36391e1ce97568cab0bb6
Binary files /dev/null and b/corpus/word-lists/cold/notebook/word.one differ
diff --git a/corpus/word-lists/cold/read/environment.json b/corpus/word-lists/cold/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..7748fd7404dbc093964277c576879dd2d3fd88db
--- /dev/null
+++ b/corpus/word-lists/cold/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-237B5BEB",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/word-lists/cold/read/hierarchy.xml b/corpus/word-lists/cold/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..ab5197de5ed5f906bace3b42b5e899ceb4feccf7
--- /dev/null
+++ b/corpus/word-lists/cold/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/word-lists/cold/read/page-000.navigation.json b/corpus/word-lists/cold/read/page-000.navigation.json
new file mode 100644
index 0000000000000000000000000000000000000000..0e1df2f44c8de84709e3a55afc2a0a097b4bc55f
--- /dev/null
+++ b/corpus/word-lists/cold/read/page-000.navigation.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 800,
+ "h": 600,
+ "error": null,
+ "win": {
+ "title": "Word lists - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/word-lists/cold/read/page-000.png b/corpus/word-lists/cold/read/page-000.png
new file mode 100644
index 0000000000000000000000000000000000000000..e880068e6a526ad23277d35f4e2dcbef1a5887f3
Binary files /dev/null and b/corpus/word-lists/cold/read/page-000.png differ
diff --git a/corpus/word-lists/cold/read/page-000.xml b/corpus/word-lists/cold/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..607165b587f4505e4aa1c558c6c25abbbd72a4ea
--- /dev/null
+++ b/corpus/word-lists/cold/read/page-000.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/word-lists/cold/read/payloads.json b/corpus/word-lists/cold/read/payloads.json
new file mode 100644
index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843
--- /dev/null
+++ b/corpus/word-lists/cold/read/payloads.json
@@ -0,0 +1,3 @@
+[
+
+]
\ No newline at end of file
diff --git a/corpus/word-lists/cold/run.json b/corpus/word-lists/cold/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..e7b6452ec0cb991e08280c1d51e959b3a5bc14c7
--- /dev/null
+++ b/corpus/word-lists/cold/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/private/tmp/pastefix/cand61",
+ "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": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
+ }
+}
diff --git a/corpus/word-lists/cold/scripts/cold.ps1 b/corpus/word-lists/cold/scripts/cold.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f
--- /dev/null
+++ b/corpus/word-lists/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/word-lists/cold/scripts/read.ps1 b/corpus/word-lists/cold/scripts/read.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a42366b32e8baaf13dae92e9472cae7a071220cc
--- /dev/null
+++ b/corpus/word-lists/cold/scripts/read.ps1
@@ -0,0 +1,144 @@
+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)
+ # A paragraph's file is named by its paragraph, one on the page by itself.
+ $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
+ $payloads += @{ page = $id; object = $owner.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/word-lists/cold/source.json b/corpus/word-lists/cold/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..6bc7f85c7bde1d98f26faf621673f308be7694a1
--- /dev/null
+++ b/corpus/word-lists/cold/source.json
@@ -0,0 +1,14 @@
+[
+ {
+ "path": "Open Notebook.onetoc2",
+ "bytes": 1596,
+ "sha256": "2974f1efd4b392b00d5d1a3e2485ad34d049e1ea46c0aceaed754f2ff365bc4e",
+ "mtime_ns": 1790967579952579436
+ },
+ {
+ "path": "word.one",
+ "bytes": 9632,
+ "sha256": "144bab7f0967f85881a858efd183c54aa8f334f07043d6959a68569b1fcba664",
+ "mtime_ns": 1790967579952375355
+ }
+]
diff --git a/corpus/word-lists/cold/teardown.json b/corpus/word-lists/cold/teardown.json
new file mode 100644
index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70
--- /dev/null
+++ b/corpus/word-lists/cold/teardown.json
@@ -0,0 +1 @@
+{"absent": true}
diff --git a/crates/canvas/src/editor/clip.rs b/crates/canvas/src/editor/clip.rs
index e750063d6744abb4e01c432f8fb4fae4bc59a2cb..5be34c5aa26cc34c565eaa13bcaf45e8db52d016 100644
--- a/crates/canvas/src/editor/clip.rs
+++ b/crates/canvas/src/editor/clip.rs
@@ -307,15 +307,13 @@ fn outline(
use super::format::AUTOMATIC;
for node in nodes {
let mut line = format!("{prefix}{}", " ".repeat(node.level as usize - 1));
- match node.lists.last().map(|list| &definitions[list].kind) {
- Some(Kind::List {
- bullet: Some(_), ..
- }) => line.push_str("• "),
- Some(kind @ Kind::List { .. }) => {
- let (_, kind) = super::html::list_tag(kind);
- line.push_str(&format!("{kind}. "));
+ if let Some(kind @ Kind::List { .. }) =
+ node.lists.last().map(|list| &definitions[list].kind)
+ {
+ match super::html::list_tag(kind) {
+ ("ul", _) => line.push_str("• "),
+ (_, kind) => line.push_str(&format!("{kind}. ")),
}
- _ => {}
}
let mut tags = node.tags.clone();
match &node.content {
diff --git a/crates/canvas/src/editor/html.rs b/crates/canvas/src/editor/html.rs
index 3e0a4f45fe9f00023bc73b47434ff32bf6350181..b6a1894633668e0de5682b8af751914acfcb576c 100644
--- a/crates/canvas/src/editor/html.rs
+++ b/crates/canvas/src/editor/html.rs
@@ -104,7 +104,10 @@ pub(super) fn list_tag(kind: &Kind) -> (&'static str, &'static str) {
let Kind::List { bullet, format, .. } = kind else {
return ("ul", "disc");
};
- if bullet.is_some() {
+ let sequence = format
+ .as_deref()
+ .and_then(|format| format.split('\u{fffd}').nth(1)?.chars().next());
+ if bullet.is_some() || sequence.is_none() {
let kind = match ListStyle::of(kind) {
Some(ListStyle::Bullet(3)) => "circle",
Some(ListStyle::Bullet(11 | 14)) => "square",
@@ -112,9 +115,6 @@ pub(super) fn list_tag(kind: &Kind) -> (&'static str, &'static str) {
};
return ("ul", kind);
}
- let sequence = format
- .as_deref()
- .and_then(|format| format.split('\u{fffd}').nth(1)?.chars().next());
let kind = match sequence.map(u32::from) {
Some(1) => "I",
Some(2) => "i",
@@ -257,6 +257,8 @@ pub fn html_pieces(
tables: Vec::new(),
lists: Vec::new(),
item: None,
+ word: None,
+ sequences: BTreeMap::new(),
open: Vec::new(),
runs: Vec::new(),
shown: false,
@@ -267,13 +269,22 @@ pub fn html_pieces(
reader.pieces
}
+/// A paragraph Word marks as a list item, its `mso-list` list and level, and the marker Word
+/// wrote before its text, in the marker's font.
+struct WordItem {
+ list: String,
+ level: usize,
+ marker: String,
+ font: Option,
+}
+
/// A paragraph or table read, before it becomes a clip's.
enum Block {
Text {
runs: Vec<(String, Format)>,
/// How many lists it sits in, its own not counted.
depth: usize,
- list: Option,
+ list: Option>,
},
Table {
rows: Vec>>,
@@ -296,6 +307,8 @@ struct Open {
name: String,
format: Format,
link: Option,
+ /// Whether it holds a Word list item's marker (`mso-list:Ignore`).
+ marker: bool,
}
struct Reader {
@@ -307,6 +320,10 @@ struct Reader {
lists: Vec,
/// The list of the item whose text has yet to come.
item: Option,
+ /// The Word list item being read, as `mso-list` names it.
+ word: Option,
+ /// The number sequence each Word list level (`mso-list` list and level) began with.
+ sequences: BTreeMap<(String, usize), char>,
open: Vec,
runs: Vec<(String, Format)>,
/// Whether the paragraph read so far shows anything.
@@ -366,6 +383,10 @@ impl; 2]) -> Option> Reader {
let end = tag_end(after);
let tag = &after[..end];
rest = after.get(end + 1..).unwrap_or("");
+ // Declarations and Word's `` conditions.
+ if tag.starts_with('!') {
+ continue;
+ }
let (closing, tag) = match tag.strip_prefix('/') {
Some(tag) => (true, tag),
None => (false, tag),
@@ -394,6 +415,25 @@ impl; 2]) -> Option> Reader {
if block || matches!(name, "br" | "table" | "tr" | "td" | "th" | "ul" | "ol") {
self.paragraph();
}
+ let list = style(tag, "mso-list");
+ if block && let Some(list) = &list {
+ let mut words = list.split_whitespace();
+ if let (Some(id), Some(Ok(level))) = (
+ words.next(),
+ words
+ .next()
+ .and_then(|level| level.strip_prefix("level"))
+ .map(str::parse::),
+ ) && level > 0
+ {
+ self.word = Some(WordItem {
+ list: id.to_owned(),
+ level,
+ marker: String::new(),
+ font: None,
+ });
+ }
+ }
match name {
"br" => return,
"img" => {
@@ -479,6 +519,7 @@ impl; 2]) -> Option> Reader {
name: name.to_owned(),
format,
link,
+ marker: list.is_some_and(|list| list.eq_ignore_ascii_case("ignore")),
});
}
@@ -547,6 +588,16 @@ impl; 2]) -> Option> Reader {
fn text(&mut self, text: &str) {
let format = self.format();
+ if self.open.iter().any(|open| open.marker) {
+ if let Some(item) = &mut self.word {
+ let shown = text.trim_matches(|c: char| c.is_whitespace() || c == '\u{a0}');
+ if !shown.is_empty() && item.marker.is_empty() {
+ item.font = format.font.clone();
+ }
+ item.marker.push_str(shown);
+ }
+ return;
+ }
for c in text.chars() {
if c.is_whitespace() && c != '\u{a0}' {
if self.shown && self.space.is_none() {
@@ -580,15 +631,78 @@ impl; 2]) -> Option> Reader {
for open in &mut self.open {
open.link = None;
}
- let list = self.item.take();
- let depth = self.lists.len() - usize::from(list.is_some() && !self.lists.is_empty());
runs.retain(|(text, _)| !text.is_empty());
if runs.is_empty() {
runs.push((String::new(), self.format()));
}
+ let (list, depth) = match self.word.take() {
+ Some(item) => {
+ let depth = self.lists.len() + item.level - 1;
+ (Some(Box::new(self.word_list(item, &runs[0].1))), depth)
+ }
+ None => {
+ let list = self.item.take();
+ let depth =
+ self.lists.len() - usize::from(list.is_some() && !self.lists.is_empty());
+ (
+ list.map(|style| Box::new(list_definition(style, &runs[0].1))),
+ depth,
+ )
+ }
+ };
self.sink().push(Block::Text { runs, depth, list });
}
+ /// The list OneNote 2010 made of a Word list item whose text begins in `format` (lab,
+ /// 2026-10-02): a marker without a number is a bullet in Word's glyph and font; a number
+ /// keeps Word's punctuation, in the sequence its list level began with.
+ fn word_list(&mut self, item: WordItem, format: &Format) -> Definition {
+ let mut definition = list_definition(ListStyle::NUMBER, format);
+ let Kind::List {
+ font, format: list, ..
+ } = &mut definition.kind
+ else {
+ unreachable!("a number is a list")
+ };
+ let marker = item.marker;
+ let alphanumeric = |c: char| c.is_ascii_alphanumeric();
+ let token = marker
+ .find(alphanumeric)
+ .zip(marker.rfind(alphanumeric))
+ .map(|(start, end)| start..end + 1);
+ let digits = token
+ .as_ref()
+ .is_some_and(|token| marker[token.clone()].bytes().any(|b| b.is_ascii_digit()));
+ let Some(number) = token.filter(|token| digits || token.len() < marker.len()) else {
+ *font = item.font;
+ *list = Some(marker);
+ return definition;
+ };
+ let token = &marker[number.clone()];
+ let sequence = *self
+ .sequences
+ .entry((item.list, item.level))
+ .or_insert_with(|| {
+ let roman = token == "i"
+ || token == "I"
+ || token.len() > 1 && token.chars().all(|c| "ivxlcdmIVXLCDM".contains(c));
+ let upper = token.starts_with(|c: char| c.is_ascii_uppercase());
+ match (digits, roman, upper) {
+ (true, ..) => '\u{0}',
+ (_, true, true) => '\u{1}',
+ (_, true, false) => '\u{2}',
+ (_, false, true) => '\u{3}',
+ (_, false, false) => '\u{4}',
+ }
+ });
+ *list = Some(format!(
+ "{}\u{fffd}{sequence}{}",
+ &marker[..number.start],
+ &marker[number.end..]
+ ));
+ definition
+ }
+
/// Where a block read goes: the open cell, else the clip.
fn sink(&mut self) -> &mut Vec {
match self.tables.last_mut() {
@@ -643,11 +757,10 @@ fn nodes(
let level = (depth(&block) - least + 1) as u32;
let mut paragraph = match block {
Block::Text { runs, list, .. } => {
- let first = runs[0].1.clone();
let mut paragraph = node(Paragraph::from_runs(runs), Format::default())?;
- if let Some(style) = list {
+ if let Some(list) = list {
let id = new_id()?;
- definitions.insert(id, list_definition(style, &first));
+ definitions.insert(id, *list);
paragraph.lists = vec![id];
}
paragraph
@@ -1064,6 +1177,83 @@ mod tests {
);
}
+ /// Word 2010's list paragraphs (`corpus/clipboard/word-2010.html`) paste as the lists
+ /// OneNote 2010 made of them (lab, 2026-10-02): each level nested under the item above,
+ /// a bullet in Word's own glyph and font, a number in Word's sequence and punctuation.
+ #[test]
+ fn words_lists_paste_as_lists() {
+ let html = include_str!("../../../../corpus/clipboard/word-2010.html");
+ let [clip] = &clips(html)[..] else {
+ panic!("one clip")
+ };
+ assert_eq!(
+ clip.outline(),
+ [
+ "• Bullet one",
+ " • Bullet nested",
+ " • Bullet deeper",
+ "• Bullet two",
+ "Plain paragraph",
+ "1. Number one",
+ " a. Number nested",
+ " i. Number deeper",
+ "1. Number two",
+ "Last plain",
+ ]
+ );
+ let lists = crate::document::leaves(&clip.paragraphs, None)
+ .filter_map(
+ |(.., node)| match &clip.definitions[node.lists.first()?].kind {
+ Kind::List {
+ font,
+ format,
+ bullet,
+ ..
+ } => Some((font.clone(), format.clone().unwrap(), *bullet)),
+ _ => None,
+ },
+ )
+ .collect::>();
+ let bullet = |font: &str, glyph: &str| (Some(font.to_owned()), glyph.to_owned(), None);
+ let number = |format: &str| (None, format.to_owned(), None);
+ assert_eq!(
+ lists,
+ [
+ bullet("Symbol", "\u{b7}"),
+ bullet("Courier New", "o"),
+ bullet("Wingdings", "\u{a7}"),
+ bullet("Symbol", "\u{b7}"),
+ number("\u{fffd}\u{0}."),
+ number("\u{fffd}\u{4}."),
+ number("\u{fffd}\u{2}."),
+ number("\u{fffd}\u{0}."),
+ ]
+ );
+ }
+
+ /// LibreOffice writes lists as nested `ul` and `ol` around paragraphs, leaving items
+ /// unclosed.
+ #[test]
+ fn libreoffices_lists_paste_as_lists() {
+ let clip = &clips(
+ "
\n\t
Bullet one
\n\t
\n\t\t\
+
Bullet nested
\n\t
\n\t
Bullet two
\n
\n\n\t\
+
Number one
\n\t\n\t\t
Number nested
\n\t\
+
\n\t
Number two
\n
",
+ )[0];
+ assert_eq!(
+ clip.outline(),
+ [
+ "• Bullet one",
+ " • Bullet nested",
+ "• Bullet two",
+ "1. Number one",
+ " a. Number nested",
+ "1. Number two",
+ ]
+ );
+ }
+
/// Snowbound's HTML reads back as the clip it was written from.
#[test]
fn snowbound_html_reads_back_as_written() {
diff --git a/crates/canvas/tests/clip_paste.rs b/crates/canvas/tests/clip_paste.rs
index 632691524ff9f0959e3b651eb0d9dfe76d421501..82defc2b62d8136d571d42d0c084be9be014e358 100644
--- a/crates/canvas/tests/clip_paste.rs
+++ b/crates/canvas/tests/clip_paste.rs
@@ -16,6 +16,7 @@ use onestore::{
};
const ONENOTE: &str = include_str!("../../../corpus/clipboard/onenote-2010.html");
+const WORD: &str = include_str!("../../../corpus/clipboard/word-2010.html");
/// Each paragraph of a body outline: its level, list kind, text and bold runs.
fn outline(page: &Page, outline: &Outline) -> Vec {
@@ -23,8 +24,9 @@ fn outline(page: &Page, outline: &Outline) -> Vec {
for node in nodes {
let list = match node.lists.last().map(|id| &page.definitions[id].kind) {
Some(Kind::List {
- bullet: Some(_), ..
- }) => "• ",
+ format: Some(format),
+ ..
+ }) if !format.contains('\u{fffd}') => "• ",
Some(Kind::List { .. }) => "# ",
_ => "",
};
@@ -310,3 +312,71 @@ fn several_lines_pasted_from_the_title_go_into_the_body() {
.unwrap();
}
}
+
+/// Word 2010's lists (`corpus/clipboard/word-2010.html`) paste as lists that the section
+/// stores and reads back. `CANVAS_WORD_LIST_EXPORT` names a directory receiving the section
+/// as a notebook, for a cold reopen in OneNote 2010.
+#[test]
+fn words_lists_paste_and_store() {
+ let source = onestore::create_section("word.one", "Word lists", "Author").unwrap();
+ let arena = Arena::default();
+ let mut section = Section::open(&arena, source.clone()).unwrap();
+ let (space, ..) = section.pages().unwrap()[0].clone();
+ let mut engine = TextEngine::default();
+ let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap();
+ editor
+ .place_caret(&mut engine, [72.0, 120.0], 400.0)
+ .unwrap();
+ editor
+ .paste_pieces(&mut engine, html_pieces(WORD, 0x409, |_, _| None))
+ .unwrap();
+ let ops = editor.take_ops().unwrap();
+ let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect();
+ section
+ .apply(
+ "Author",
+ &Edit {
+ at: 133_000_000_000_000_000,
+ ops,
+ },
+ )
+ .unwrap();
+
+ let mut image = source;
+ section.seal().unwrap().unwrap().apply(&mut image).unwrap();
+ let arena = Arena::default();
+ let reopened = Section::open(&arena, image.clone()).unwrap();
+ let page = reopened.page(space).unwrap();
+ assert_eq!(
+ bodies(&page),
+ [
+ vec!["Word lists"],
+ vec![
+ "• Bullet one",
+ " • Bullet nested",
+ " • Bullet deeper",
+ "• Bullet two",
+ "Plain paragraph",
+ "# Number one",
+ " # Number nested",
+ " # Number deeper",
+ "# Number two",
+ "Last plain",
+ ]
+ ]
+ );
+ assert_eq!(bodies(&page), bodies(&editor.page().unwrap()));
+
+ if let Some(directory) = std::env::var_os("CANVAS_WORD_LIST_EXPORT") {
+ let directory = std::path::PathBuf::from(directory);
+ std::fs::create_dir_all(&directory).unwrap();
+ std::fs::write(directory.join("word.one"), &image).unwrap();
+ let file_id = Store::parse(&image).unwrap().header.file_id;
+ std::fs::write(
+ directory.join("Open Notebook.onetoc2"),
+ onestore::create_table_of_contents("Open Notebook.onetoc2", &[("word.one", file_id)])
+ .unwrap(),
+ )
+ .unwrap();
+ }
+}
diff --git a/tools/test_word_lists.py b/tools/test_word_lists.py
new file mode 100644
index 0000000000000000000000000000000000000000..da0b4fe8b2ccb919cf7c074779b4b7af94502e5f
--- /dev/null
+++ b/tools/test_word_lists.py
@@ -0,0 +1,55 @@
+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/word-lists'
+compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
+
+
+class WordListsTest(unittest.TestCase):
+ def test_lists_pasted_from_word_open_in_onenote_as_its_own_paste_gave_them(self):
+ read = FIXTURE / 'cold/read'
+ with TemporaryDirectory() as temporary:
+ copy = Path(temporary) / 'read'
+ shutil.copytree(read, copy)
+ compare(FIXTURE / 'candidate', copy)
+ page = ET.parse(read / 'page-000.xml').getroot()
+ lines = []
+
+ def walk(children, depth):
+ for element in children.findall('one:OE', ns):
+ bullet = element.find('one:List/one:Bullet', ns)
+ number = element.find('one:List/one:Number', ns)
+ marker = ('bullet %s ' % bullet.get('bullet') if bullet is not None
+ else number.get('text') + ' ' if number is not None else '')
+ text = re.sub(r'<[^>]*>', '', element.find('one:T', ns).text or '')
+ lines.append(' ' * depth + marker + text)
+ for nested in element.findall('one:OEChildren', ns):
+ walk(nested, depth + 1)
+
+ outline = page.findall('one:Outline', ns)[-1]
+ for children in outline.findall('one:OEChildren', ns):
+ walk(children, 0)
+ self.assertEqual(lines, [
+ 'bullet 1 Bullet one',
+ ' bullet 0 Bullet nested',
+ ' bullet 13 Bullet deeper',
+ 'bullet 1 Bullet two',
+ 'Plain paragraph',
+ '1. Number one',
+ ' a. Number nested',
+ ' i. Number deeper',
+ '2. Number two',
+ 'Last plain',
+ ])
+
+
+if __name__ == '__main__':
+ unittest.main()