authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 12:01:08-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 12:59:38-07:00
log021a7246820757dd105aa45223eec7b9add63f79
tree4cd305e86edfb33ec9c929115ff04635ff8c1004
parent24d94846b5b33c2a832f3814bf0d80cf7a66cdde
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

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

24 files changed, 1285 insertions(+), 20 deletions(-)

corpus/clipboard/README.md+14
...@@ -9,3 +9,17 @@ built through COM and copied with Ctrl+A, Ctrl+C). Beside it OneNote offered...@@ -9,3 +9,17 @@ built through COM and copied with Ctrl+A, Ctrl+C). Beside it OneNote offered
9ANSI text, an enhanced metafile and a device-independent bitmap, but no RTF. The9ANSI text, an enhanced metafile and a device-independent bitmap, but no RTF. The
10tag is absent from the HTML. `crates/canvas/src/editor/html.rs` reads it in its10tag is absent from the HTML. `crates/canvas/src/editor/html.rs` reads it in its
11tests.11tests.
12
13`word-2010.html` is the `HTML Format` Word 2010 put on the clipboard for a
14document of a bulleted list three levels deep, a plain paragraph, a numbered
15list three levels deep (1., a., i.) and a last plain paragraph, typed with
16AutoFormat and Tab and copied with Ctrl+A, Ctrl+C (lab, 2026-10-02). Word
17writes each item as a paragraph whose `mso-list` style names its list and
18level, with its marker in a `mso-list:Ignore` span inside `<![if
19!supportLists]>`. Pasted into OneNote 2010, each level nested under the item
20above; bullets kept Word's glyph and font (Symbol `·`, Courier New `o`,
21Wingdings `§`) with no gallery index, and numbers took Word's sequence and
22punctuation. OneNote also nested the numbered list under the plain paragraph
23before it, from Word's indents; Snowbound nests by `mso-list` level alone, as
24it reads `ul` and `ol`. `crates/canvas/src/editor/html.rs` and
25`crates/canvas/tests/clip_paste.rs` read it.
corpus/clipboard/word-2010.html created+691
...@@ -0,0 +1,691 @@
1Version:1.0
2StartHTML:0000000105
3EndHTML:0000031043
4StartFragment:0000027392
5EndFragment:0000031003
6
7<html xmlns:o="urn:schemas-microsoft-com:office:office"
8xmlns:w="urn:schemas-microsoft-com:office:word"
9xmlns:m="http://schemas.microsoft.com/office/2004/12/omml"
10xmlns="http://www.w3.org/TR/REC-html40">
11
12<head>
13<meta http-equiv=Content-Type content="text/html; charset=utf-8">
14<meta name=ProgId content=Word.Document>
15<meta name=Generator content="Microsoft Word 14">
16<meta name=Originator content="Microsoft Word 14">
17<link rel=File-List
18href="file:///C:\Users\clover\AppData\Local\Temp\msohtmlclip1\01\clip_filelist.xml">
19<!--[if gte mso 9]><xml>
20 <o:OfficeDocumentSettings>
21 <o:AllowPNG/>
22 </o:OfficeDocumentSettings>
23</xml><![endif]-->
24<link rel=themeData
25href="file:///C:\Users\clover\AppData\Local\Temp\msohtmlclip1\01\clip_themedata.thmx">
26<link rel=colorSchemeMapping
27href="file:///C:\Users\clover\AppData\Local\Temp\msohtmlclip1\01\clip_colorschememapping.xml">
28<!--[if gte mso 9]><xml>
29 <w:WordDocument>
30 <w:View>Normal</w:View>
31 <w:Zoom>0</w:Zoom>
32 <w:TrackMoves/>
33 <w:TrackFormatting/>
34 <w:PunctuationKerning/>
35 <w:ValidateAgainstSchemas/>
36 <w:SaveIfXMLInvalid>false</w:SaveIfXMLInvalid>
37 <w:IgnoreMixedContent>false</w:IgnoreMixedContent>
38 <w:AlwaysShowPlaceholderText>false</w:AlwaysShowPlaceholderText>
39 <w:DoNotPromoteQF/>
40 <w:LidThemeOther>EN-US</w:LidThemeOther>
41 <w:LidThemeAsian>X-NONE</w:LidThemeAsian>
42 <w:LidThemeComplexScript>X-NONE</w:LidThemeComplexScript>
43 <w:Compatibility>
44 <w:BreakWrappedTables/>
45 <w:SnapToGridInCell/>
46 <w:WrapTextWithPunct/>
47 <w:UseAsianBreakRules/>
48 <w:DontGrowAutofit/>
49 <w:SplitPgBreakAndParaMark/>
50 <w:EnableOpenTypeKerning/>
51 <w:DontFlipMirrorIndents/>
52 <w:OverrideTableStyleHps/>
53 </w:Compatibility>
54 <m:mathPr>
55 <m:mathFont m:val="Cambria Math"/>
56 <m:brkBin m:val="before"/>
57 <m:brkBinSub m:val="&#45;-"/>
58 <m:smallFrac m:val="off"/>
59 <m:dispDef/>
60 <m:lMargin m:val="0"/>
61 <m:rMargin m:val="0"/>
62 <m:defJc m:val="centerGroup"/>
63 <m:wrapIndent m:val="1440"/>
64 <m:intLim m:val="subSup"/>
65 <m:naryLim m:val="undOvr"/>
66 </m:mathPr></w:WordDocument>
67</xml><![endif]--><!--[if gte mso 9]><xml>
68 <w:LatentStyles DefLockedState="false" DefUnhideWhenUsed="true"
69 DefSemiHidden="true" DefQFormat="false" DefPriority="99"
70 LatentStyleCount="267">
71 <w:LsdException Locked="false" Priority="0" SemiHidden="false"
72 UnhideWhenUsed="false" QFormat="true" Name="Normal"/>
73 <w:LsdException Locked="false" Priority="9" SemiHidden="false"
74 UnhideWhenUsed="false" QFormat="true" Name="heading 1"/>
75 <w:LsdException Locked="false" Priority="9" QFormat="true" Name="heading 2"/>
76 <w:LsdException Locked="false" Priority="9" QFormat="true" Name="heading 3"/>
77 <w:LsdException Locked="false" Priority="9" QFormat="true" Name="heading 4"/>
78 <w:LsdException Locked="false" Priority="9" QFormat="true" Name="heading 5"/>
79 <w:LsdException Locked="false" Priority="9" QFormat="true" Name="heading 6"/>
80 <w:LsdException Locked="false" Priority="9" QFormat="true" Name="heading 7"/>
81 <w:LsdException Locked="false" Priority="9" QFormat="true" Name="heading 8"/>
82 <w:LsdException Locked="false" Priority="9" QFormat="true" Name="heading 9"/>
83 <w:LsdException Locked="false" Priority="39" Name="toc 1"/>
84 <w:LsdException Locked="false" Priority="39" Name="toc 2"/>
85 <w:LsdException Locked="false" Priority="39" Name="toc 3"/>
86 <w:LsdException Locked="false" Priority="39" Name="toc 4"/>
87 <w:LsdException Locked="false" Priority="39" Name="toc 5"/>
88 <w:LsdException Locked="false" Priority="39" Name="toc 6"/>
89 <w:LsdException Locked="false" Priority="39" Name="toc 7"/>
90 <w:LsdException Locked="false" Priority="39" Name="toc 8"/>
91 <w:LsdException Locked="false" Priority="39" Name="toc 9"/>
92 <w:LsdException Locked="false" Priority="35" QFormat="true" Name="caption"/>
93 <w:LsdException Locked="false" Priority="10" SemiHidden="false"
94 UnhideWhenUsed="false" QFormat="true" Name="Title"/>
95 <w:LsdException Locked="false" Priority="1" Name="Default Paragraph Font"/>
96 <w:LsdException Locked="false" Priority="11" SemiHidden="false"
97 UnhideWhenUsed="false" QFormat="true" Name="Subtitle"/>
98 <w:LsdException Locked="false" Priority="22" SemiHidden="false"
99 UnhideWhenUsed="false" QFormat="true" Name="Strong"/>
100 <w:LsdException Locked="false" Priority="20" SemiHidden="false"
101 UnhideWhenUsed="false" QFormat="true" Name="Emphasis"/>
102 <w:LsdException Locked="false" Priority="59" SemiHidden="false"
103 UnhideWhenUsed="false" Name="Table Grid"/>
104 <w:LsdException Locked="false" UnhideWhenUsed="false" Name="Placeholder Text"/>
105 <w:LsdException Locked="false" Priority="1" SemiHidden="false"
106 UnhideWhenUsed="false" QFormat="true" Name="No Spacing"/>
107 <w:LsdException Locked="false" Priority="60" SemiHidden="false"
108 UnhideWhenUsed="false" Name="Light Shading"/>
109 <w:LsdException Locked="false" Priority="61" SemiHidden="false"
110 UnhideWhenUsed="false" Name="Light List"/>
111 <w:LsdException Locked="false" Priority="62" SemiHidden="false"
112 UnhideWhenUsed="false" Name="Light Grid"/>
113 <w:LsdException Locked="false" Priority="63" SemiHidden="false"
114 UnhideWhenUsed="false" Name="Medium Shading 1"/>
115 <w:LsdException Locked="false" Priority="64" SemiHidden="false"
116 UnhideWhenUsed="false" Name="Medium Shading 2"/>
117 <w:LsdException Locked="false" Priority="65" SemiHidden="false"
118 UnhideWhenUsed="false" Name="Medium List 1"/>
119 <w:LsdException Locked="false" Priority="66" SemiHidden="false"
120 UnhideWhenUsed="false" Name="Medium List 2"/>
121 <w:LsdException Locked="false" Priority="67" SemiHidden="false"
122 UnhideWhenUsed="false" Name="Medium Grid 1"/>
123 <w:LsdException Locked="false" Priority="68" SemiHidden="false"
124 UnhideWhenUsed="false" Name="Medium Grid 2"/>
125 <w:LsdException Locked="false" Priority="69" SemiHidden="false"
126 UnhideWhenUsed="false" Name="Medium Grid 3"/>
127 <w:LsdException Locked="false" Priority="70" SemiHidden="false"
128 UnhideWhenUsed="false" Name="Dark List"/>
129 <w:LsdException Locked="false" Priority="71" SemiHidden="false"
130 UnhideWhenUsed="false" Name="Colorful Shading"/>
131 <w:LsdException Locked="false" Priority="72" SemiHidden="false"
132 UnhideWhenUsed="false" Name="Colorful List"/>
133 <w:LsdException Locked="false" Priority="73" SemiHidden="false"
134 UnhideWhenUsed="false" Name="Colorful Grid"/>
135 <w:LsdException Locked="false" Priority="60" SemiHidden="false"
136 UnhideWhenUsed="false" Name="Light Shading Accent 1"/>
137 <w:LsdException Locked="false" Priority="61" SemiHidden="false"
138 UnhideWhenUsed="false" Name="Light List Accent 1"/>
139 <w:LsdException Locked="false" Priority="62" SemiHidden="false"
140 UnhideWhenUsed="false" Name="Light Grid Accent 1"/>
141 <w:LsdException Locked="false" Priority="63" SemiHidden="false"
142 UnhideWhenUsed="false" Name="Medium Shading 1 Accent 1"/>
143 <w:LsdException Locked="false" Priority="64" SemiHidden="false"
144 UnhideWhenUsed="false" Name="Medium Shading 2 Accent 1"/>
145 <w:LsdException Locked="false" Priority="65" SemiHidden="false"
146 UnhideWhenUsed="false" Name="Medium List 1 Accent 1"/>
147 <w:LsdException Locked="false" UnhideWhenUsed="false" Name="Revision"/>
148 <w:LsdException Locked="false" Priority="34" SemiHidden="false"
149 UnhideWhenUsed="false" QFormat="true" Name="List Paragraph"/>
150 <w:LsdException Locked="false" Priority="29" SemiHidden="false"
151 UnhideWhenUsed="false" QFormat="true" Name="Quote"/>
152 <w:LsdException Locked="false" Priority="30" SemiHidden="false"
153 UnhideWhenUsed="false" QFormat="true" Name="Intense Quote"/>
154 <w:LsdException Locked="false" Priority="66" SemiHidden="false"
155 UnhideWhenUsed="false" Name="Medium List 2 Accent 1"/>
156 <w:LsdException Locked="false" Priority="67" SemiHidden="false"
157 UnhideWhenUsed="false" Name="Medium Grid 1 Accent 1"/>
158 <w:LsdException Locked="false" Priority="68" SemiHidden="false"
159 UnhideWhenUsed="false" Name="Medium Grid 2 Accent 1"/>
160 <w:LsdException Locked="false" Priority="69" SemiHidden="false"
161 UnhideWhenUsed="false" Name="Medium Grid 3 Accent 1"/>
162 <w:LsdException Locked="false" Priority="70" SemiHidden="false"
163 UnhideWhenUsed="false" Name="Dark List Accent 1"/>
164 <w:LsdException Locked="false" Priority="71" SemiHidden="false"
165 UnhideWhenUsed="false" Name="Colorful Shading Accent 1"/>
166 <w:LsdException Locked="false" Priority="72" SemiHidden="false"
167 UnhideWhenUsed="false" Name="Colorful List Accent 1"/>
168 <w:LsdException Locked="false" Priority="73" SemiHidden="false"
169 UnhideWhenUsed="false" Name="Colorful Grid Accent 1"/>
170 <w:LsdException Locked="false" Priority="60" SemiHidden="false"
171 UnhideWhenUsed="false" Name="Light Shading Accent 2"/>
172 <w:LsdException Locked="false" Priority="61" SemiHidden="false"
173 UnhideWhenUsed="false" Name="Light List Accent 2"/>
174 <w:LsdException Locked="false" Priority="62" SemiHidden="false"
175 UnhideWhenUsed="false" Name="Light Grid Accent 2"/>
176 <w:LsdException Locked="false" Priority="63" SemiHidden="false"
177 UnhideWhenUsed="false" Name="Medium Shading 1 Accent 2"/>
178 <w:LsdException Locked="false" Priority="64" SemiHidden="false"
179 UnhideWhenUsed="false" Name="Medium Shading 2 Accent 2"/>
180 <w:LsdException Locked="false" Priority="65" SemiHidden="false"
181 UnhideWhenUsed="false" Name="Medium List 1 Accent 2"/>
182 <w:LsdException Locked="false" Priority="66" SemiHidden="false"
183 UnhideWhenUsed="false" Name="Medium List 2 Accent 2"/>
184 <w:LsdException Locked="false" Priority="67" SemiHidden="false"
185 UnhideWhenUsed="false" Name="Medium Grid 1 Accent 2"/>
186 <w:LsdException Locked="false" Priority="68" SemiHidden="false"
187 UnhideWhenUsed="false" Name="Medium Grid 2 Accent 2"/>
188 <w:LsdException Locked="false" Priority="69" SemiHidden="false"
189 UnhideWhenUsed="false" Name="Medium Grid 3 Accent 2"/>
190 <w:LsdException Locked="false" Priority="70" SemiHidden="false"
191 UnhideWhenUsed="false" Name="Dark List Accent 2"/>
192 <w:LsdException Locked="false" Priority="71" SemiHidden="false"
193 UnhideWhenUsed="false" Name="Colorful Shading Accent 2"/>
194 <w:LsdException Locked="false" Priority="72" SemiHidden="false"
195 UnhideWhenUsed="false" Name="Colorful List Accent 2"/>
196 <w:LsdException Locked="false" Priority="73" SemiHidden="false"
197 UnhideWhenUsed="false" Name="Colorful Grid Accent 2"/>
198 <w:LsdException Locked="false" Priority="60" SemiHidden="false"
199 UnhideWhenUsed="false" Name="Light Shading Accent 3"/>
200 <w:LsdException Locked="false" Priority="61" SemiHidden="false"
201 UnhideWhenUsed="false" Name="Light List Accent 3"/>
202 <w:LsdException Locked="false" Priority="62" SemiHidden="false"
203 UnhideWhenUsed="false" Name="Light Grid Accent 3"/>
204 <w:LsdException Locked="false" Priority="63" SemiHidden="false"
205 UnhideWhenUsed="false" Name="Medium Shading 1 Accent 3"/>
206 <w:LsdException Locked="false" Priority="64" SemiHidden="false"
207 UnhideWhenUsed="false" Name="Medium Shading 2 Accent 3"/>
208 <w:LsdException Locked="false" Priority="65" SemiHidden="false"
209 UnhideWhenUsed="false" Name="Medium List 1 Accent 3"/>
210 <w:LsdException Locked="false" Priority="66" SemiHidden="false"
211 UnhideWhenUsed="false" Name="Medium List 2 Accent 3"/>
212 <w:LsdException Locked="false" Priority="67" SemiHidden="false"
213 UnhideWhenUsed="false" Name="Medium Grid 1 Accent 3"/>
214 <w:LsdException Locked="false" Priority="68" SemiHidden="false"
215 UnhideWhenUsed="false" Name="Medium Grid 2 Accent 3"/>
216 <w:LsdException Locked="false" Priority="69" SemiHidden="false"
217 UnhideWhenUsed="false" Name="Medium Grid 3 Accent 3"/>
218 <w:LsdException Locked="false" Priority="70" SemiHidden="false"
219 UnhideWhenUsed="false" Name="Dark List Accent 3"/>
220 <w:LsdException Locked="false" Priority="71" SemiHidden="false"
221 UnhideWhenUsed="false" Name="Colorful Shading Accent 3"/>
222 <w:LsdException Locked="false" Priority="72" SemiHidden="false"
223 UnhideWhenUsed="false" Name="Colorful List Accent 3"/>
224 <w:LsdException Locked="false" Priority="73" SemiHidden="false"
225 UnhideWhenUsed="false" Name="Colorful Grid Accent 3"/>
226 <w:LsdException Locked="false" Priority="60" SemiHidden="false"
227 UnhideWhenUsed="false" Name="Light Shading Accent 4"/>
228 <w:LsdException Locked="false" Priority="61" SemiHidden="false"
229 UnhideWhenUsed="false" Name="Light List Accent 4"/>
230 <w:LsdException Locked="false" Priority="62" SemiHidden="false"
231 UnhideWhenUsed="false" Name="Light Grid Accent 4"/>
232 <w:LsdException Locked="false" Priority="63" SemiHidden="false"
233 UnhideWhenUsed="false" Name="Medium Shading 1 Accent 4"/>
234 <w:LsdException Locked="false" Priority="64" SemiHidden="false"
235 UnhideWhenUsed="false" Name="Medium Shading 2 Accent 4"/>
236 <w:LsdException Locked="false" Priority="65" SemiHidden="false"
237 UnhideWhenUsed="false" Name="Medium List 1 Accent 4"/>
238 <w:LsdException Locked="false" Priority="66" SemiHidden="false"
239 UnhideWhenUsed="false" Name="Medium List 2 Accent 4"/>
240 <w:LsdException Locked="false" Priority="67" SemiHidden="false"
241 UnhideWhenUsed="false" Name="Medium Grid 1 Accent 4"/>
242 <w:LsdException Locked="false" Priority="68" SemiHidden="false"
243 UnhideWhenUsed="false" Name="Medium Grid 2 Accent 4"/>
244 <w:LsdException Locked="false" Priority="69" SemiHidden="false"
245 UnhideWhenUsed="false" Name="Medium Grid 3 Accent 4"/>
246 <w:LsdException Locked="false" Priority="70" SemiHidden="false"
247 UnhideWhenUsed="false" Name="Dark List Accent 4"/>
248 <w:LsdException Locked="false" Priority="71" SemiHidden="false"
249 UnhideWhenUsed="false" Name="Colorful Shading Accent 4"/>
250 <w:LsdException Locked="false" Priority="72" SemiHidden="false"
251 UnhideWhenUsed="false" Name="Colorful List Accent 4"/>
252 <w:LsdException Locked="false" Priority="73" SemiHidden="false"
253 UnhideWhenUsed="false" Name="Colorful Grid Accent 4"/>
254 <w:LsdException Locked="false" Priority="60" SemiHidden="false"
255 UnhideWhenUsed="false" Name="Light Shading Accent 5"/>
256 <w:LsdException Locked="false" Priority="61" SemiHidden="false"
257 UnhideWhenUsed="false" Name="Light List Accent 5"/>
258 <w:LsdException Locked="false" Priority="62" SemiHidden="false"
259 UnhideWhenUsed="false" Name="Light Grid Accent 5"/>
260 <w:LsdException Locked="false" Priority="63" SemiHidden="false"
261 UnhideWhenUsed="false" Name="Medium Shading 1 Accent 5"/>
262 <w:LsdException Locked="false" Priority="64" SemiHidden="false"
263 UnhideWhenUsed="false" Name="Medium Shading 2 Accent 5"/>
264 <w:LsdException Locked="false" Priority="65" SemiHidden="false"
265 UnhideWhenUsed="false" Name="Medium List 1 Accent 5"/>
266 <w:LsdException Locked="false" Priority="66" SemiHidden="false"
267 UnhideWhenUsed="false" Name="Medium List 2 Accent 5"/>
268 <w:LsdException Locked="false" Priority="67" SemiHidden="false"
269 UnhideWhenUsed="false" Name="Medium Grid 1 Accent 5"/>
270 <w:LsdException Locked="false" Priority="68" SemiHidden="false"
271 UnhideWhenUsed="false" Name="Medium Grid 2 Accent 5"/>
272 <w:LsdException Locked="false" Priority="69" SemiHidden="false"
273 UnhideWhenUsed="false" Name="Medium Grid 3 Accent 5"/>
274 <w:LsdException Locked="false" Priority="70" SemiHidden="false"
275 UnhideWhenUsed="false" Name="Dark List Accent 5"/>
276 <w:LsdException Locked="false" Priority="71" SemiHidden="false"
277 UnhideWhenUsed="false" Name="Colorful Shading Accent 5"/>
278 <w:LsdException Locked="false" Priority="72" SemiHidden="false"
279 UnhideWhenUsed="false" Name="Colorful List Accent 5"/>
280 <w:LsdException Locked="false" Priority="73" SemiHidden="false"
281 UnhideWhenUsed="false" Name="Colorful Grid Accent 5"/>
282 <w:LsdException Locked="false" Priority="60" SemiHidden="false"
283 UnhideWhenUsed="false" Name="Light Shading Accent 6"/>
284 <w:LsdException Locked="false" Priority="61" SemiHidden="false"
285 UnhideWhenUsed="false" Name="Light List Accent 6"/>
286 <w:LsdException Locked="false" Priority="62" SemiHidden="false"
287 UnhideWhenUsed="false" Name="Light Grid Accent 6"/>
288 <w:LsdException Locked="false" Priority="63" SemiHidden="false"
289 UnhideWhenUsed="false" Name="Medium Shading 1 Accent 6"/>
290 <w:LsdException Locked="false" Priority="64" SemiHidden="false"
291 UnhideWhenUsed="false" Name="Medium Shading 2 Accent 6"/>
292 <w:LsdException Locked="false" Priority="65" SemiHidden="false"
293 UnhideWhenUsed="false" Name="Medium List 1 Accent 6"/>
294 <w:LsdException Locked="false" Priority="66" SemiHidden="false"
295 UnhideWhenUsed="false" Name="Medium List 2 Accent 6"/>
296 <w:LsdException Locked="false" Priority="67" SemiHidden="false"
297 UnhideWhenUsed="false" Name="Medium Grid 1 Accent 6"/>
298 <w:LsdException Locked="false" Priority="68" SemiHidden="false"
299 UnhideWhenUsed="false" Name="Medium Grid 2 Accent 6"/>
300 <w:LsdException Locked="false" Priority="69" SemiHidden="false"
301 UnhideWhenUsed="false" Name="Medium Grid 3 Accent 6"/>
302 <w:LsdException Locked="false" Priority="70" SemiHidden="false"
303 UnhideWhenUsed="false" Name="Dark List Accent 6"/>
304 <w:LsdException Locked="false" Priority="71" SemiHidden="false"
305 UnhideWhenUsed="false" Name="Colorful Shading Accent 6"/>
306 <w:LsdException Locked="false" Priority="72" SemiHidden="false"
307 UnhideWhenUsed="false" Name="Colorful List Accent 6"/>
308 <w:LsdException Locked="false" Priority="73" SemiHidden="false"
309 UnhideWhenUsed="false" Name="Colorful Grid Accent 6"/>
310 <w:LsdException Locked="false" Priority="19" SemiHidden="false"
311 UnhideWhenUsed="false" QFormat="true" Name="Subtle Emphasis"/>
312 <w:LsdException Locked="false" Priority="21" SemiHidden="false"
313 UnhideWhenUsed="false" QFormat="true" Name="Intense Emphasis"/>
314 <w:LsdException Locked="false" Priority="31" SemiHidden="false"
315 UnhideWhenUsed="false" QFormat="true" Name="Subtle Reference"/>
316 <w:LsdException Locked="false" Priority="32" SemiHidden="false"
317 UnhideWhenUsed="false" QFormat="true" Name="Intense Reference"/>
318 <w:LsdException Locked="false" Priority="33" SemiHidden="false"
319 UnhideWhenUsed="false" QFormat="true" Name="Book Title"/>
320 <w:LsdException Locked="false" Priority="37" Name="Bibliography"/>
321 <w:LsdException Locked="false" Priority="39" QFormat="true" Name="TOC Heading"/>
322 </w:LatentStyles>
323</xml><![endif]-->
324<style>
325<!--
326 /* Font Definitions */
327 @font-face
328 {font-family:Wingdings;
329 panose-1:5 0 0 0 0 0 0 0 0 0;
330 mso-font-charset:2;
331 mso-generic-font-family:auto;
332 mso-font-pitch:variable;
333 mso-font-signature:0 268435456 0 0 -2147483648 0;}
334@font-face
335 {font-family:Wingdings;
336 panose-1:5 0 0 0 0 0 0 0 0 0;
337 mso-font-charset:2;
338 mso-generic-font-family:auto;
339 mso-font-pitch:variable;
340 mso-font-signature:0 268435456 0 0 -2147483648 0;}
341@font-face
342 {font-family:Calibri;
343 panose-1:2 15 5 2 2 2 4 3 2 4;
344 mso-font-charset:0;
345 mso-generic-font-family:swiss;
346 mso-font-pitch:variable;
347 mso-font-signature:-520092929 1073786111 9 0 415 0;}
348 /* Style Definitions */
349 p.MsoNormal, li.MsoNormal, div.MsoNormal
350 {mso-style-unhide:no;
351 mso-style-qformat:yes;
352 mso-style-parent:"";
353 margin-top:0in;
354 margin-right:0in;
355 margin-bottom:10.0pt;
356 margin-left:0in;
357 line-height:115%;
358 mso-pagination:widow-orphan;
359 font-size:11.0pt;
360 font-family:"Calibri","sans-serif";
361 mso-ascii-font-family:Calibri;
362 mso-ascii-theme-font:minor-latin;
363 mso-fareast-font-family:Calibri;
364 mso-fareast-theme-font:minor-latin;
365 mso-hansi-font-family:Calibri;
366 mso-hansi-theme-font:minor-latin;
367 mso-bidi-font-family:"Times New Roman";
368 mso-bidi-theme-font:minor-bidi;}
369p.MsoListParagraph, li.MsoListParagraph, div.MsoListParagraph
370 {mso-style-priority:34;
371 mso-style-unhide:no;
372 mso-style-qformat:yes;
373 margin-top:0in;
374 margin-right:0in;
375 margin-bottom:10.0pt;
376 margin-left:.5in;
377 mso-add-space:auto;
378 line-height:115%;
379 mso-pagination:widow-orphan;
380 font-size:11.0pt;
381 font-family:"Calibri","sans-serif";
382 mso-ascii-font-family:Calibri;
383 mso-ascii-theme-font:minor-latin;
384 mso-fareast-font-family:Calibri;
385 mso-fareast-theme-font:minor-latin;
386 mso-hansi-font-family:Calibri;
387 mso-hansi-theme-font:minor-latin;
388 mso-bidi-font-family:"Times New Roman";
389 mso-bidi-theme-font:minor-bidi;}
390p.MsoListParagraphCxSpFirst, li.MsoListParagraphCxSpFirst, div.MsoListParagraphCxSpFirst
391 {mso-style-priority:34;
392 mso-style-unhide:no;
393 mso-style-qformat:yes;
394 mso-style-type:export-only;
395 margin-top:0in;
396 margin-right:0in;
397 margin-bottom:0in;
398 margin-left:.5in;
399 margin-bottom:.0001pt;
400 mso-add-space:auto;
401 line-height:115%;
402 mso-pagination:widow-orphan;
403 font-size:11.0pt;
404 font-family:"Calibri","sans-serif";
405 mso-ascii-font-family:Calibri;
406 mso-ascii-theme-font:minor-latin;
407 mso-fareast-font-family:Calibri;
408 mso-fareast-theme-font:minor-latin;
409 mso-hansi-font-family:Calibri;
410 mso-hansi-theme-font:minor-latin;
411 mso-bidi-font-family:"Times New Roman";
412 mso-bidi-theme-font:minor-bidi;}
413p.MsoListParagraphCxSpMiddle, li.MsoListParagraphCxSpMiddle, div.MsoListParagraphCxSpMiddle
414 {mso-style-priority:34;
415 mso-style-unhide:no;
416 mso-style-qformat:yes;
417 mso-style-type:export-only;
418 margin-top:0in;
419 margin-right:0in;
420 margin-bottom:0in;
421 margin-left:.5in;
422 margin-bottom:.0001pt;
423 mso-add-space:auto;
424 line-height:115%;
425 mso-pagination:widow-orphan;
426 font-size:11.0pt;
427 font-family:"Calibri","sans-serif";
428 mso-ascii-font-family:Calibri;
429 mso-ascii-theme-font:minor-latin;
430 mso-fareast-font-family:Calibri;
431 mso-fareast-theme-font:minor-latin;
432 mso-hansi-font-family:Calibri;
433 mso-hansi-theme-font:minor-latin;
434 mso-bidi-font-family:"Times New Roman";
435 mso-bidi-theme-font:minor-bidi;}
436p.MsoListParagraphCxSpLast, li.MsoListParagraphCxSpLast, div.MsoListParagraphCxSpLast
437 {mso-style-priority:34;
438 mso-style-unhide:no;
439 mso-style-qformat:yes;
440 mso-style-type:export-only;
441 margin-top:0in;
442 margin-right:0in;
443 margin-bottom:10.0pt;
444 margin-left:.5in;
445 mso-add-space:auto;
446 line-height:115%;
447 mso-pagination:widow-orphan;
448 font-size:11.0pt;
449 font-family:"Calibri","sans-serif";
450 mso-ascii-font-family:Calibri;
451 mso-ascii-theme-font:minor-latin;
452 mso-fareast-font-family:Calibri;
453 mso-fareast-theme-font:minor-latin;
454 mso-hansi-font-family:Calibri;
455 mso-hansi-theme-font:minor-latin;
456 mso-bidi-font-family:"Times New Roman";
457 mso-bidi-theme-font:minor-bidi;}
458.MsoChpDefault
459 {mso-style-type:export-only;
460 mso-default-props:yes;
461 font-family:"Calibri","sans-serif";
462 mso-ascii-font-family:Calibri;
463 mso-ascii-theme-font:minor-latin;
464 mso-fareast-font-family:Calibri;
465 mso-fareast-theme-font:minor-latin;
466 mso-hansi-font-family:Calibri;
467 mso-hansi-theme-font:minor-latin;
468 mso-bidi-font-family:"Times New Roman";
469 mso-bidi-theme-font:minor-bidi;}
470.MsoPapDefault
471 {mso-style-type:export-only;
472 margin-bottom:10.0pt;
473 line-height:115%;}
474@page WordSection1
475 {size:8.5in 11.0in;
476 margin:1.0in 1.0in 1.0in 1.0in;
477 mso-header-margin:.5in;
478 mso-footer-margin:.5in;
479 mso-paper-source:0;}
480div.WordSection1
481 {page:WordSection1;}
482 /* List Definitions */
483 @list l0
484 {mso-list-id:727800390;
485 mso-list-type:hybrid;
486 mso-list-template-ids:-60157512 67698703 67698713 67698715 67698703 67698713 67698715 67698703 67698713 67698715;}
487@list l0:level1
488 {mso-level-tab-stop:none;
489 mso-level-number-position:left;
490 text-indent:-.25in;}
491@list l0:level2
492 {mso-level-number-format:alpha-lower;
493 mso-level-tab-stop:none;
494 mso-level-number-position:left;
495 text-indent:-.25in;}
496@list l0:level3
497 {mso-level-number-format:roman-lower;
498 mso-level-tab-stop:none;
499 mso-level-number-position:right;
500 text-indent:-9.0pt;}
501@list l0:level4
502 {mso-level-tab-stop:none;
503 mso-level-number-position:left;
504 text-indent:-.25in;}
505@list l0:level5
506 {mso-level-number-format:alpha-lower;
507 mso-level-tab-stop:none;
508 mso-level-number-position:left;
509 text-indent:-.25in;}
510@list l0:level6
511 {mso-level-number-format:roman-lower;
512 mso-level-tab-stop:none;
513 mso-level-number-position:right;
514 text-indent:-9.0pt;}
515@list l0:level7
516 {mso-level-tab-stop:none;
517 mso-level-number-position:left;
518 text-indent:-.25in;}
519@list l0:level8
520 {mso-level-number-format:alpha-lower;
521 mso-level-tab-stop:none;
522 mso-level-number-position:left;
523 text-indent:-.25in;}
524@list l0:level9
525 {mso-level-number-format:roman-lower;
526 mso-level-tab-stop:none;
527 mso-level-number-position:right;
528 text-indent:-9.0pt;}
529@list l1
530 {mso-list-id:1613515428;
531 mso-list-type:hybrid;
532 mso-list-template-ids:-1154053724 -532929662 67698691 67698693 67698689 67698691 67698693 67698689 67698691 67698693;}
533@list l1:level1
534 {mso-level-start-at:0;
535 mso-level-number-format:bullet;
536 mso-level-text:ï‚·;
537 mso-level-tab-stop:none;
538 mso-level-number-position:left;
539 text-indent:-.25in;
540 font-family:Symbol;
541 mso-fareast-font-family:Calibri;
542 mso-fareast-theme-font:minor-latin;
543 mso-bidi-font-family:"Times New Roman";
544 mso-bidi-theme-font:minor-bidi;}
545@list l1:level2
546 {mso-level-number-format:bullet;
547 mso-level-text:o;
548 mso-level-tab-stop:none;
549 mso-level-number-position:left;
550 text-indent:-.25in;
551 font-family:"Courier New";}
552@list l1:level3
553 {mso-level-number-format:bullet;
554 mso-level-text:ï‚§;
555 mso-level-tab-stop:none;
556 mso-level-number-position:left;
557 text-indent:-.25in;
558 font-family:Wingdings;}
559@list l1:level4
560 {mso-level-number-format:bullet;
561 mso-level-text:ï‚·;
562 mso-level-tab-stop:none;
563 mso-level-number-position:left;
564 text-indent:-.25in;
565 font-family:Symbol;}
566@list l1:level5
567 {mso-level-number-format:bullet;
568 mso-level-text:o;
569 mso-level-tab-stop:none;
570 mso-level-number-position:left;
571 text-indent:-.25in;
572 font-family:"Courier New";}
573@list l1:level6
574 {mso-level-number-format:bullet;
575 mso-level-text:ï‚§;
576 mso-level-tab-stop:none;
577 mso-level-number-position:left;
578 text-indent:-.25in;
579 font-family:Wingdings;}
580@list l1:level7
581 {mso-level-number-format:bullet;
582 mso-level-text:ï‚·;
583 mso-level-tab-stop:none;
584 mso-level-number-position:left;
585 text-indent:-.25in;
586 font-family:Symbol;}
587@list l1:level8
588 {mso-level-number-format:bullet;
589 mso-level-text:o;
590 mso-level-tab-stop:none;
591 mso-level-number-position:left;
592 text-indent:-.25in;
593 font-family:"Courier New";}
594@list l1:level9
595 {mso-level-number-format:bullet;
596 mso-level-text:ï‚§;
597 mso-level-tab-stop:none;
598 mso-level-number-position:left;
599 text-indent:-.25in;
600 font-family:Wingdings;}
601ol
602 {margin-bottom:0in;}
603ul
604 {margin-bottom:0in;}
605-->
606</style>
607<!--[if gte mso 10]>
608<style>
609 /* Style Definitions */
610 table.MsoNormalTable
611 {mso-style-name:"Table Normal";
612 mso-tstyle-rowband-size:0;
613 mso-tstyle-colband-size:0;
614 mso-style-noshow:yes;
615 mso-style-priority:99;
616 mso-style-parent:"";
617 mso-padding-alt:0in 5.4pt 0in 5.4pt;
618 mso-para-margin-top:0in;
619 mso-para-margin-right:0in;
620 mso-para-margin-bottom:10.0pt;
621 mso-para-margin-left:0in;
622 line-height:115%;
623 mso-pagination:widow-orphan;
624 font-size:11.0pt;
625 font-family:"Calibri","sans-serif";
626 mso-ascii-font-family:Calibri;
627 mso-ascii-theme-font:minor-latin;
628 mso-hansi-font-family:Calibri;
629 mso-hansi-theme-font:minor-latin;
630 mso-bidi-font-family:"Times New Roman";
631 mso-bidi-theme-font:minor-bidi;}
632</style>
633<![endif]-->
634</head>
635
636<body lang=EN-US style='tab-interval:.5in'>
637<!--StartFragment-->
638
639<p class=MsoListParagraphCxSpFirst style='text-indent:-.25in;mso-list:l1 level1 lfo1'><![if !supportLists]><span
640style='font-family:Symbol;mso-fareast-font-family:Symbol;mso-bidi-font-family:
641Symbol'><span style='mso-list:Ignore'>·<span style='font:7.0pt "Times New Roman"'>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
642</span></span></span><![endif]>Bullet one<o:p></o:p></p>
643
644<p class=MsoListParagraphCxSpMiddle style='margin-left:1.0in;mso-add-space:
645auto;text-indent:-.25in;mso-list:l1 level2 lfo1'><![if !supportLists]><span
646style='font-family:"Courier New";mso-fareast-font-family:"Courier New"'><span
647style='mso-list:Ignore'>o<span style='font:7.0pt "Times New Roman"'>&nbsp;&nbsp;
648</span></span></span><![endif]>Bullet nested<o:p></o:p></p>
649
650<p class=MsoListParagraphCxSpMiddle style='margin-left:1.5in;mso-add-space:
651auto;text-indent:-.25in;mso-list:l1 level3 lfo1'><![if !supportLists]><span
652style='font-family:Wingdings;mso-fareast-font-family:Wingdings;mso-bidi-font-family:
653Wingdings'><span style='mso-list:Ignore'>§<span style='font:7.0pt "Times New Roman"'>&nbsp;
654</span></span></span><![endif]>Bullet deeper<o:p></o:p></p>
655
656<p class=MsoListParagraphCxSpLast style='text-indent:-.25in;mso-list:l1 level1 lfo1'><![if !supportLists]><span
657style='font-family:Symbol;mso-fareast-font-family:Symbol;mso-bidi-font-family:
658Symbol'><span style='mso-list:Ignore'>·<span style='font:7.0pt "Times New Roman"'>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
659</span></span></span><![endif]>Bullet two<o:p></o:p></p>
660
661<p class=MsoNormal>Plain paragraph<o:p></o:p></p>
662
663<p class=MsoListParagraphCxSpFirst style='text-indent:-.25in;mso-list:l0 level1 lfo2'><![if !supportLists]><span
664style='mso-bidi-font-family:Calibri;mso-bidi-theme-font:minor-latin'><span
665style='mso-list:Ignore'>1.<span style='font:7.0pt "Times New Roman"'>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
666</span></span></span><![endif]>Number one<o:p></o:p></p>
667
668<p class=MsoListParagraphCxSpMiddle style='margin-left:1.0in;mso-add-space:
669auto;text-indent:-.25in;mso-list:l0 level2 lfo2'><![if !supportLists]><span
670style='mso-bidi-font-family:Calibri;mso-bidi-theme-font:minor-latin'><span
671style='mso-list:Ignore'>a.<span style='font:7.0pt "Times New Roman"'>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
672</span></span></span><![endif]>Number nested<o:p></o:p></p>
673
674<p class=MsoListParagraphCxSpMiddle style='margin-left:1.5in;mso-add-space:
675auto;text-indent:-1.5in;mso-text-indent-alt:-9.0pt;mso-list:l0 level3 lfo2'><![if !supportLists]><span
676style='mso-bidi-font-family:Calibri;mso-bidi-theme-font:minor-latin'><span
677style='mso-list:Ignore'><span style='font:7.0pt "Times New Roman"'>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
678</span>i.<span style='font:7.0pt "Times New Roman"'>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
679</span></span></span><![endif]>Number deeper<o:p></o:p></p>
680
681<p class=MsoListParagraphCxSpLast style='text-indent:-.25in;mso-list:l0 level1 lfo2'><![if !supportLists]><span
682style='mso-bidi-font-family:Calibri;mso-bidi-theme-font:minor-latin'><span
683style='mso-list:Ignore'>2.<span style='font:7.0pt "Times New Roman"'>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
684</span></span></span><![endif]>Number two<o:p></o:p></p>
685
686<p class=MsoNormal>Last plain<o:p></o:p></p>
687
688<!--EndFragment-->
689</body>
690
691</html>
corpus/word-lists/README.md created+12
...@@ -0,0 +1,12 @@
1# Lists pasted from Word
2
3`candidate/` is `crates/canvas/tests/clip_paste.rs`'s `words_lists_paste_and_store`
4section: Word 2010's copy of three-level bulleted and numbered lists
5(`corpus/clipboard/word-2010.html`) pasted through the editor at a caret on blank page,
6stored as one edit. Bullets keep Word's glyph and font with no gallery index
7(`ListMSAAIndex`), as OneNote 2010 stored the same paste (lab, 2026-10-02).
8`cold/` is a fresh OneNote 2010 read: the bullets read back as the ones OneNote's own
9paste gave (`bullet` 1, 0 and 13), and the numbers as 1., a., i. and 2.
10`tools/test_word_lists.py` checks the capture without a VM. Regenerate with
11`CANVAS_WORD_LIST_EXPORT` set to a directory while running the test, then
12`tools/native_runner.py DIR COLD --expected-pages 1 --collect-notebook --screenshots`.
corpus/word-lists/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/word-lists/candidate/Open Notebook.onetoc2 differ
corpus/word-lists/candidate/word.one created
Binary files /dev/null and b/corpus/word-lists/candidate/word.one differ
corpus/word-lists/cold/commands.jsonl created+3
...@@ -0,0 +1,3 @@
1{"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}
2{"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}
3{"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}
corpus/word-lists/cold/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-237b5beb", "hostname": "ONE-M6-237B5BEB"}
corpus/word-lists/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/word-lists/cold/notebook/Open Notebook.onetoc2 differ
corpus/word-lists/cold/notebook/word.one created
Binary files /dev/null and b/corpus/word-lists/cold/notebook/word.one differ
corpus/word-lists/cold/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-237B5BEB",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/word-lists/cold/read/hierarchy.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{2E97477E-5494-424D-A410-E4D8E87E2C2F}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-10-02T19:03:33.000Z" color="#B49EDE"><one:Section name="word" ID="{E6BEBA52-50C8-0D11-01D6-CD4A9683CA34}{1}{B0}" path="C:\one-tests\runs\capture\notebook\word.one" lastModifiedTime="2026-10-02T19:03:33.000Z" color="#8AA8E4"><one:Page ID="{8DF8F58E-417D-0774-19CC-80F9239344E5}{1}{B0}" name="Word lists" dateTime="2026-10-02T18:59:39.000Z" lastModifiedTime="2026-10-02T19:03:33.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/word-lists/cold/read/page-000.navigation.json created+13
...@@ -0,0 +1,13 @@
1{
2 "exit": 0,
3 "stdout": "",
4 "stderr": "",
5 "w": 800,
6 "h": 600,
7 "error": null,
8 "win": {
9 "title": "Word lists - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/word-lists/cold/read/page-000.png created
Binary files /dev/null and b/corpus/word-lists/cold/read/page-000.png differ
corpus/word-lists/cold/read/page-000.xml created+2
...@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{8DF8F58E-417D-0774-19CC-80F9239344E5}{1}{B0}" name="Word lists" dateTime="2026-10-02T18:59:39.000Z" lastModifiedTime="2026-10-02T19:03:33.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" lastModifiedTime="2026-10-02T18:59:39.000Z" objectID="{E6BEBA52-50C8-0D11-01D6-CD4A9683CA34}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="84.77598571777344" height="13.4277172088623"/><one:OEChildren><one:OE creationTime="2026-10-02T18:59:39.000Z" lastModifiedTime="2026-10-02T18:59:39.000Z" objectID="{E6BEBA52-50C8-0D11-01D6-CD4A9683CA34}{24}{B0}" alignment="left"><one:T><![CDATA[Word lists]]></one:T></one:OE></one:OEChildren></one:Outline><one:Outline author="Author" authorInitials="A" lastModifiedBy="Author" lastModifiedByInitials="A" lastModifiedTime="2022-06-18T04:26:40.000Z" objectID="{EB92038B-8B15-0AED-0E44-D334EF652A6D}{122}{B0}"><one:Position x="72.0" y="120.0" z="1"/><one:Size width="164.8041687011719" height="135.7771759033203"/><one:OEChildren><one:OE creationTime="2022-06-18T04:26:40.000Z" lastModifiedTime="2022-06-18T04:26:40.000Z" objectID="{EB92038B-8B15-0AED-0E44-D334EF652A6D}{159}{B0}" alignment="left"><one:List><one:Bullet bullet="1" fontSize="11.0"/></one:List><one:T><![CDATA[Bullet one]]></one:T><one:OEChildren><one:OE creationTime="2022-06-18T04:26:40.000Z" lastModifiedTime="2022-06-18T04:26:40.000Z" objectID="{EB92038B-8B15-0AED-0E44-D334EF652A6D}{161}{B0}" alignment="left"><one:List><one:Bullet bullet="0" fontSize="11.0"/></one:List><one:T><![CDATA[Bullet nested]]></one:T><one:OEChildren><one:OE creationTime="2022-06-18T04:26:40.000Z" lastModifiedTime="2022-06-18T04:26:40.000Z" objectID="{EB92038B-8B15-0AED-0E44-D334EF652A6D}{163}{B0}" alignment="left"><one:List><one:Bullet bullet="13" fontSize="11.0"/></one:List><one:T><![CDATA[Bullet deeper]]></one:T></one:OE></one:OEChildren></one:OE></one:OEChildren></one:OE><one:OE creationTime="2022-06-18T04:26:40.000Z" lastModifiedTime="2022-06-18T04:26:40.000Z" objectID="{EB92038B-8B15-0AED-0E44-D334EF652A6D}{165}{B0}" alignment="left"><one:List><one:Bullet bullet="1" fontSize="11.0"/></one:List><one:T><![CDATA[Bullet two]]></one:T></one:OE><one:OE creationTime="2022-06-18T04:26:40.000Z" lastModifiedTime="2022-06-18T04:26:40.000Z" objectID="{EB92038B-8B15-0AED-0E44-D334EF652A6D}{167}{B0}" alignment="left"><one:T><![CDATA[Plain paragraph]]></one:T></one:OE><one:OE creationTime="2022-06-18T04:26:40.000Z" lastModifiedTime="2022-06-18T04:26:40.000Z" objectID="{EB92038B-8B15-0AED-0E44-D334EF652A6D}{169}{B0}" alignment="left"><one:List><one:Number numberSequence="0" numberFormat="##." fontSize="11.0" font="Calibri" text="1."/></one:List><one:T><![CDATA[Number one]]></one:T><one:OEChildren><one:OE creationTime="2022-06-18T04:26:40.000Z" lastModifiedTime="2022-06-18T04:26:40.000Z" objectID="{EB92038B-8B15-0AED-0E44-D334EF652A6D}{171}{B0}" alignment="left"><one:List><one:Number numberSequence="4" numberFormat="##." fontSize="11.0" font="Calibri" text="a."/></one:List><one:T><![CDATA[Number nested]]></one:T><one:OEChildren><one:OE creationTime="2022-06-18T04:26:40.000Z" lastModifiedTime="2022-06-18T04:26:40.000Z" objectID="{EB92038B-8B15-0AED-0E44-D334EF652A6D}{173}{B0}" alignment="left"><one:List><one:Number numberSequence="2" numberFormat="##." fontSize="11.0" font="Calibri" text="i."/></one:List><one:T><![CDATA[Number deeper]]></one:T></one:OE></one:OEChildren></one:OE></one:OEChildren></one:OE><one:OE creationTime="2022-06-18T04:26:40.000Z" lastModifiedTime="2022-06-18T04:26:40.000Z" objectID="{EB92038B-8B15-0AED-0E44-D334EF652A6D}{175}{B0}" alignment="left"><one:List><one:Number numberSequence="0" numberFormat="##." fontSize="11.0" font="Calibri" text="2."/></one:List><one:T><![CDATA[Number two]]></one:T></one:OE><one:OE creationTime="2022-06-18T04:26:40.000Z" lastModifiedTime="2022-06-18T04:26:40.000Z" objectID="{EB92038B-8B15-0AED-0E44-D334EF652A6D}{177}{B0}" alignment="left"><one:T><![CDATA[Last plain]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/word-lists/cold/read/payloads.json created+3
...@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/word-lists/cold/run.json created+18
...@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/pastefix/cand61",
3 "expected_pages": 1,
4 "author": null,
5 "author_timeout_seconds": 600,
6 "inspect": false,
7 "collect_notebook": true,
8 "base": {
9 "file": "win7-office-base.qcow2",
10 "format": "qcow2",
11 "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
12 "virtual_size": 68719476736
13 },
14 "scripts": {
15 "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
16 "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35"
17 }
18}
corpus/word-lists/cold/scripts/cold.ps1 created+27
...@@ -0,0 +1,27 @@
1param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '')
2Set-StrictMode -Version Latest
3$ErrorActionPreference = 'Stop'
4$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
5if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
6 throw 'Choose a run directly below C:\one-tests\runs.'
7}
8if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' }
9$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote'
10if ($CloneHost) {
11 if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) {
12 throw 'The disposable clone hostname does not match this machine.'
13 }
14 New-Item "$key\Options\Paths" -Force | Out-Null
15 New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null
16} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or
17 -not (Test-Path 'C:\one-tests\profile-original-cache')) {
18 throw 'Park the personal OneNote profile before resetting the test cache.'
19}
20$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0'
21$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root))
22if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' }
23New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null
24if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked }
25if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse }
26New-Item "$key\OpenNotebooks" | Out-Null
27New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null
corpus/word-lists/cold/scripts/read.ps1 created+144
...@@ -0,0 +1,144 @@
1param(
2 [Parameter(Mandatory=$true)][string]$Root,
3 [int]$ExpectedPages = -1,
4 [switch]$UseCurrentCache,
5 [switch]$Pdf,
6 [switch]$KeepOpen,
7 [string]$CloneHost = ''
8)
9Set-StrictMode -Version Latest
10$ErrorActionPreference = 'Stop'
11$root = [IO.Path]::GetFullPath($Root).TrimEnd('\')
12if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') {
13 throw 'Choose a run directly below C:\one-tests\runs.'
14}
15$notebook = Join-Path $root 'notebook'
16$output = Join-Path $root 'read'
17if (Test-Path $output) { throw 'Choose a new read destination.' }
18if ($UseCurrentCache) {
19 if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') {
20 throw 'Park the personal OneNote profile before reading test notebooks.'
21 }
22} else {
23 & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost
24}
25New-Item -ItemType Directory -Path $output | Out-Null
26$app = New-Object -ComObject OneNote.Application
27$notebookId = ''
28$failure = $null
29try {
30 $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0)
31 $process = Get-Process ONENOTE
32 @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion;
33 powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } |
34 ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8
35 $sections = @()
36 foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) {
37 $id = ''
38 $app.OpenHierarchy($file.FullName, '', [ref]$id, 0)
39 $sections += $id
40 }
41 $deadline = [DateTime]::UtcNow.AddSeconds(300)
42 $previous = ''
43 $lastChange = ''
44 $stableSince = [DateTime]::UtcNow
45 $settled = $false
46 do {
47 $pages = @{}
48 foreach ($section in $sections) {
49 $hierarchy = ''
50 $app.GetHierarchy($section, 4, [ref]$hierarchy, 1)
51 [xml]$xml = $hierarchy
52 foreach ($node in $xml.SelectNodes('//*[@path]')) {
53 if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) {
54 throw 'OneNote opened a section outside the copied notebook.'
55 }
56 }
57 foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) {
58 $id = $node.GetAttribute('ID')
59 $content = ''
60 $app.GetPageContent($id, [ref]$content, 1, 1)
61 $pages[$id] = $content
62 }
63 }
64 $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] }))
65 if ($signature -ne $previous) {
66 $lastChange = $previous
67 $previous = $signature
68 $stableSince = [DateTime]::UtcNow
69 }
70 if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or
71 ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and
72 ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break }
73 Start-Sleep -Milliseconds 250
74 } while ([DateTime]::UtcNow -lt $deadline)
75 if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) {
76 [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8)
77 $index = 0
78 foreach ($id in @($pages.Keys | Sort-Object)) {
79 [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
80 $index++
81 }
82 $hierarchy = ''
83 $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1)
84 [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8)
85 throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled."
86 }
87 $index = 0
88 $payloads = @()
89 foreach ($id in @($pages.Keys | Sort-Object)) {
90 [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8)
91 if ($Pdf) {
92 $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index)
93 $app.NavigateTo($id, '', $false)
94 $app.Publish($id, $pdfPath, 3, '')
95 if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' }
96 }
97 [xml]$page = $pages[$id]
98 foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) {
99 $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache'))
100 $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
101 [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes)
102 # A paragraph's file is named by its paragraph, one on the page by itself.
103 $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode }
104 $payloads += @{ page = $id; object = $owner.GetAttribute('objectID');
105 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
106 sha256 = $hash; bytes = $bytes.Length }
107 }
108 $index++
109 }
110 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
111 $all = ''
112 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
113 [xml]$finalTree = $all
114 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
115 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
116 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
117 }
118 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
119 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
120} catch {
121 $failure = $_
122 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
123 throw
124} finally {
125 try {
126 try {
127 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
128 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
129 $app.CloseNotebook($notebookId, $false)
130 }
131 } catch {
132 if ($null -eq $failure) { throw }
133 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
134 }
135 } finally {
136 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
137 }
138 $app = $null
139 [GC]::Collect()
140 [GC]::WaitForPendingFinalizers()
141 if (-not $UseCurrentCache -and -not $CloneHost) {
142 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
143 }
144}
corpus/word-lists/cold/source.json created+14
...@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1596,
5 "sha256": "2974f1efd4b392b00d5d1a3e2485ad34d049e1ea46c0aceaed754f2ff365bc4e",
6 "mtime_ns": 1790967579952579436
7 },
8 {
9 "path": "word.one",
10 "bytes": 9632,
11 "sha256": "144bab7f0967f85881a858efd183c54aa8f334f07043d6959a68569b1fcba664",
12 "mtime_ns": 1790967579952375355
13 }
14]
corpus/word-lists/cold/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
crates/canvas/src/editor/clip.rs+6-8
...@@ -307,15 +307,13 @@ fn outline(...@@ -307,15 +307,13 @@ fn outline(
307 use super::format::AUTOMATIC;307 use super::format::AUTOMATIC;
308 for node in nodes {308 for node in nodes {
309 let mut line = format!("{prefix}{}", " ".repeat(node.level as usize - 1));309 let mut line = format!("{prefix}{}", " ".repeat(node.level as usize - 1));
310 match node.lists.last().map(|list| &definitions[list].kind) {310 if let Some(kind @ Kind::List { .. }) =
311 Some(Kind::List {311 node.lists.last().map(|list| &definitions[list].kind)
312 bullet: Some(_), ..312 {
313 }) => line.push_str("• "),313 match super::html::list_tag(kind) {
314 Some(kind @ Kind::List { .. }) => {314 ("ul", _) => line.push_str("• "),
315 let (_, kind) = super::html::list_tag(kind);315 (_, kind) => line.push_str(&format!("{kind}. ")),
316 line.push_str(&format!("{kind}. "));
317 }316 }
318 _ => {}
319 }317 }
320 let mut tags = node.tags.clone();318 let mut tags = node.tags.clone();
321 match &node.content {319 match &node.content {
crates/canvas/src/editor/html.rs+200-10
...@@ -104,7 +104,10 @@ pub(super) fn list_tag(kind: &Kind) -> (&'static str, &'static str) {...@@ -104,7 +104,10 @@ pub(super) fn list_tag(kind: &Kind) -> (&'static str, &'static str) {
104 let Kind::List { bullet, format, .. } = kind else {104 let Kind::List { bullet, format, .. } = kind else {
105 return ("ul", "disc");105 return ("ul", "disc");
106 };106 };
107 if bullet.is_some() {107 let sequence = format
108 .as_deref()
109 .and_then(|format| format.split('\u{fffd}').nth(1)?.chars().next());
110 if bullet.is_some() || sequence.is_none() {
108 let kind = match ListStyle::of(kind) {111 let kind = match ListStyle::of(kind) {
109 Some(ListStyle::Bullet(3)) => "circle",112 Some(ListStyle::Bullet(3)) => "circle",
110 Some(ListStyle::Bullet(11 | 14)) => "square",113 Some(ListStyle::Bullet(11 | 14)) => "square",
...@@ -112,9 +115,6 @@ pub(super) fn list_tag(kind: &Kind) -> (&'static str, &'static str) {...@@ -112,9 +115,6 @@ pub(super) fn list_tag(kind: &Kind) -> (&'static str, &'static str) {
112 };115 };
113 return ("ul", kind);116 return ("ul", kind);
114 }117 }
115 let sequence = format
116 .as_deref()
117 .and_then(|format| format.split('\u{fffd}').nth(1)?.chars().next());
118 let kind = match sequence.map(u32::from) {118 let kind = match sequence.map(u32::from) {
119 Some(1) => "I",119 Some(1) => "I",
120 Some(2) => "i",120 Some(2) => "i",
...@@ -257,6 +257,8 @@ pub fn html_pieces(...@@ -257,6 +257,8 @@ pub fn html_pieces(
257 tables: Vec::new(),257 tables: Vec::new(),
258 lists: Vec::new(),258 lists: Vec::new(),
259 item: None,259 item: None,
260 word: None,
261 sequences: BTreeMap::new(),
260 open: Vec::new(),262 open: Vec::new(),
261 runs: Vec::new(),263 runs: Vec::new(),
262 shown: false,264 shown: false,
...@@ -267,13 +269,22 @@ pub fn html_pieces(...@@ -267,13 +269,22 @@ pub fn html_pieces(
267 reader.pieces269 reader.pieces
268}270}
269271
272/// A paragraph Word marks as a list item, its `mso-list` list and level, and the marker Word
273/// wrote before its text, in the marker's font.
274struct WordItem {
275 list: String,
276 level: usize,
277 marker: String,
278 font: Option<String>,
279}
280
270/// A paragraph or table read, before it becomes a clip's.281/// A paragraph or table read, before it becomes a clip's.
271enum Block {282enum Block {
272 Text {283 Text {
273 runs: Vec<(String, Format)>,284 runs: Vec<(String, Format)>,
274 /// How many lists it sits in, its own not counted.285 /// How many lists it sits in, its own not counted.
275 depth: usize,286 depth: usize,
276 list: Option<ListStyle>,287 list: Option<Box<Definition>>,
277 },288 },
278 Table {289 Table {
279 rows: Vec<Vec<Vec<Block>>>,290 rows: Vec<Vec<Vec<Block>>>,
...@@ -296,6 +307,8 @@ struct Open {...@@ -296,6 +307,8 @@ struct Open {
296 name: String,307 name: String,
297 format: Format,308 format: Format,
298 link: Option<usize>,309 link: Option<usize>,
310 /// Whether it holds a Word list item's marker (`mso-list:Ignore`).
311 marker: bool,
299}312}
300313
301struct Reader<F> {314struct Reader<F> {
...@@ -307,6 +320,10 @@ struct Reader<F> {...@@ -307,6 +320,10 @@ struct Reader<F> {
307 lists: Vec<ListStyle>,320 lists: Vec<ListStyle>,
308 /// The list of the item whose text has yet to come.321 /// The list of the item whose text has yet to come.
309 item: Option<ListStyle>,322 item: Option<ListStyle>,
323 /// The Word list item being read, as `mso-list` names it.
324 word: Option<WordItem>,
325 /// The number sequence each Word list level (`mso-list` list and level) began with.
326 sequences: BTreeMap<(String, usize), char>,
310 open: Vec<Open>,327 open: Vec<Open>,
311 runs: Vec<(String, Format)>,328 runs: Vec<(String, Format)>,
312 /// Whether the paragraph read so far shows anything.329 /// Whether the paragraph read so far shows anything.
...@@ -366,6 +383,10 @@ impl<F: FnMut(&str, [Option<f32>; 2]) -> Option<Piece>> Reader<F> {...@@ -366,6 +383,10 @@ impl<F: FnMut(&str, [Option<f32>; 2]) -> Option<Piece>> Reader<F> {
366 let end = tag_end(after);383 let end = tag_end(after);
367 let tag = &after[..end];384 let tag = &after[..end];
368 rest = after.get(end + 1..).unwrap_or("");385 rest = after.get(end + 1..).unwrap_or("");
386 // Declarations and Word's `<![if ...]>` conditions.
387 if tag.starts_with('!') {
388 continue;
389 }
369 let (closing, tag) = match tag.strip_prefix('/') {390 let (closing, tag) = match tag.strip_prefix('/') {
370 Some(tag) => (true, tag),391 Some(tag) => (true, tag),
371 None => (false, tag),392 None => (false, tag),
...@@ -394,6 +415,25 @@ impl<F: FnMut(&str, [Option<f32>; 2]) -> Option<Piece>> Reader<F> {...@@ -394,6 +415,25 @@ impl<F: FnMut(&str, [Option<f32>; 2]) -> Option<Piece>> Reader<F> {
394 if block || matches!(name, "br" | "table" | "tr" | "td" | "th" | "ul" | "ol") {415 if block || matches!(name, "br" | "table" | "tr" | "td" | "th" | "ul" | "ol") {
395 self.paragraph();416 self.paragraph();
396 }417 }
418 let list = style(tag, "mso-list");
419 if block && let Some(list) = &list {
420 let mut words = list.split_whitespace();
421 if let (Some(id), Some(Ok(level))) = (
422 words.next(),
423 words
424 .next()
425 .and_then(|level| level.strip_prefix("level"))
426 .map(str::parse::<usize>),
427 ) && level > 0
428 {
429 self.word = Some(WordItem {
430 list: id.to_owned(),
431 level,
432 marker: String::new(),
433 font: None,
434 });
435 }
436 }
397 match name {437 match name {
398 "br" => return,438 "br" => return,
399 "img" => {439 "img" => {
...@@ -479,6 +519,7 @@ impl<F: FnMut(&str, [Option<f32>; 2]) -> Option<Piece>> Reader<F> {...@@ -479,6 +519,7 @@ impl<F: FnMut(&str, [Option<f32>; 2]) -> Option<Piece>> Reader<F> {
479 name: name.to_owned(),519 name: name.to_owned(),
480 format,520 format,
481 link,521 link,
522 marker: list.is_some_and(|list| list.eq_ignore_ascii_case("ignore")),
482 });523 });
483 }524 }
484525
...@@ -547,6 +588,16 @@ impl<F: FnMut(&str, [Option<f32>; 2]) -> Option<Piece>> Reader<F> {...@@ -547,6 +588,16 @@ impl<F: FnMut(&str, [Option<f32>; 2]) -> Option<Piece>> Reader<F> {
547588
548 fn text(&mut self, text: &str) {589 fn text(&mut self, text: &str) {
549 let format = self.format();590 let format = self.format();
591 if self.open.iter().any(|open| open.marker) {
592 if let Some(item) = &mut self.word {
593 let shown = text.trim_matches(|c: char| c.is_whitespace() || c == '\u{a0}');
594 if !shown.is_empty() && item.marker.is_empty() {
595 item.font = format.font.clone();
596 }
597 item.marker.push_str(shown);
598 }
599 return;
600 }
550 for c in text.chars() {601 for c in text.chars() {
551 if c.is_whitespace() && c != '\u{a0}' {602 if c.is_whitespace() && c != '\u{a0}' {
552 if self.shown && self.space.is_none() {603 if self.shown && self.space.is_none() {
...@@ -580,15 +631,78 @@ impl<F: FnMut(&str, [Option<f32>; 2]) -> Option<Piece>> Reader<F> {...@@ -580,15 +631,78 @@ impl<F: FnMut(&str, [Option<f32>; 2]) -> Option<Piece>> Reader<F> {
580 for open in &mut self.open {631 for open in &mut self.open {
581 open.link = None;632 open.link = None;
582 }633 }
583 let list = self.item.take();
584 let depth = self.lists.len() - usize::from(list.is_some() && !self.lists.is_empty());
585 runs.retain(|(text, _)| !text.is_empty());634 runs.retain(|(text, _)| !text.is_empty());
586 if runs.is_empty() {635 if runs.is_empty() {
587 runs.push((String::new(), self.format()));636 runs.push((String::new(), self.format()));
588 }637 }
638 let (list, depth) = match self.word.take() {
639 Some(item) => {
640 let depth = self.lists.len() + item.level - 1;
641 (Some(Box::new(self.word_list(item, &runs[0].1))), depth)
642 }
643 None => {
644 let list = self.item.take();
645 let depth =
646 self.lists.len() - usize::from(list.is_some() && !self.lists.is_empty());
647 (
648 list.map(|style| Box::new(list_definition(style, &runs[0].1))),
649 depth,
650 )
651 }
652 };
589 self.sink().push(Block::Text { runs, depth, list });653 self.sink().push(Block::Text { runs, depth, list });
590 }654 }
591655
656 /// The list OneNote 2010 made of a Word list item whose text begins in `format` (lab,
657 /// 2026-10-02): a marker without a number is a bullet in Word's glyph and font; a number
658 /// keeps Word's punctuation, in the sequence its list level began with.
659 fn word_list(&mut self, item: WordItem, format: &Format) -> Definition {
660 let mut definition = list_definition(ListStyle::NUMBER, format);
661 let Kind::List {
662 font, format: list, ..
663 } = &mut definition.kind
664 else {
665 unreachable!("a number is a list")
666 };
667 let marker = item.marker;
668 let alphanumeric = |c: char| c.is_ascii_alphanumeric();
669 let token = marker
670 .find(alphanumeric)
671 .zip(marker.rfind(alphanumeric))
672 .map(|(start, end)| start..end + 1);
673 let digits = token
674 .as_ref()
675 .is_some_and(|token| marker[token.clone()].bytes().any(|b| b.is_ascii_digit()));
676 let Some(number) = token.filter(|token| digits || token.len() < marker.len()) else {
677 *font = item.font;
678 *list = Some(marker);
679 return definition;
680 };
681 let token = &marker[number.clone()];
682 let sequence = *self
683 .sequences
684 .entry((item.list, item.level))
685 .or_insert_with(|| {
686 let roman = token == "i"
687 || token == "I"
688 || token.len() > 1 && token.chars().all(|c| "ivxlcdmIVXLCDM".contains(c));
689 let upper = token.starts_with(|c: char| c.is_ascii_uppercase());
690 match (digits, roman, upper) {
691 (true, ..) => '\u{0}',
692 (_, true, true) => '\u{1}',
693 (_, true, false) => '\u{2}',
694 (_, false, true) => '\u{3}',
695 (_, false, false) => '\u{4}',
696 }
697 });
698 *list = Some(format!(
699 "{}\u{fffd}{sequence}{}",
700 &marker[..number.start],
701 &marker[number.end..]
702 ));
703 definition
704 }
705
592 /// Where a block read goes: the open cell, else the clip.706 /// Where a block read goes: the open cell, else the clip.
593 fn sink(&mut self) -> &mut Vec<Block> {707 fn sink(&mut self) -> &mut Vec<Block> {
594 match self.tables.last_mut() {708 match self.tables.last_mut() {
...@@ -643,11 +757,10 @@ fn nodes(...@@ -643,11 +757,10 @@ fn nodes(
643 let level = (depth(&block) - least + 1) as u32;757 let level = (depth(&block) - least + 1) as u32;
644 let mut paragraph = match block {758 let mut paragraph = match block {
645 Block::Text { runs, list, .. } => {759 Block::Text { runs, list, .. } => {
646 let first = runs[0].1.clone();
647 let mut paragraph = node(Paragraph::from_runs(runs), Format::default())?;760 let mut paragraph = node(Paragraph::from_runs(runs), Format::default())?;
648 if let Some(style) = list {761 if let Some(list) = list {
649 let id = new_id()?;762 let id = new_id()?;
650 definitions.insert(id, list_definition(style, &first));763 definitions.insert(id, *list);
651 paragraph.lists = vec![id];764 paragraph.lists = vec![id];
652 }765 }
653 paragraph766 paragraph
...@@ -1064,6 +1177,83 @@ mod tests {...@@ -1064,6 +1177,83 @@ mod tests {
1064 );1177 );
1065 }1178 }
10661179
1180 /// Word 2010's list paragraphs (`corpus/clipboard/word-2010.html`) paste as the lists
1181 /// OneNote 2010 made of them (lab, 2026-10-02): each level nested under the item above,
1182 /// a bullet in Word's own glyph and font, a number in Word's sequence and punctuation.
1183 #[test]
1184 fn words_lists_paste_as_lists() {
1185 let html = include_str!("../../../../corpus/clipboard/word-2010.html");
1186 let [clip] = &clips(html)[..] else {
1187 panic!("one clip")
1188 };
1189 assert_eq!(
1190 clip.outline(),
1191 [
1192 "• Bullet one",
1193 " • Bullet nested",
1194 " • Bullet deeper",
1195 "• Bullet two",
1196 "Plain paragraph",
1197 "1. Number one",
1198 " a. Number nested",
1199 " i. Number deeper",
1200 "1. Number two",
1201 "Last plain",
1202 ]
1203 );
1204 let lists = crate::document::leaves(&clip.paragraphs, None)
1205 .filter_map(
1206 |(.., node)| match &clip.definitions[node.lists.first()?].kind {
1207 Kind::List {
1208 font,
1209 format,
1210 bullet,
1211 ..
1212 } => Some((font.clone(), format.clone().unwrap(), *bullet)),
1213 _ => None,
1214 },
1215 )
1216 .collect::<Vec<_>>();
1217 let bullet = |font: &str, glyph: &str| (Some(font.to_owned()), glyph.to_owned(), None);
1218 let number = |format: &str| (None, format.to_owned(), None);
1219 assert_eq!(
1220 lists,
1221 [
1222 bullet("Symbol", "\u{b7}"),
1223 bullet("Courier New", "o"),
1224 bullet("Wingdings", "\u{a7}"),
1225 bullet("Symbol", "\u{b7}"),
1226 number("\u{fffd}\u{0}."),
1227 number("\u{fffd}\u{4}."),
1228 number("\u{fffd}\u{2}."),
1229 number("\u{fffd}\u{0}."),
1230 ]
1231 );
1232 }
1233
1234 /// LibreOffice writes lists as nested `ul` and `ol` around paragraphs, leaving items
1235 /// unclosed.
1236 #[test]
1237 fn libreoffices_lists_paste_as_lists() {
1238 let clip = &clips(
1239 "<ul>\n\t<li><p style=\"margin-bottom: 0in\">Bullet one</p>\n\t<ul>\n\t\t\
1240 <li><p>Bullet nested</p>\n\t</ul>\n\t<li><p>Bullet two</p>\n</ul>\n<ol>\n\t\
1241 <li><p>Number one</p>\n\t<ol type=\"a\">\n\t\t<li><p>Number nested</p>\n\t\
1242 </ol>\n\t<li><p>Number two</p>\n</ol>",
1243 )[0];
1244 assert_eq!(
1245 clip.outline(),
1246 [
1247 "• Bullet one",
1248 " • Bullet nested",
1249 "• Bullet two",
1250 "1. Number one",
1251 " a. Number nested",
1252 "1. Number two",
1253 ]
1254 );
1255 }
1256
1067 /// Snowbound's HTML reads back as the clip it was written from.1257 /// Snowbound's HTML reads back as the clip it was written from.
1068 #[test]1258 #[test]
1069 fn snowbound_html_reads_back_as_written() {1259 fn snowbound_html_reads_back_as_written() {
crates/canvas/tests/clip_paste.rs+72-2
...@@ -16,6 +16,7 @@ use onestore::{...@@ -16,6 +16,7 @@ use onestore::{
16};16};
1717
18const ONENOTE: &str = include_str!("../../../corpus/clipboard/onenote-2010.html");18const ONENOTE: &str = include_str!("../../../corpus/clipboard/onenote-2010.html");
19const WORD: &str = include_str!("../../../corpus/clipboard/word-2010.html");
1920
20/// Each paragraph of a body outline: its level, list kind, text and bold runs.21/// Each paragraph of a body outline: its level, list kind, text and bold runs.
21fn outline(page: &Page, outline: &Outline) -> Vec<String> {22fn outline(page: &Page, outline: &Outline) -> Vec<String> {
...@@ -23,8 +24,9 @@ fn outline(page: &Page, outline: &Outline) -> Vec<String> {...@@ -23,8 +24,9 @@ fn outline(page: &Page, outline: &Outline) -> Vec<String> {
23 for node in nodes {24 for node in nodes {
24 let list = match node.lists.last().map(|id| &page.definitions[id].kind) {25 let list = match node.lists.last().map(|id| &page.definitions[id].kind) {
25 Some(Kind::List {26 Some(Kind::List {
26 bullet: Some(_), ..27 format: Some(format),
27 }) => "• ",28 ..
29 }) if !format.contains('\u{fffd}') => "• ",
28 Some(Kind::List { .. }) => "# ",30 Some(Kind::List { .. }) => "# ",
29 _ => "",31 _ => "",
30 };32 };
...@@ -310,3 +312,71 @@ fn several_lines_pasted_from_the_title_go_into_the_body() {...@@ -310,3 +312,71 @@ fn several_lines_pasted_from_the_title_go_into_the_body() {
310 .unwrap();312 .unwrap();
311 }313 }
312}314}
315
316/// Word 2010's lists (`corpus/clipboard/word-2010.html`) paste as lists that the section
317/// stores and reads back. `CANVAS_WORD_LIST_EXPORT` names a directory receiving the section
318/// as a notebook, for a cold reopen in OneNote 2010.
319#[test]
320fn words_lists_paste_and_store() {
321 let source = onestore::create_section("word.one", "Word lists", "Author").unwrap();
322 let arena = Arena::default();
323 let mut section = Section::open(&arena, source.clone()).unwrap();
324 let (space, ..) = section.pages().unwrap()[0].clone();
325 let mut engine = TextEngine::default();
326 let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap();
327 editor
328 .place_caret(&mut engine, [72.0, 120.0], 400.0)
329 .unwrap();
330 editor
331 .paste_pieces(&mut engine, html_pieces(WORD, 0x409, |_, _| None))
332 .unwrap();
333 let ops = editor.take_ops().unwrap();
334 let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect();
335 section
336 .apply(
337 "Author",
338 &Edit {
339 at: 133_000_000_000_000_000,
340 ops,
341 },
342 )
343 .unwrap();
344
345 let mut image = source;
346 section.seal().unwrap().unwrap().apply(&mut image).unwrap();
347 let arena = Arena::default();
348 let reopened = Section::open(&arena, image.clone()).unwrap();
349 let page = reopened.page(space).unwrap();
350 assert_eq!(
351 bodies(&page),
352 [
353 vec!["Word lists"],
354 vec![
355 "• Bullet one",
356 " • Bullet nested",
357 " • Bullet deeper",
358 "• Bullet two",
359 "Plain paragraph",
360 "# Number one",
361 " # Number nested",
362 " # Number deeper",
363 "# Number two",
364 "Last plain",
365 ]
366 ]
367 );
368 assert_eq!(bodies(&page), bodies(&editor.page().unwrap()));
369
370 if let Some(directory) = std::env::var_os("CANVAS_WORD_LIST_EXPORT") {
371 let directory = std::path::PathBuf::from(directory);
372 std::fs::create_dir_all(&directory).unwrap();
373 std::fs::write(directory.join("word.one"), &image).unwrap();
374 let file_id = Store::parse(&image).unwrap().header.file_id;
375 std::fs::write(
376 directory.join("Open Notebook.onetoc2"),
377 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("word.one", file_id)])
378 .unwrap(),
379 )
380 .unwrap();
381 }
382}
tools/test_word_lists.py created+55
...@@ -0,0 +1,55 @@
1from pathlib import Path
2import re
3import runpy
4import shutil
5from tempfile import TemporaryDirectory
6import unittest
7import xml.etree.ElementTree as ET
8
9from native_xml import ns
10
11ROOT = Path(__file__).resolve().parent.parent
12FIXTURE = ROOT / 'corpus/word-lists'
13compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
14
15
16class WordListsTest(unittest.TestCase):
17 def test_lists_pasted_from_word_open_in_onenote_as_its_own_paste_gave_them(self):
18 read = FIXTURE / 'cold/read'
19 with TemporaryDirectory() as temporary:
20 copy = Path(temporary) / 'read'
21 shutil.copytree(read, copy)
22 compare(FIXTURE / 'candidate', copy)
23 page = ET.parse(read / 'page-000.xml').getroot()
24 lines = []
25
26 def walk(children, depth):
27 for element in children.findall('one:OE', ns):
28 bullet = element.find('one:List/one:Bullet', ns)
29 number = element.find('one:List/one:Number', ns)
30 marker = ('bullet %s ' % bullet.get('bullet') if bullet is not None
31 else number.get('text') + ' ' if number is not None else '')
32 text = re.sub(r'<[^>]*>', '', element.find('one:T', ns).text or '')
33 lines.append(' ' * depth + marker + text)
34 for nested in element.findall('one:OEChildren', ns):
35 walk(nested, depth + 1)
36
37 outline = page.findall('one:Outline', ns)[-1]
38 for children in outline.findall('one:OEChildren', ns):
39 walk(children, 0)
40 self.assertEqual(lines, [
41 'bullet 1 Bullet one',
42 ' bullet 0 Bullet nested',
43 ' bullet 13 Bullet deeper',
44 'bullet 1 Bullet two',
45 'Plain paragraph',
46 '1. Number one',
47 ' a. Number nested',
48 ' i. Number deeper',
49 '2. Number two',
50 'Last plain',
51 ])
52
53
54if __name__ == '__main__':
55 unittest.main()