From 021a7246820757dd105aa45223eec7b9add63f79 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 2 Oct 2026 12:01:08 -0700 Subject: [PATCH] fix: lists pasted from Word keep their bullets, numbers and levels Word writes list items as paragraphs whose mso-list style names the list and level, with the marker in a mso-list:Ignore span, so they pasted as plain paragraphs. They now paste as OneNote 2010 pasted the same copy (lab, 2026-10-02): each level nested under the item above, a bullet in Word's glyph and font without a gallery index, a number in Word's sequence and punctuation. A list without a number now copies out as a ul. LibreOffice's nested ul and ol already pasted as lists; a test keeps them so. Corpus row word-lists holds OneNote's cold read. Fixes #61. Assisted-by: claude-opus-5.5 --- corpus/clipboard/README.md | 14 + corpus/clipboard/word-2010.html | 691 ++++++++++++++++++ corpus/word-lists/README.md | 12 + .../candidate/Open Notebook.onetoc2 | Bin 0 -> 1596 bytes corpus/word-lists/candidate/word.one | Bin 0 -> 9632 bytes corpus/word-lists/cold/commands.jsonl | 3 + corpus/word-lists/cold/machine.json | 1 + .../cold/notebook/Open Notebook.onetoc2 | Bin 0 -> 3748 bytes corpus/word-lists/cold/notebook/word.one | Bin 0 -> 11112 bytes corpus/word-lists/cold/read/environment.json | 7 + corpus/word-lists/cold/read/hierarchy.xml | 2 + .../cold/read/page-000.navigation.json | 13 + corpus/word-lists/cold/read/page-000.png | Bin 0 -> 39136 bytes corpus/word-lists/cold/read/page-000.xml | 2 + corpus/word-lists/cold/read/payloads.json | 3 + corpus/word-lists/cold/run.json | 18 + corpus/word-lists/cold/scripts/cold.ps1 | 27 + corpus/word-lists/cold/scripts/read.ps1 | 144 ++++ corpus/word-lists/cold/source.json | 14 + corpus/word-lists/cold/teardown.json | 1 + crates/canvas/src/editor/clip.rs | 14 +- crates/canvas/src/editor/html.rs | 210 +++++- crates/canvas/tests/clip_paste.rs | 74 +- tools/test_word_lists.py | 55 ++ 24 files changed, 1285 insertions(+), 20 deletions(-) create mode 100644 corpus/clipboard/word-2010.html create mode 100644 corpus/word-lists/README.md create mode 100644 corpus/word-lists/candidate/Open Notebook.onetoc2 create mode 100644 corpus/word-lists/candidate/word.one create mode 100644 corpus/word-lists/cold/commands.jsonl create mode 100644 corpus/word-lists/cold/machine.json create mode 100644 corpus/word-lists/cold/notebook/Open Notebook.onetoc2 create mode 100644 corpus/word-lists/cold/notebook/word.one create mode 100644 corpus/word-lists/cold/read/environment.json create mode 100644 corpus/word-lists/cold/read/hierarchy.xml create mode 100644 corpus/word-lists/cold/read/page-000.navigation.json create mode 100644 corpus/word-lists/cold/read/page-000.png create mode 100644 corpus/word-lists/cold/read/page-000.xml create mode 100644 corpus/word-lists/cold/read/payloads.json create mode 100644 corpus/word-lists/cold/run.json create mode 100644 corpus/word-lists/cold/scripts/cold.ps1 create mode 100644 corpus/word-lists/cold/scripts/read.ps1 create mode 100644 corpus/word-lists/cold/source.json create mode 100644 corpus/word-lists/cold/teardown.json create mode 100644 tools/test_word_lists.py 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 GIT binary patch literal 1596 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 literal 0 HcmV?d00001 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 GIT binary patch literal 3748 zcmZ3e|KIuM`!b(-j|5(YE0x8^2w&6wd%V!Kv3P%x#>|Fj1~g!Qcb0(k#IK%9FNjZk zdt%)UX`nnZhAICK1RypOOaMy5#LTssjO$0)_?w*6Q2t zZQXud@3z{ky{CN(rds+1LqqGrsuGJW2V8e8v$(98tCa(jgXqR2A@YhM3=Iq_2|xk} z(2WO)gOsI7muyLS=;yj&*7qyr@D0hN^i z$%4f}G9Vmj0Q54HEdw-*VF|RX0vYJN%ed*V=%H?y|3M)K0z5#+pgRy`n1BPrgaoKn zVAFwOpl}5NkUn(tgh7Hpz%c=+4_Yn;0QuO=X2D@6#B@~iLGc9g6v(}xvKwR%hz7ZF z*|z#AOF)t!0CX!*92@|kKw|>OJ1FgdRBxGg53Gox0ejkdE&6@kxg0LXiEUNYYKP*Y zL8jwQTd*($xecUJ`b9%SiMIC9*VgO2=K89M+DdQu1ro=Wb~Xsd>{t+Vmk}h-z@RA7 z07@qf4IpmG#tlzBl&btzyxJ+4FVXr`)c`071RxK9*bprY3>pFr4Lv|TceLiuO1;0- zZK-@Q*Ri`dZ9($70S!Gdz-Z@0N;r-d4LvqMF%~YMNvtg?3_ZvJ4YGluMw8)}4A59G rXOtKXfzc3vh5&L_ft7ind@l;bpbV!u_d^C$=_oZC0;3@?0zv=)N6a?_ literal 0 HcmV?d00001 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 GIT binary patch literal 11112 zcmeHN3vgA{72Pj+<^zF72=aRgPlyl^6b6K}(%}U}0^~*HQ=lM|P*Ot;pAx7fPU%&^ zhM$ICCqRMl35lfyXrpEv`3M#RD5l^jicsy)aatx7$}g=TUF+U+lY3w8OQ3CL>dZTt zeecQMXYaGmKKtym&$;iym?_V%I8*8`-#2za^@G1UeX-q*@{>6&QdYf`|9tnuQzuJ6 z8GLR-Z0A+iMwC>yUsY38ex@hrRxq32MqxeNM5wokH_=nte)Q0-cYiwUh1`R4lh$jK zd*%M?K!+HEnvIvH>f=x^Y-!*N@S+?RVzM7H@aA(?;o#-nhAl{2GU)P8rgUj@Dnb#i zWQ^<4cf<8z+jjh;_1nK}-_dLz>|OET*6)}QtW5|XvcR|50xq@0!ljQ#JXs#;;Ppsi zEQYju-G{X|)=u^B>iF{`J3G$%Li^LfUd3b1xxir*EnHrVm$>EqCGLPn;*x+REGxU9 z8n7js*USD95bp8}a#s0Y@6hmUph+xVA6<2RqoBr6NPLqn~v< zJ;VR!w4z0xl4T!VolE&$V?|2@jcpEOtg5}3A2o7g#qcS=Pl((`Jljlr-iUTJN)02JX%)8~Il6*SZ{C^YCSp zX0)h(8u-11Qdp#Pss_=?Qj6zEfe|x}sAP zQFZ-?dnBd}#u|tI+Obc7$I`be4$YM>Kp=|`Z-Uvu=#&lXI~p|X&l)$vP6{IQerRL% zvmjo)d4On)-5i^X6rlzmXKxy2at8iqpHde$+Uoxqabr>MD+?2kJp06+nv%aJ48J*z zI{Y_rqf8|foEI8 z-&R&)vLgCG;AFNoiGQe&f(435&*m(<1Z$Fu*Ohs?==(Ltex0~y@rFlwB==zBV%4}0 z=$u|Z8`EBAua7z~=~_k2fa5$oOr2E zWAjKyWI3$aJt@$6NVhsqpubRtQ$Ml5M+3Mo(*NOZy2ZJE84Wl)#G5lgyYNgwy>utW zyXH~$Q+cX+2}nB@L{@z7^u_JzOKvSYH!^(ZtATb5%%5n- zpcyt3mB1t^Xok(?EihxE=0Kx3!;mgg1?+x6P60WakKWmHr4O9T%!3g(}cDyi1chPu_AmXv9n z>;ezgwxHt@Kq-KexCgKgP;aMTXXyRdPiQAdkw(tZ;u}AHywCG)-}-a3X%8NnSZI%f z;~waourhP_>njJHSd;m8+qo0!1RZxZ2aB=*kdKCS2f;?hjVlz5=aNn^C}At=)d1t( zfaiCPndf@*OwYTF?f{OLpkM5PLyrJ_Xq_Ra;rX6zso)Sq${hS<;g-XTxH$_ogPHp* z_1Oj?ras$I{{YZD`gj`XLv47klZCybUMu~0pzW~ebrSVR{SL#)MvnG@JY8ZV_Gzfk zbZC-=z{Y(gP@kO^eco>teHzZ!JnU<;Et>EisLY~=aS!BV6Pv#@;U`}7Fd9CRsqf!j zj6W0O&%}<-=Nx?(K&QA7qQCsc(*G{h-vcy{ztA4(@L%RI#!m0#zrVHUvm5mj&883i z#ja0d{#$O*>p1GogiQI*4 zeO4(>ABYTlHqI!OOeBNIz1zm=Q$&hopt5lmC?dzn9pA=zMiG4=GQ`+8JE#qtoQyd( z&TK_wp9}^z&b<~Qql1mJ)5CvrsH^6B}oNBKkn&!m@EzDIzVzjK;<(@xY?^ z09*=noMtBJlrNe7Yu3s61f-NSAYa#54ZqjWG*o5mSHIXd_31@n{ogz{^s}CCZ6uy;0>57okHFgC+gB1~tpF|Q z{MIjeE{{(wdhCT{ z#$zg#P^D>4uou=1z1n>f-6L!rH0cesyL4 Zdp*rIM!k#BBV>V)1sYhux)jaz|8ERxJHP+{ literal 0 HcmV?d00001 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 GIT binary patch literal 39136 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

S2cYEL5*xRF7oVa&69j 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 R%q{=`002ovPDHLkV1j;2Wx@ae literal 0 HcmV?d00001 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\ +
  1. Number one

    \n\t
      \n\t\t
    1. Number nested

      \n\t\ +
    \n\t
  2. 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() -- 2.54.0