authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-13 00:48:29-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-25 20:26:20-07:00
log3d72c72fcc4bf5ea8b86377b6117843fea073b61
tree942a226393504ab5a0605abf25ac109664ac9bd6
parent75e67fc117387e3301560b6a3ee21075f3487b71
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: copy pages between sections, quick styles on new paragraphs, scale baseline

`Page::copy` gives a page's content fresh identities (every `{guid},n` in the model, keys and references alike, becomes `{fresh},1`) with its definitions and payloads, and `Section::import_page` queues a page creation and a save for it in the destination, so a copy publishes offline like any edit; `Section::delete_pages` completes a move. Picture and attachment payloads now serialize with the model (base64), because a queued intent replayed without them failed publication for any new picture or attachment. The writer learned what copied pages need: a new outline may start with a picture, table, attachment or ink paragraph and takes the model's indentation table; a new paragraph in a new outline may reference a paragraph style, whose object the writer creates from the definition as OneNote keeps quick styles. A styled paragraph appended to a stored outline still stays for review, as the split-merge lane expects. `corpus/page-copy` is the cold read of eight copied pages (text with attachment, table, picture, ink, tags, lists, equations, paragraph formatting) with screenshots; the `QuickStyleDef` export carries the copied style's font, size and colour. `evidence/P01-BASELINE.md` records the queue and large-notebook baselines. Assisted-by: claude-fable-5.1

53 files changed, 969 insertions(+), 16 deletions(-)

corpus/page-copy/README.md created+23
...@@ -0,0 +1,23 @@
1# Pages copied between sections
2
3`candidate/copies.one` is the destination section
4`pages_copy_into_another_section_with_their_content_and_fresh_identities` in
5`crates/notebook/tests/copy.rs` builds: a section created in Rust into which
6`Section::import_page` copied one page from each of eight earlier rows (text
7with an attachment and its icon, a table, an inserted picture, a page-level
8ink drawing, note tags, bullets and numbering, equations, paragraph
9formatting), each as a page creation and a save queued through the replica
10and published to the file; the last copy (paragraph formatting) was then deleted permanently
11through `Section::delete_pages` (`expected-count.txt` is the page count
12left). Every object carries a fresh identity (`Page::copy`), paragraph
13styles become quick-style objects the writer creates from the copied
14definitions (OneNote exports their font, size and colour through
15`QuickStyleDef`, not on the runs), and payloads travelled inside the queued
16intents.
17
18`cold/` is a fresh OneNote 2010 read with a screenshot per page
19(`read/page-NNN.png`). `tools/test_page_copy.py` checks the content oracle
20and the page set without a VM. Regenerate with `NOTEBOOK_COPY_EXPORT` set to
21a new directory while running the test, then cold-open with
22`tools/native_runner.py OUTPUT COLD --expected-pages 9 --collect-notebook
23--screenshots`.
corpus/page-copy/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/page-copy/candidate/Open Notebook.onetoc2 differ
corpus/page-copy/candidate/copies.one created
Binary files /dev/null and b/corpus/page-copy/candidate/copies.one differ
corpus/page-copy/candidate/expected-count.txt created+1
...@@ -0,0 +1 @@
19
\ No newline at end of file
corpus/page-copy/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-1C0F2CDE -ExpectedPages 9 -KeepOpen", "exit": 0, "stdout": "Read 1 sections and 9 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/page-copy/cold/machine.json created+1
...@@ -0,0 +1 @@
1{"name": "m6-1c0f2cde", "hostname": "ONE-M6-1C0F2CDE"}
corpus/page-copy/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/page-copy/cold/notebook/Open Notebook.onetoc2 differ
corpus/page-copy/cold/notebook/copies.one created
Binary files /dev/null and b/corpus/page-copy/cold/notebook/copies.one differ
corpus/page-copy/cold/notebook/expected-count.txt created+1
...@@ -0,0 +1 @@
19
\ No newline at end of file
corpus/page-copy/cold/read/b20a14e044e33eac39b4665e7d79813b8a3672dbbd27ed9d2e4ac866c13ec4b6.attachment created+1
...@@ -0,0 +1 @@
1Rust wrote this attachment.
corpus/page-copy/cold/read/environment.json created+7
...@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-1C0F2CDE",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/page-copy/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="{CA49439A-5FEF-43A6-AB60-E8C051A59087}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-13T07:34:46.000Z" color="#B49EDE"><one:Section name="copies" ID="{6D7B6C59-2E45-0D8F-286E-4FD72999E63A}{1}{B0}" path="C:\one-tests\runs\capture\notebook\copies.one" lastModifiedTime="2026-09-13T07:34:46.000Z" color="#8AA8E4"><one:Page ID="{8BB0D046-3BC7-0CE9-33B2-04F7D5E9C6FD}{1}{B0}" name="Destination" dateTime="2026-09-13T07:33:14.000Z" lastModifiedTime="2026-09-13T07:34:35.000Z" pageLevel="1"/><one:Page ID="{C12C0ECF-DDF8-0A21-11BE-3074429542E0}{1}{B0}" name="Before the file" dateTime="2026-09-13T07:33:14.000Z" lastModifiedTime="2026-09-13T07:34:35.000Z" pageLevel="1"/><one:Page ID="{91EAB23E-CBE0-0D18-1A25-23B9CBC39112}{1}{B0}" name="Before the table" dateTime="2026-09-13T07:33:14.000Z" lastModifiedTime="2026-09-13T07:34:35.000Z" pageLevel="1"/><one:Page ID="{B5A45771-4A39-05F8-1953-4F2F9B2B33A5}{1}{B0}" name="Before the picture" dateTime="2026-09-13T07:33:14.000Z" lastModifiedTime="2026-09-13T07:34:36.000Z" pageLevel="1"/><one:Page ID="{B28A26BE-5DF2-0214-1827-B1747FE95ECC}{1}{B0}" name="Beside the drawing" dateTime="2026-09-13T07:33:14.000Z" lastModifiedTime="2026-09-13T07:34:36.000Z" pageLevel="1"/><one:Page ID="{5C003E48-8717-05C2-096E-6762316BC0A0}{1}{B0}" name="Open task" dateTime="2026-09-13T07:33:14.000Z" lastModifiedTime="2026-09-13T07:34:36.000Z" pageLevel="1"/><one:Page ID="{C5E005CB-753E-07E2-0C53-CD40FBB64A63}{1}{B0}" name="Bullet item" dateTime="2026-09-13T07:33:14.000Z" lastModifiedTime="2026-09-13T07:34:36.000Z" pageLevel="1"/><one:Page ID="{5A5E0FDA-1644-0F00-0C45-8D72178B2031}{1}{B0}" name="Equations below" dateTime="2026-09-13T07:33:15.000Z" lastModifiedTime="2026-09-13T07:34:36.000Z" pageLevel="1"/><one:Page ID="{D4C06049-3C91-0190-0C2E-1BB1EBA49EBA}{1}{B0}" name="A notebook created in Rust." dateTime="2026-09-13T07:33:15.000Z" lastModifiedTime="2026-09-13T07:34:36.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/page-copy/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": "Equations below - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-copy/cold/read/page-000.png created
Binary files /dev/null and b/corpus/page-copy/cold/read/page-000.png differ
corpus/page-copy/cold/read/page-000.xml created+3
...@@ -0,0 +1,3 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{5A5E0FDA-1644-0F00-0C45-8D72178B2031}{1}{B0}" name="Equations below" dateTime="2026-09-13T07:33:15.000Z" lastModifiedTime="2026-09-13T07:34:36.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Copier" lastModifiedBy="Copier" creationTime="2026-09-13T07:33:15.000Z" lastModifiedTime="2026-09-13T07:33:15.000Z" objectID="{5A5E0FDA-1644-0F00-0C45-8D72178B2031}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Equations below]]></one:T></one:OE></one:Title><one:Outline author="Copier" lastModifiedBy="Copier" lastModifiedTime="2026-09-13T07:33:15.000Z" objectID="{75AB7537-48D1-0C4C-297D-B4D4CFA87CA2}{1}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="140.9678649902344" height="208.9916534423828"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-13T07:33:15.000Z" lastModifiedTime="2026-09-13T07:33:15.000Z" objectID="{D6CEF286-AFD3-0A03-0655-BA825F09BE5C}{1}{B0}" alignment="left"><one:T><![CDATA[Equations below]]></one:T></one:OE><one:OE creationTime="2026-09-13T07:33:15.000Z" lastModifiedTime="2026-09-13T07:33:15.000Z" objectID="{111AB493-1B15-0171-1BEC-C517BF808F15}{1}{B0}" alignment="left" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:msup><mml:mi>x</mml:mi><mml:mn>2</mml:mn></mml:msup><mml:mo>+</mml:mo><mml:msup><mml:mi>y</mml:mi><mml:mn>2</mml:mn></mml:msup><mml:mo>=</mml:mo><mml:msup><mml:mi>z</mml:mi><mml:mn>2</mml:mn></mml:msup></mml:math><![endif]-->]]></one:T></one:OE><one:OE creationTime="2026-09-13T07:33:15.000Z" lastModifiedTime="2026-09-13T07:33:15.000Z" objectID="{E963BDE9-C0E4-02BE-2D54-23989FC58846}{1}{B0}" alignment="left" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfrac><mml:mrow><mml:mi>a</mml:mi><mml:mo>+</mml:mo><mml:mi>b</mml:mi></mml:mrow><mml:mrow><mml:mi>c</mml:mi><mml:mo>+</mml:mo><mml:mi>d</mml:mi></mml:mrow></mml:mfrac></mml:math><![endif]-->]]></one:T></one:OE><one:OE creationTime="2026-09-13T07:33:15.000Z" lastModifiedTime="2026-09-13T07:33:15.000Z" objectID="{869A34E1-759D-03DC-1F28-DE83A586BDD6}{1}{B0}" alignment="left" style="font-size:11.0pt"><one:T><![CDATA[<span
3style='font-family:Calibri' lang=en-US>Inline </span><!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML"><mml:mi>&#120572;</mml:mi><mml:mo>+</mml:mo><mml:mi>&#120573;</mml:mi></mml:math><![endif]-->]]></one:T></one:OE><one:OE creationTime="2026-09-13T07:33:15.000Z" lastModifiedTime="2026-09-13T07:33:15.000Z" objectID="{877DD34D-81AC-038C-0477-B3E0645DD63E}{1}{B0}" alignment="left" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:msub><mml:mi>a</mml:mi><mml:mn>1</mml:mn></mml:msub><mml:mo>+</mml:mo><mml:msub><mml:mi>b</mml:mi><mml:mn>2</mml:mn></mml:msub></mml:math><![endif]-->]]></one:T></one:OE><one:OE creationTime="2026-09-13T07:33:15.000Z" lastModifiedTime="2026-09-13T07:33:15.000Z" objectID="{CD4EC150-40D1-074B-1157-F388604EEBE0}{1}{B0}" alignment="left" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:msubsup><mml:mi>x</mml:mi><mml:mi>i</mml:mi><mml:mn>2</mml:mn></mml:msubsup></mml:math><![endif]-->]]></one:T></one:OE><one:OE creationTime="2026-09-13T07:33:15.000Z" lastModifiedTime="2026-09-13T07:33:15.000Z" objectID="{1B908B9B-F5DF-0E47-3861-D55838C5AB28}{1}{B0}" alignment="left" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfenced><mml:mrow><mml:mi>a</mml:mi><mml:mo>+</mml:mo><mml:mi>b</mml:mi></mml:mrow></mml:mfenced></mml:math><![endif]-->]]></one:T></one:OE><one:OE creationTime="2026-09-13T07:33:15.000Z" lastModifiedTime="2026-09-13T07:33:15.000Z" objectID="{2A2E6B30-0040-052F-26F1-91BABCAE46B2}{1}{B0}" alignment="left" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:msubsup><mml:mo stretchy="false">∫</mml:mo><mml:mn>0</mml:mn><mml:mn>1</mml:mn></mml:msubsup><mml:mrow><mml:mi>x</mml:mi><mml:mo>&nbsp;</mml:mo><mml:mi>d</mml:mi><mml:mi>x</mml:mi><mml:mo>&nbsp;</mml:mo></mml:mrow></mml:math><![endif]-->]]></one:T></one:OE><one:OE creationTime="2026-09-13T07:33:15.000Z" lastModifiedTime="2026-09-13T07:33:15.000Z" objectID="{3110679E-A6C8-08CA-14E7-D38AABF07619}{1}{B0}" alignment="left" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:munderover><mml:mo stretchy="false">∑</mml:mo><mml:mrow><mml:mi>i</mml:mi><mml:mo>=</mml:mo><mml:mn>1</mml:mn></mml:mrow><mml:mi>n</mml:mi></mml:munderover><mml:mrow><mml:mi>i</mml:mi><mml:mo>&nbsp;</mml:mo></mml:mrow></mml:math><![endif]-->]]></one:T></one:OE><one:OE creationTime="2026-09-13T07:33:15.000Z" lastModifiedTime="2026-09-13T07:33:15.000Z" objectID="{58A7316B-7529-03F0-2F54-20F513A79572}{1}{B0}" alignment="left" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:msup><mml:mi>x</mml:mi><mml:mn>2</mml:mn></mml:msup><mml:mo>+</mml:mo><mml:mn>1</mml:mn></mml:math><![endif]-->]]></one:T></one:OE><one:OE creationTime="2026-09-13T07:33:15.000Z" lastModifiedTime="2026-09-13T07:33:15.000Z" objectID="{BFE82679-7A72-09C5-266D-FBEC819E237D}{1}{B0}" alignment="left" style="font-family:'Cambria Math';font-size:11.0pt"><one:T><![CDATA[<!--[if mathML]><mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block"><mml:mfrac><mml:mrow><mml:mi>a</mml:mi><mml:mo>+</mml:mo><mml:mi>b</mml:mi></mml:mrow><mml:mrow><mml:msup><mml:mi>c</mml:mi><mml:mn>2</mml:mn></mml:msup></mml:mrow></mml:mfrac></mml:math><![endif]-->]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/page-copy/cold/read/page-001.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": "Open task - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-copy/cold/read/page-001.png created
Binary files /dev/null and b/corpus/page-copy/cold/read/page-001.png differ
corpus/page-copy/cold/read/page-001.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="{5C003E48-8717-05C2-096E-6762316BC0A0}{1}{B0}" name="Open task" dateTime="2026-09-13T07:33:14.000Z" lastModifiedTime="2026-09-13T07:34:36.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:TagDef index="0" type="0" symbol="3" fontColor="automatic" highlightColor="none" name="Rust task"/><one:TagDef index="1" type="1" symbol="13" fontColor="#FF0000" highlightColor="#FFFF00" name="Important"/><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Copier" lastModifiedBy="Copier" creationTime="2026-09-13T07:33:14.000Z" lastModifiedTime="2026-09-13T07:33:14.000Z" objectID="{5C003E48-8717-05C2-096E-6762316BC0A0}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Open task]]></one:T></one:OE></one:Title><one:Outline author="Copier" lastModifiedBy="Copier" lastModifiedTime="2026-09-13T07:33:15.000Z" objectID="{EA96F1EA-D912-043B-3585-EF6DB73F7D60}{1}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="158.2036895751953" height="53.71086883544922"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-13T07:33:15.000Z" lastModifiedTime="2026-09-13T07:33:15.000Z" objectID="{2B4A358D-B65A-0B30-167C-A3C772956F2F}{1}{B0}" alignment="left"><one:Tag index="0" completed="false" disabled="false" creationDate="2020-01-02T03:04:05.000Z"/><one:T><![CDATA[Open task]]></one:T></one:OE><one:OE creationTime="2026-09-13T07:33:15.000Z" lastModifiedTime="2026-09-13T07:33:15.000Z" objectID="{30380DA0-EF03-0A04-03AD-6F50C3F095CB}{1}{B0}" alignment="left"><one:Tag index="0" completed="true" disabled="false" creationDate="2020-01-02T03:04:05.000Z" completionDate="2020-01-02T04:05:06.000Z"/><one:T><![CDATA[Completed task]]></one:T></one:OE><one:OE creationTime="2026-09-13T07:33:15.000Z" lastModifiedTime="2026-09-13T07:33:15.000Z" objectID="{26502F9C-1811-0C5C-23BE-395D96FFF797}{1}{B0}" alignment="left"><one:Tag index="0" completed="false" disabled="false" creationDate="2020-01-02T03:04:05.000Z"/><one:Tag index="1" completed="false" disabled="false" creationDate="2020-01-02T03:04:05.000Z"/><one:T><![CDATA[Important open task]]></one:T></one:OE><one:OE creationTime="2026-09-13T07:33:15.000Z" lastModifiedTime="2026-09-13T07:33:15.000Z" objectID="{B17CBFD9-E3D1-0CC8-0837-B615ED83C6E5}{1}{B0}" alignment="left"><one:T><![CDATA[Untagged]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/page-copy/cold/read/page-002.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": "Destination - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-copy/cold/read/page-002.png created
Binary files /dev/null and b/corpus/page-copy/cold/read/page-002.png differ
corpus/page-copy/cold/read/page-002.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="{8BB0D046-3BC7-0CE9-33B2-04F7D5E9C6FD}{1}{B0}" name="Destination" dateTime="2026-09-13T07:33:14.000Z" lastModifiedTime="2026-09-13T07:34:35.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" lastModifiedBy="Author" lastModifiedTime="2026-09-13T07:33:14.000Z" objectID="{6D7B6C59-2E45-0D8F-286E-4FD72999E63A}{23}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="118.3393707275391" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-13T07:33:14.000Z" lastModifiedTime="2026-09-13T07:33:14.000Z" objectID="{6D7B6C59-2E45-0D8F-286E-4FD72999E63A}{24}{B0}" alignment="left"><one:T><![CDATA[Destination]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/page-copy/cold/read/page-003.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": "Before the table - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-copy/cold/read/page-003.png created
Binary files /dev/null and b/corpus/page-copy/cold/read/page-003.png differ
corpus/page-copy/cold/read/page-003.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="{91EAB23E-CBE0-0D18-1A25-23B9CBC39112}{1}{B0}" name="Before the table" dateTime="2026-09-13T07:33:14.000Z" lastModifiedTime="2026-09-13T07:34:35.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Copier" lastModifiedBy="Copier" creationTime="2026-09-13T07:33:14.000Z" lastModifiedTime="2026-09-13T07:33:14.000Z" objectID="{91EAB23E-CBE0-0D18-1A25-23B9CBC39112}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Before the table]]></one:T></one:OE></one:Title><one:Outline author="Copier" lastModifiedBy="Copier" lastModifiedTime="2026-09-13T07:33:14.000Z" objectID="{899FA9AA-6E30-05C6-0039-C1174852FBAA}{1}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="395.1299438476562" height="67.21086883544922"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-13T07:33:14.000Z" lastModifiedTime="2026-09-13T07:33:14.000Z" objectID="{03DB4753-6DD9-0476-0A44-807F1E239611}{1}{B0}" alignment="left"><one:T><![CDATA[Before the table]]></one:T></one:OE><one:OE creationTime="2026-09-13T07:33:14.000Z" lastModifiedTime="2026-09-13T07:33:14.000Z" objectID="{CC36F460-50AA-0CAC-185C-B2DD3405EC23}{1}{B0}" alignment="left"><one:Table bordersVisible="true" lastModifiedTime="2026-09-13T07:33:14.000Z" objectID="{F406EA96-EABF-0750-358B-13B077B8877C}{1}{B0}"><one:Columns><one:Column index="0" width="144.0" isLocked="true"/><one:Column index="1" width="216.0" isLocked="true"/></one:Columns><one:Row objectID="{B57E3F32-D364-012F-129A-7A8AFA0A8DE1}{1}{B0}" lastModifiedTime="2026-09-13T07:33:14.000Z"><one:Cell lastModifiedTime="2026-09-13T07:33:14.000Z" objectID="{8B99B904-69FB-0ABF-25D8-6EDED1F41EED}{1}{B0}"><one:OEChildren><one:OE creationTime="2026-09-13T07:33:14.000Z" lastModifiedTime="2026-09-13T07:33:14.000Z" objectID="{2F0315A5-D396-09FD-0772-968EDD6113D6}{1}{B0}" alignment="left"><one:T><![CDATA[Name]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-13T07:33:14.000Z" objectID="{58E45041-62CE-0EFA-3F22-C4509482E3BF}{1}{B0}"><one:OEChildren><one:OE creationTime="2026-09-13T07:33:14.000Z" lastModifiedTime="2026-09-13T07:33:14.000Z" objectID="{0F92F8F0-F5F6-0CCA-3E1C-10A217EA11EF}{1}{B0}" alignment="left"><one:T><![CDATA[Value]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row><one:Row objectID="{6D9AA72E-D225-0D46-3D00-5B12D99343C3}{1}{B0}" lastModifiedTime="2026-09-13T07:33:14.000Z"><one:Cell lastModifiedTime="2026-09-13T07:33:14.000Z" objectID="{2555B7CF-F12A-013D-0652-4CA9AEF5284C}{1}{B0}"><one:OEChildren><one:OE creationTime="2026-09-13T07:33:14.000Z" lastModifiedTime="2026-09-13T07:33:14.000Z" objectID="{A0E15DEA-0A8D-0F42-12FA-36CC54EA289A}{1}{B0}" alignment="left"><one:T><![CDATA[Rust &#129408;]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-13T07:33:14.000Z" objectID="{7ED49F5B-8C93-0208-16B6-8816DB6B4151}{1}{B0}"><one:OEChildren><one:OE creationTime="2026-09-13T07:33:14.000Z" lastModifiedTime="2026-09-13T07:33:14.000Z" objectID="{D60ED3D2-2081-02A2-1EC5-A3D1190448EB}{1}{B0}" alignment="left"><one:T><![CDATA[Written natively]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE><one:OE creationTime="2026-09-13T07:33:14.000Z" lastModifiedTime="2026-09-13T07:33:14.000Z" objectID="{F18DD402-3CA3-01AB-32DC-D1D5A8545B7F}{1}{B0}" alignment="left"><one:T><![CDATA[After the table]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/page-copy/cold/read/page-004.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": "Beside the drawing - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-copy/cold/read/page-004.png created
Binary files /dev/null and b/corpus/page-copy/cold/read/page-004.png differ
corpus/page-copy/cold/read/page-004.xml created+7
...@@ -0,0 +1,7 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{B28A26BE-5DF2-0214-1827-B1747FE95ECC}{1}{B0}" name="Beside the drawing" dateTime="2026-09-13T07:33:14.000Z" lastModifiedTime="2026-09-13T07:34:36.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Copier" lastModifiedBy="Copier" creationTime="2026-09-13T07:33:14.000Z" lastModifiedTime="2026-09-13T07:33:14.000Z" objectID="{B28A26BE-5DF2-0214-1827-B1747FE95ECC}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Beside the drawing]]></one:T></one:OE></one:Title><one:Outline author="Copier" lastModifiedBy="Copier" lastModifiedTime="2026-09-13T07:33:14.000Z" objectID="{BA59696B-EAE1-0771-0BCB-5878F45A65F8}{1}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="152.4244079589843" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-13T07:33:14.000Z" lastModifiedTime="2026-09-13T07:33:14.000Z" objectID="{38FEF76B-56AE-0456-1B54-A2C668C24685}{1}{B0}" alignment="left"><one:T><![CDATA[Beside the drawing]]></one:T></one:OE></one:OEChildren></one:Outline><one:InkDrawing lastModifiedTime="2026-09-13T07:33:14.000Z" objectID="{501D4164-CA81-04D3-1BDC-D2CA0006F088}{1}{B0}"><one:Position x="299.9905395507812" y="119.9905471801758" z="1"/><one:Size width="60.03781127929687" height="60.03779220581055"/><one:Data>AM8CHQSiAaIBAWAqABofi0JwRYR1gL7QRdkE+oeVMvLsF0G4wBa/8yv0et67E4vlXrNInJuhJVq3
3URK/7HHMzCKxQYkOuxMs7nqhWM9UiuaXxU+PBvi60uGbIsRmbacNslhJmh1OQDQGbBACEQZIEEUj
4RiMJSBBE/wFFI0YjBQgLZU9mZ2hkaRkUMggAgB4CIeLiQTMIAPAQAovF4kFkAxVGAEAKXAWH8gln
5jCj7wnvCe8IAh/B8h4Pke8I7wnvCQAUURgBAAAAJBAUURgAAAAASABEgKFNtArpWcU+AsrjC8JNY
6RQo/QCNkARXUNSA7oAMVRgBACj9AIm9GCC70WAqACQEKTAIPUq7GcA5CJYzgBRRGAIAAAAkEBRRG
7AAAAABIAESAJY0kvgp5dSp0WwhnX6NwFCj9AI2QBFdQ1IDugAxVGAEAKP0Aib0YILvRYCoA=</one:Data></one:InkDrawing></one:Page>
corpus/page-copy/cold/read/page-005.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": "Before the picture - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-copy/cold/read/page-005.png created
Binary files /dev/null and b/corpus/page-copy/cold/read/page-005.png differ
corpus/page-copy/cold/read/page-005.xml created+4
...@@ -0,0 +1,4 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{B5A45771-4A39-05F8-1953-4F2F9B2B33A5}{1}{B0}" name="Before the picture" dateTime="2026-09-13T07:33:14.000Z" lastModifiedTime="2026-09-13T07:34:36.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Copier" lastModifiedBy="Copier" creationTime="2026-09-13T07:33:14.000Z" lastModifiedTime="2026-09-13T07:33:14.000Z" objectID="{B5A45771-4A39-05F8-1953-4F2F9B2B33A5}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Before the picture]]></one:T></one:OE></one:Title><one:Outline author="Copier" lastModifiedBy="Copier" lastModifiedTime="2026-09-13T07:33:14.000Z" objectID="{6323EB68-A315-07CD-37C1-E46DAF4BAB42}{1}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="148.3100738525391" height="27.60543251037598"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-13T07:33:14.000Z" lastModifiedTime="2026-09-13T07:33:14.000Z" objectID="{078AE9E1-0253-0D93-182A-750B047E4D60}{1}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:T><![CDATA[Before the picture]]></one:T></one:OE><one:OE creationTime="2026-09-13T07:33:14.000Z" lastModifiedTime="2026-09-13T07:33:14.000Z" objectID="{4DBAC23A-2DBC-0548-3811-E4D1B951886F}{1}{B0}" alignment="left"><one:Image format="png"><one:Data>iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2w
3AAAAAElFTkSuQmCC
4</one:Data></one:Image></one:OE><one:OE creationTime="2026-09-13T07:33:14.000Z" lastModifiedTime="2026-09-13T07:33:14.000Z" objectID="{79D39D0E-8F58-05A6-1C44-0CB6265FFE97}{1}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:T><![CDATA[After the picture]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/page-copy/cold/read/page-006.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": "Before the file - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-copy/cold/read/page-006.png created
Binary files /dev/null and b/corpus/page-copy/cold/read/page-006.png differ
corpus/page-copy/cold/read/page-006.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="{C12C0ECF-DDF8-0A21-11BE-3074429542E0}{1}{B0}" name="Before the file" dateTime="2026-09-13T07:33:14.000Z" lastModifiedTime="2026-09-13T07:34:35.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Copier" lastModifiedBy="Copier" creationTime="2026-09-13T07:33:14.000Z" lastModifiedTime="2026-09-13T07:33:14.000Z" objectID="{C12C0ECF-DDF8-0A21-11BE-3074429542E0}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Before the file]]></one:T></one:OE></one:Title><one:Outline author="Copier" lastModifiedBy="Copier" lastModifiedTime="2026-09-13T07:33:14.000Z" objectID="{A4D24969-1B13-0F34-3174-834AD8FF8832}{1}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="130.4619598388672" height="89.85542297363281"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-13T07:33:14.000Z" lastModifiedTime="2026-09-13T07:33:14.000Z" objectID="{6D8ADE9E-3EE4-03A9-2EC9-0A80C321B126}{1}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:T><![CDATA[Before the file]]></one:T></one:OE><one:OE creationTime="2026-09-13T07:33:14.000Z" lastModifiedTime="2026-09-13T07:33:14.000Z" objectID="{03AD9394-425B-0B9C-0D27-CA384DFFCEBF}{1}{B0}" alignment="left"><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Microsoft\OneNote\14.0\OneNoteOfflineCache_Files\6d823ba5-6b7e-4df3-947b-a1047440dfa2.txt" preferredName="notes 🦀.txt"/></one:OE><one:OE creationTime="2026-09-13T07:33:14.000Z" lastModifiedTime="2026-09-13T07:33:14.000Z" objectID="{A6E026B4-3D17-078E-20A3-50C86A85A2AB}{1}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:T><![CDATA[After the file]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/page-copy/cold/read/page-007.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": "Bullet item - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-copy/cold/read/page-007.png created
Binary files /dev/null and b/corpus/page-copy/cold/read/page-007.png differ
corpus/page-copy/cold/read/page-007.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="{C5E005CB-753E-07E2-0C53-CD40FBB64A63}{1}{B0}" name="Bullet item" dateTime="2026-09-13T07:33:14.000Z" lastModifiedTime="2026-09-13T07:34:36.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Copier" lastModifiedBy="Copier" creationTime="2026-09-13T07:33:14.000Z" lastModifiedTime="2026-09-13T07:33:14.000Z" objectID="{C5E005CB-753E-07E2-0C53-CD40FBB64A63}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Bullet item]]></one:T></one:OE></one:Title><one:Outline author="Copier" lastModifiedBy="Copier" lastModifiedTime="2026-09-13T07:33:15.000Z" objectID="{04550845-DFD8-0D15-199D-14BBB6068FC4}{1}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="174.7353515625" height="93.99401092529296"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-13T07:33:15.000Z" lastModifiedTime="2026-09-13T07:33:15.000Z" objectID="{FADAD1EB-13D4-0679-1470-A27D6E3C4C82}{1}{B0}" alignment="left"><one:List><one:Bullet bullet="3" fontSize="11.0"/></one:List><one:T><![CDATA[Bullet item]]></one:T></one:OE><one:OE creationTime="2026-09-13T07:33:15.000Z" lastModifiedTime="2026-09-13T07:33:15.000Z" objectID="{D1386D56-B57F-01DD-3DCE-AE2F7E41CF7B}{1}{B0}" alignment="left"><one:List><one:Number numberSequence="1" numberFormat="##." fontSize="11.0" font="Calibri" text="I."/></one:List><one:T><![CDATA[First numbered]]></one:T></one:OE><one:OE creationTime="2026-09-13T07:33:15.000Z" lastModifiedTime="2026-09-13T07:33:15.000Z" objectID="{8F5A5D13-ADD9-0514-013E-D1A0CBA1E68F}{1}{B0}" alignment="left"><one:List><one:Number numberSequence="1" numberFormat="##." fontSize="11.0" font="Calibri" text="II."/></one:List><one:T><![CDATA[Second numbered]]></one:T><one:OEChildren><one:OE creationTime="2026-09-13T07:33:15.000Z" lastModifiedTime="2026-09-13T07:33:15.000Z" objectID="{448444D9-663C-0454-150E-544F2D29DAED}{1}{B0}" alignment="left"><one:List><one:Number numberSequence="1" numberFormat="##." fontSize="11.0" font="Calibri" text="I."/></one:List><one:T><![CDATA[Nested numbered]]></one:T></one:OE></one:OEChildren></one:OE><one:OE creationTime="2026-09-13T07:33:15.000Z" lastModifiedTime="2026-09-13T07:33:15.000Z" objectID="{AB4C6543-D5B5-02F2-18CD-1EA3C75D0B5F}{1}{B0}" alignment="left"><one:List><one:Number numberSequence="1" numberFormat="##." fontSize="11.0" font="Calibri" text="III."/></one:List><one:T><![CDATA[Restarted at three]]></one:T><one:OEChildren><one:OE creationTime="2026-09-13T07:33:15.000Z" lastModifiedTime="2026-09-13T07:33:15.000Z" objectID="{1E15DFFF-2CEC-03FA-0FEF-75E2303FB336}{1}{B0}" alignment="left"><one:T><![CDATA[Nested plain]]></one:T></one:OE></one:OEChildren></one:OE><one:OE creationTime="2026-09-13T07:33:15.000Z" lastModifiedTime="2026-09-13T07:33:15.000Z" objectID="{49D667B4-861E-0253-29DB-8BA25CF21B9E}{1}{B0}" alignment="left"><one:T><![CDATA[Plain again]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/page-copy/cold/read/page-008.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": "A notebook created in Rust. - Microsoft OneNote",
10 "class": "Framework::CFrame",
11 "dialog": false
12 }
13}
\ No newline at end of file
corpus/page-copy/cold/read/page-008.png created
Binary files /dev/null and b/corpus/page-copy/cold/read/page-008.png differ
corpus/page-copy/cold/read/page-008.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="{D4C06049-3C91-0190-0C2E-1BB1EBA49EBA}{1}{B0}" name="A notebook created in Rust." dateTime="2026-09-13T07:33:15.000Z" lastModifiedTime="2026-09-13T07:34:36.000Z" pageLevel="1" style="font-family:Calibri;font-size:11.0pt" lang="en-US"><one:QuickStyleDef index="0" name="PageTitle" font="Calibri" fontSize="17.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title showDate="false" showTime="false" lang="en-US"><one:OE author="Copier" lastModifiedBy="Copier" creationTime="2026-09-13T07:33:15.000Z" lastModifiedTime="2026-09-13T07:33:15.000Z" objectID="{D4C06049-3C91-0190-0C2E-1BB1EBA49EBA}{15}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[A notebook created in Rust.]]></one:T></one:OE></one:Title><one:Outline author="Copier" lastModifiedBy="Copier" lastModifiedTime="2026-09-13T07:33:16.000Z" objectID="{F0CA7185-BDBF-066E-0D2C-CAE46E036850}{1}{B0}"><one:Position x="36.0" y="36.0" z="0"/><one:Size width="191.1661224365234" height="13.4277172088623"/><one:Indents><one:Indent level="0" indent="0.0"/></one:Indents><one:OEChildren><one:OE creationTime="2026-09-13T07:33:16.000Z" lastModifiedTime="2026-09-13T07:33:16.000Z" objectID="{F7D0FEC9-71DC-00E3-30D7-07DB00C2623B}{1}{B0}" alignment="left"><one:T><![CDATA[A notebook created in Rust.]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/page-copy/cold/read/payloads.json created+10
...@@ -0,0 +1,10 @@
1[
2 {
3 "sha256": "b20a14e044e33eac39b4665e7d79813b8a3672dbbd27ed9d2e4ac866c13ec4b6",
4 "name": "notes 🦀.txt",
5 "object": "{03AD9394-425B-0B9C-0D27-CA384DFFCEBF}{1}{B0}",
6 "kind": "InsertedFile",
7 "page": "{C12C0ECF-DDF8-0A21-11BE-3074429542E0}{1}{B0}",
8 "bytes": 28
9 }
10]
\ No newline at end of file
corpus/page-copy/cold/run.json created+18
...@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/m4/copy",
3 "expected_pages": 9,
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": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
17 }
18}
corpus/page-copy/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/page-copy/cold/scripts/read.ps1 created+142
...@@ -0,0 +1,142 @@
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 $payloads += @{ page = $id; object = $file.ParentNode.GetAttribute('objectID');
103 kind = $file.LocalName; name = $file.GetAttribute('preferredName');
104 sha256 = $hash; bytes = $bytes.Length }
105 }
106 $index++
107 }
108 [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8)
109 $all = ''
110 $app.GetHierarchy($notebookId, 4, [ref]$all, 1)
111 [xml]$finalTree = $all
112 $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique)
113 if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) {
114 throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.'
115 }
116 [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8)
117 Write-Output "Read $($sections.Count) sections and $($pages.Count) pages."
118} catch {
119 $failure = $_
120 [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
121 throw
122} finally {
123 try {
124 try {
125 if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) }
126 if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) {
127 $app.CloseNotebook($notebookId, $false)
128 }
129 } catch {
130 if ($null -eq $failure) { throw }
131 [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8)
132 }
133 } finally {
134 [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
135 }
136 $app = $null
137 [GC]::Collect()
138 [GC]::WaitForPendingFinalizers()
139 if (-not $UseCurrentCache -and -not $CloneHost) {
140 Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10
141 }
142}
corpus/page-copy/cold/source.json created+20
...@@ -0,0 +1,20 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1604,
5 "sha256": "bf856a7700054971543fb890257cba327dfd35ef6e5438a41483490c4e06407b",
6 "mtime_ns": 1789284797191547350
7 },
8 {
9 "path": "copies.one",
10 "bytes": 91008,
11 "sha256": "8f6c9b66379c4bf6730e281d8363737d88fbe1c521a62fd4d054f735b0505cc7",
12 "mtime_ns": 1789284797187906252
13 },
14 {
15 "path": "expected-count.txt",
16 "bytes": 1,
17 "sha256": "19581e27de7ced00ff1ce50b2047e7a567c76b1cbaebabe5ef03f7c3017bb5b7",
18 "mtime_ns": 1789284797191633183
19 }
20]
corpus/page-copy/cold/teardown.json created+1
...@@ -0,0 +1 @@
1{"absent": true}
crates/notebook/README.md+7
...@@ -347,6 +347,13 @@ With the optional `protected` feature, `Notebook::unlock(path, password)`...@@ -347,6 +347,13 @@ With the optional `protected` feature, `Notebook::unlock(path, password)`
347reads the pages of a `Locked` section for display; nothing is cached or347reads the pages of a `Locked` section for display; nothing is cached or
348written, and a wrong password is `Error::Protected(PasswordMismatch)`.348written, and a wrong password is `Error::Protected(PasswordMismatch)`.
349349
350`Section::import_page(page, author)` copies a page, usually read from another
351section, to the end of this one as a page creation and a save queued like the
352user's own edits, under fresh identities (`Page::copy`); payloads travel with
353the model, so the copy publishes offline later like any save. Content outside
354the model refuses to copy. A move is an import here followed by
355`Section::delete_pages` there.
356
350`Notebook::refresh` rereads the directory on the caller's schedule and reports357`Notebook::refresh` rereads the directory on the caller's schedule and reports
351what another client changed as `Change`s keyed by file identity: a renamed or358what another client changed as `Change`s keyed by file identity: a renamed or
352moved section is `Moved`, not removed and added, and a deleted section moves359moved section is `Moved`, not removed and added, and a deleted section moves
crates/notebook/src/session.rs+49-1
...@@ -5,7 +5,8 @@ use crate::{...@@ -5,7 +5,8 @@ use crate::{
5 ConflictKind, EditStatus, Error, PendingEdit, Remote, Replica, Result, SyncWorker, discover,5 ConflictKind, EditStatus, Error, PendingEdit, Remote, Replica, Result, SyncWorker, discover,
6};6};
7use onestore::{7use onestore::{
8 CommitError, ExGuid, PreparedEdit, RevisionIndex, Store, document::Document, page::Page,8 CommitError, ExGuid, PageCreation, PreparedEdit, RevisionIndex, Store, document::Document,
9 page::Page,
9};10};
10use std::{11use std::{
11 collections::BTreeMap,12 collections::BTreeMap,
...@@ -661,6 +662,53 @@ impl Section {...@@ -661,6 +662,53 @@ impl Section {
661 Ok(Page::from_space(&Document::parse(&index)?, space)?)662 Ok(Page::from_space(&Document::parse(&index)?, space)?)
662 }663 }
663664
665 /// Copies a page (usually from another section) to the end of this section as a
666 /// creation and a save queued like the user's own edits, under fresh identities.
667 /// Returns the new page's space. Content outside the model refuses to copy.
668 pub fn import_page(&self, page: &Page, author: &str) -> Result<ExGuid> {
669 let copy = page.copy()?;
670 let creation = PageCreation::new(None, Some(&page.title), author)?;
671 self.replica
672 .create_page(&self.replica.snapshot()?, &creation)?;
673 let space = creation.space();
674 loop {
675 // The worker may publish the creation, and so replace the working image,
676 // between reading it and saving against it.
677 let source = self.replica.snapshot()?;
678 let mut after = Page::from_space(
679 &Document::parse(&RevisionIndex::parse(&Store::parse(&source)?)?)?,
680 space,
681 )?;
682 after
683 .objects
684 .retain(|object| matches!(object, onestore::page::PageObject::Title(_)));
685 after.objects.extend(
686 copy.objects
687 .iter()
688 .filter(|object| !matches!(object, onestore::page::PageObject::Title(_)))
689 .cloned(),
690 );
691 after.definitions = copy.definitions.clone();
692 match self.replica.save(&source, space, &after, author) {
693 Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy => continue,
694 result => result?,
695 };
696 break;
697 }
698 self.wake();
699 Ok(space)
700 }
701
702 /// Removes pages permanently, queued like the user's own edits (a move across
703 /// sections is `import_page` there, then this here).
704 pub fn delete_pages(&self, pages: &[ExGuid]) -> Result<Option<u64>> {
705 let id = self
706 .replica
707 .delete_pages(&self.replica.snapshot()?, pages)?;
708 self.wake();
709 Ok(id)
710 }
711
664 /// Saves an edited page. `before` is the model the edit started from; a stored page712 /// Saves an edited page. `before` is the model the edit started from; a stored page
665 /// that differs from it means the section changed underneath the editor.713 /// that differs from it means the section changed underneath the editor.
666 pub fn save(&self, space: ExGuid, before: &Page, after: &Page, author: &str) -> Result<Save> {714 pub fn save(&self, space: ExGuid, before: &Page, after: &Page, author: &str) -> Result<Save> {
crates/notebook/tests/copy.rs created+242
...@@ -0,0 +1,242 @@
1use notebook::{
2 EditStatus,
3 session::{Event, Section},
4};
5use onestore::{
6 RevisionIndex, Store,
7 document::Document,
8 page::{Page, PageObject},
9};
10use std::{
11 collections::BTreeMap,
12 io::Write,
13 path::Path,
14 time::{Duration, Instant},
15};
16
17/// Pages the writer authored for other rows: text with an attachment and its icon, a
18/// table, an inserted picture, a page-level ink drawing, tags, lists, equations
19/// and paragraph formatting.
20const SOURCES: &[&str] = &[
21 "attachment-edit/icon/candidate/files.one",
22 "table-edit/created/candidate/tables.one",
23 "picture-edit/inserted/candidate/pictures.one",
24 "ink-edit/drawing/candidate/ink.one",
25 "tag-edit/candidate/tags.one",
26 "list-edit/candidate/lists.one",
27 "math-edit/written/candidate/math.one",
28 "paragraph-format/candidate/synthetic.one",
29];
30
31fn pages(bytes: &[u8]) -> Vec<Page> {
32 let store = Store::parse(bytes).unwrap();
33 let index = RevisionIndex::parse(&store).unwrap();
34 let document = Document::parse(&index).unwrap();
35 document
36 .pages()
37 .unwrap()
38 .into_iter()
39 .map(|(space, _)| Page::from_space(&document, space).unwrap())
40 .collect()
41}
42
43/// Everything but identities and the title object: what a copy must preserve. Identities
44/// are numbered by first appearance in the content, so definitions compare by use.
45fn shape(page: &Page) -> String {
46 let mut page = page.clone();
47 flatten(&mut page);
48 let mut value = serde_json::to_value(&page).unwrap();
49 let mut names: BTreeMap<String, String> = BTreeMap::new();
50 fn name(names: &mut BTreeMap<String, String>, text: &str) -> String {
51 let next = format!("id{}", names.len());
52 names.entry(text.to_owned()).or_insert(next).clone()
53 }
54 fn scrub(value: &mut serde_json::Value, names: &mut BTreeMap<String, String>) {
55 match value {
56 serde_json::Value::String(text) if text.parse::<onestore::ExGuid>().is_ok() => {
57 *text = name(names, text);
58 }
59 serde_json::Value::Array(items) => {
60 for item in items {
61 scrub(item, names);
62 }
63 }
64 serde_json::Value::Object(fields) => {
65 // The writer's canonical formatting: an unset flag is false and unset
66 // spacing is zero.
67 fields.retain(|_, value| {
68 !value.is_null()
69 && *value != serde_json::Value::Bool(false)
70 && value.as_f64() != Some(0.0)
71 });
72 let keys: Vec<String> = fields.keys().cloned().collect();
73 for key in keys {
74 let mut item = fields.remove(&key).unwrap();
75 scrub(&mut item, names);
76 let key = if key.parse::<onestore::ExGuid>().is_ok() {
77 name(names, &key)
78 } else {
79 key
80 };
81 fields.insert(key, item);
82 }
83 }
84 _ => {}
85 }
86 }
87 let object = value.as_object_mut().unwrap();
88 object.remove("identity");
89 object.remove("created");
90 object.remove("margin_origin");
91 let objects = object["objects"].as_array_mut().unwrap();
92 objects.retain(|object| object.get("Title").is_none());
93 scrub(&mut object["objects"], &mut names);
94 // The destination page brings its own title style.
95 object["definitions"]
96 .as_object_mut()
97 .unwrap()
98 .retain(|_, definition| definition["kind"]["type"] != "Style");
99 scrub(&mut object["definitions"], &mut names);
100 value.to_string()
101}
102
103/// Moves paragraph-level formatting into each span, where the reader resolves it: the
104/// writer keeps a copied style's formatting on the paragraph or the runs as it sees fit.
105fn flatten(page: &mut Page) {
106 fn paragraphs(list: &mut [onestore::page::PageParagraph]) {
107 for paragraph in list {
108 let format = std::mem::take(&mut paragraph.format);
109 match &mut paragraph.content {
110 onestore::page::ParagraphContent::Text(text) => {
111 let mut at = 0;
112 let runs: Vec<(String, onestore::document::Format)> = text
113 .text
114 .spans()
115 .iter()
116 .map(|span| {
117 let piece = text.text.text()[at..span.end].to_owned();
118 at = span.end;
119 (piece, span.format.inherit(&format))
120 })
121 .collect();
122 text.text = onestore::page::Paragraph::from_runs(runs);
123 }
124 onestore::page::ParagraphContent::Table(table) => {
125 for row in &mut table.rows {
126 for cell in &mut row.cells {
127 paragraphs(&mut cell.paragraphs);
128 }
129 }
130 }
131 _ => {}
132 }
133 }
134 }
135 for object in &mut page.objects {
136 if let PageObject::Outline(outline) = object {
137 paragraphs(&mut outline.paragraphs);
138 }
139 }
140}
141
142/// `NOTEBOOK_COPY_EXPORT` names a new directory receiving the destination section for a
143/// cold reopen.
144#[test]
145fn pages_copy_into_another_section_with_their_content_and_fresh_identities() {
146 let temporary = tempfile::tempdir().unwrap();
147 let file = temporary.path().join("copies.one");
148 let cache = temporary.path().join("cache.sqlite");
149 std::fs::write(
150 &file,
151 onestore::create_section("copies.one", "Destination", "Author").unwrap(),
152 )
153 .unwrap();
154 let section = Section::open(&file, &cache, || {}).unwrap();
155 let mut expected = Vec::new();
156 for source in SOURCES {
157 let bytes = std::fs::read(
158 Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus")).join(source),
159 )
160 .unwrap();
161 for page in pages(&bytes) {
162 if page
163 .objects
164 .iter()
165 .any(|object| matches!(object, PageObject::Unsupported(_)))
166 {
167 continue;
168 }
169 let copy = page.copy().unwrap();
170 assert_ne!(copy.identity, page.identity);
171 let space = section
172 .import_page(&page, "Copier")
173 .unwrap_or_else(|e| panic!("{source} {}: {e}", page.title));
174 expected.push((space, page.title.clone(), shape(&copy)));
175 }
176 }
177 assert!(expected.len() >= 8, "{} pages", expected.len());
178 let deadline = Instant::now() + Duration::from_secs(120);
179 while section
180 .queue()
181 .unwrap()
182 .iter()
183 .any(|edit| !matches!(edit.status, EditStatus::Published { .. }))
184 || !section.queue().unwrap().is_empty()
185 {
186 for event in section.events() {
187 if let Event::Failed(message) = event {
188 panic!("{message}");
189 }
190 }
191 assert!(
192 Instant::now() < deadline,
193 "publication did not finish: {:?}",
194 section.queue().unwrap()
195 );
196 std::thread::sleep(Duration::from_millis(50));
197 }
198 let stored = std::fs::read(&file).unwrap();
199 let written = pages(&stored);
200 assert_eq!(written.len(), expected.len() + 1);
201 for ((space, title, expected), page) in expected.iter().zip(&written[1..]) {
202 let _ = space;
203 assert_eq!(&page.title, title);
204 let stored = shape(page);
205 if &stored != expected {
206 let dump = std::env::temp_dir().join("copy-mismatch");
207 std::fs::create_dir_all(&dump).unwrap();
208 std::fs::write(dump.join("expected.json"), expected).unwrap();
209 std::fs::write(dump.join("stored.json"), &stored).unwrap();
210 panic!("{title}: copy differs, see {}", dump.display());
211 }
212 }
213 section.delete_pages(&[expected.last().unwrap().0]).unwrap();
214 let deadline = Instant::now() + Duration::from_secs(60);
215 while !section.queue().unwrap().is_empty() {
216 assert!(Instant::now() < deadline);
217 std::thread::sleep(Duration::from_millis(50));
218 }
219 assert_eq!(pages(&std::fs::read(&file).unwrap()).len(), expected.len());
220 section.close().unwrap();
221 if let Some(directory) = std::env::var_os("NOTEBOOK_COPY_EXPORT") {
222 let directory = std::path::PathBuf::from(directory);
223 std::fs::create_dir_all(&directory).unwrap();
224 let bytes = std::fs::read(&file).unwrap();
225 std::fs::File::create_new(directory.join("copies.one"))
226 .unwrap()
227 .write_all(&bytes)
228 .unwrap();
229 let file_id = Store::parse(&bytes).unwrap().header.file_id;
230 std::fs::write(
231 directory.join("Open Notebook.onetoc2"),
232 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("copies.one", file_id)])
233 .unwrap(),
234 )
235 .unwrap();
236 std::fs::write(
237 directory.join("expected-count.txt"),
238 expected.len().to_string(),
239 )
240 .unwrap();
241 }
242}
crates/onestore/Cargo.toml+3-3
...@@ -5,14 +5,15 @@ edition = "2024"...@@ -5,14 +5,15 @@ edition = "2024"
5publish = false5publish = false
66
7[features]7[features]
8protected = ["dep:aes", "dep:base64", "dep:cbc", "dep:roxmltree", "dep:sha1", "dep:subtle", "dep:zeroize"]8protected = ["dep:aes", "dep:cbc", "dep:roxmltree", "dep:sha1", "dep:subtle", "dep:zeroize"]
99
10[dependencies]10[dependencies]
11base64 = "0.22.1"
12serde_json = "1.0.151"
11md5 = "0.8.1"13md5 = "0.8.1"
12getrandom = "0.4.3"14getrandom = "0.4.3"
13serde = { version = "1.0.229", features = ["derive"] }15serde = { version = "1.0.229", features = ["derive"] }
14aes = { version = "0.9.3", features = ["zeroize"], optional = true }16aes = { version = "0.9.3", features = ["zeroize"], optional = true }
15base64 = { version = "0.22.1", optional = true }
16cbc = { version = "0.2.1", features = ["zeroize"], optional = true }17cbc = { version = "0.2.1", features = ["zeroize"], optional = true }
17roxmltree = { version = "0.21.1", optional = true }18roxmltree = { version = "0.21.1", optional = true }
18sha1 = { version = "0.11.0", features = ["zeroize"], optional = true }19sha1 = { version = "0.11.0", features = ["zeroize"], optional = true }
...@@ -23,4 +24,3 @@ zeroize = { version = "1.9.0", optional = true }...@@ -23,4 +24,3 @@ zeroize = { version = "1.9.0", optional = true }
23nix = { version = "0.31.3", default-features = false, features = ["fs"] }24nix = { version = "0.31.3", default-features = false, features = ["fs"] }
2425
25[dev-dependencies]26[dev-dependencies]
26serde_json = "1.0.151"
crates/onestore/README.md+2-1
...@@ -43,6 +43,7 @@ harness also accepts `--client-profile release`....@@ -43,6 +43,7 @@ harness also accepts `--client-profile release`.
43| `Object::file_reference`, `Store::file_data` | Identify internal/external payloads and read internal payload bytes |43| `Object::file_reference`, `Store::file_data` | Identify internal/external payloads and read internal payload bytes |
44| `document::Document`, `Revision::text_runs` | Interpret document objects and inherited text formatting while retaining unknown properties and revision identities |44| `document::Document`, `Revision::text_runs` | Interpret document objects and inherited text formatting while retaining unknown properties and revision identities |
45| `Document::active`, `Document::pages_in`, `Revision::parents`, `RevisionIndex::active` | Resolve the active revision, the pages of a space and parent links without repeating the lookups |45| `Document::active`, `Document::pages_in`, `Revision::parents`, `RevisionIndex::active` | Resolve the active revision, the pages of a space and parent links without repeating the lookups |
46| `Page::copy` | The page's content under fresh identities for copying into another section, definitions and payloads included |
46| `page::link::internal_link`, `page::link::parse_internal_link` | Build and read the `onenote:#…` URLs OneNote stores for links to sections, pages and paragraphs, by identity |47| `page::link::internal_link`, `page::link::parse_internal_link` | Build and read the `onenote:#…` URLs OneNote stores for links to sections, pages and paragraphs, by identity |
47| `page::Page`, `page::Paragraph`, `page::Ink`, `page::Math` | Build an editable page model (title, outlines, paragraphs with coalesced text spans, tables, images, attachments, ink drawings and handwriting decoded to stroke polylines in page points) with stored identities; equations parse from their linear text and run data into a tree that renders the MathML OneNote exports; content outside the model is retained as `Unsupported` |48| `page::Page`, `page::Paragraph`, `page::Ink`, `page::Math` | Build an editable page model (title, outlines, paragraphs with coalesced text spans, tables, images, attachments, ink drawings and handwriting decoded to stroke polylines in page points) with stored identities; equations parse from their linear text and run data into a tree that renders the MathML OneNote exports; content outside the model is retained as `Unsupported` |
48| `protected::UnlockedSection` (optional feature) | Own decoded buffers for explicit known-password inspection; clear those buffers on drop; derived document strings/exports remain caller-owned |49| `protected::UnlockedSection` (optional feature) | Own decoded buffers for explicit known-password inspection; clear those buffers on drop; derived document strings/exports remain caller-owned |
...@@ -60,7 +61,7 @@ harness also accepts `--client-profile release`....@@ -60,7 +61,7 @@ harness also accepts `--client-profile release`.
60| `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules |61| `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules |
61| `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs |62| `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs |
62| `OutlineEdit`, `PreparedEdit::outline` | Change ordinary outline position/width or a paragraph's saved expansion default, preserving identities and content |63| `OutlineEdit`, `PreparedEdit::outline` | Change ordinary outline position/width or a paragraph's saved expansion default, preserving identities and content |
63| `PreparedEdit::page` | Publish an edited `page::Page` as one revision per changed space: text, character and paragraph formatting, hyperlinks (external and, via `page::link`, to pages, paragraphs and sections), bullets and numbering, note tags, table rows, columns, cell shading and indents, nested tables, inserted pictures (in paragraphs or on the page) and attachments, picture position, size and description, a stored attachment's shown name, source path and icon size, ink drawings and handwriting (strokes added and erased), equations (built from `page::Math` trees), paragraph insertion/split/join/move/deletion, outline insertion/deletion/position/width and saved collapse state, lowered onto the typed writers with the model's identities |64| `PreparedEdit::page` | Publish an edited `page::Page` as one revision per changed space: text, character and paragraph formatting, hyperlinks (external and, via `page::link`, to pages, paragraphs and sections), bullets and numbering, note tags, table rows, columns, cell shading and indents, nested tables, inserted pictures (in paragraphs or on the page) and attachments, picture position, size and description, a stored attachment's shown name, source path and icon size, paragraph styles on new paragraphs, ink drawings and handwriting (strokes added and erased), equations (built from `page::Math` trees), paragraph insertion/split/join/move/deletion, outline insertion/deletion/position/width and saved collapse state, lowered onto the typed writers with the model's identities |
64| `TreeEdit`, `PreparedEdit::tree` | Move or delete a subtree on one page, normalize surviving containers, and replace an emptied table cell's paragraph atomically |65| `TreeEdit`, `PreparedEdit::tree` | Move or delete a subtree on one page, normalize surviving containers, and replace an emptied table cell's paragraph atomically |
65| `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter |66| `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter |
66| `read_file` | Read a snapshot under whole-file exclusion |67| `read_file` | Read a snapshot under whole-file exclusion |
crates/onestore/src/page/mod.rs+96-3
...@@ -18,6 +18,38 @@ pub mod text;...@@ -18,6 +18,38 @@ pub mod text;
18pub use ink::{Ink, InkStroke};18pub use ink::{Ink, InkStroke};
19pub use math::Math;19pub use math::Math;
20pub(crate) mod write;20pub(crate) mod write;
21
22/// Picture and attachment payloads travel with the model (queued intents replay them),
23/// as base64 text.
24mod payload {
25 use base64::Engine;
26 use std::sync::Arc;
27
28 pub fn serialize<S: serde::Serializer>(
29 bytes: &Option<Arc<[u8]>>,
30 serializer: S,
31 ) -> Result<S::Ok, S::Error> {
32 match bytes {
33 Some(bytes) => {
34 serializer.serialize_some(&base64::engine::general_purpose::STANDARD.encode(bytes))
35 }
36 None => serializer.serialize_none(),
37 }
38 }
39
40 pub fn deserialize<'de, D: serde::Deserializer<'de>>(
41 deserializer: D,
42 ) -> Result<Option<Arc<[u8]>>, D::Error> {
43 let text: Option<String> = serde::Deserialize::deserialize(deserializer)?;
44 text.map(|text| {
45 base64::engine::general_purpose::STANDARD
46 .decode(text)
47 .map(Arc::from)
48 .map_err(serde::de::Error::custom)
49 })
50 .transpose()
51 }
52}
21pub use text::Paragraph;53pub use text::Paragraph;
2254
23/// The role of a title-outline paragraph that displays the page's creation date or time.55/// The role of a title-outline paragraph that displays the page's creation date or time.
...@@ -140,9 +172,9 @@ pub struct Attachment {...@@ -140,9 +172,9 @@ pub struct Attachment {
140 pub source_path: Option<String>,172 pub source_path: Option<String>,
141 /// The icon OneNote rendered for the file, when present.173 /// The icon OneNote rendered for the file, when present.
142 pub size: Option<[f32; 2]>,174 pub size: Option<[f32; 2]>,
143 #[serde(skip)]175 #[serde(with = "payload")]
144 pub bytes: Option<Arc<[u8]>>,176 pub bytes: Option<Arc<[u8]>>,
145 #[serde(skip)]177 #[serde(with = "payload")]
146 pub preview: Option<Arc<[u8]>>,178 pub preview: Option<Arc<[u8]>>,
147}179}
148180
...@@ -245,7 +277,7 @@ pub struct Image {...@@ -245,7 +277,7 @@ pub struct Image {
245 pub layout: Layout,277 pub layout: Layout,
246 /// Displayed picture width and height in points.278 /// Displayed picture width and height in points.
247 pub size: Option<[f32; 2]>,279 pub size: Option<[f32; 2]>,
248 #[serde(skip)]280 #[serde(with = "payload")]
249 pub bytes: Option<Arc<[u8]>>,281 pub bytes: Option<Arc<[u8]>>,
250 pub alt: Option<String>,282 pub alt: Option<String>,
251 pub background: bool,283 pub background: bool,
...@@ -331,6 +363,67 @@ impl Page {...@@ -331,6 +363,67 @@ impl Page {
331 Self::from_revision(revision, id)363 Self::from_revision(revision, id)
332 }364 }
333365
366 /// The page's content under fresh identities, for a copy into this or another section:
367 /// OneNote gives a copied page and every object on it new identities, and stored
368 /// links keep naming the original. Content outside the model has no copy, so an
369 /// `Unsupported` object is an error.
370 pub fn copy(&self) -> Result<Self, Error> {
371 let invalid = |message| Error { offset: 0, message };
372 let mut value =
373 serde_json::to_value(self).map_err(|_| invalid("Page is not serializable"))?;
374 let mut fresh: BTreeMap<String, String> = BTreeMap::new();
375 fn walk(
376 value: &mut serde_json::Value,
377 fresh: &mut BTreeMap<String, String>,
378 ) -> Result<(), Error> {
379 match value {
380 serde_json::Value::String(text) if text.starts_with('{') => {
381 if text.parse::<ExGuid>().is_ok() {
382 if !fresh.contains_key(text) {
383 fresh.insert(text.clone(), text::new_id()?.to_string());
384 }
385 *text = fresh[text].clone();
386 }
387 }
388 serde_json::Value::Array(items) => {
389 for item in items {
390 walk(item, fresh)?;
391 }
392 }
393 serde_json::Value::Object(fields) => {
394 if fields.contains_key("Unsupported") {
395 return Err(Error {
396 offset: 0,
397 message: "Content outside the page model cannot be copied",
398 });
399 }
400 let keys: Vec<String> = fields.keys().cloned().collect();
401 for key in keys {
402 let mut item = fields.remove(&key).unwrap();
403 walk(&mut item, fresh)?;
404 let key = match key.parse::<ExGuid>() {
405 Ok(_) => {
406 if !fresh.contains_key(&key) {
407 fresh.insert(key.clone(), text::new_id()?.to_string());
408 }
409 fresh[&key].clone()
410 }
411 Err(_) => key,
412 };
413 fields.insert(key, item);
414 }
415 }
416 _ => {}
417 }
418 Ok(())
419 }
420 walk(&mut value, &mut fresh)?;
421 let mut copy: Self =
422 serde_json::from_value(value).map_err(|_| invalid("Page copy does not deserialize"))?;
423 copy.identity = Some(crate::write::fresh_guid()?);
424 Ok(copy)
425 }
426
334 /// The notebook-management identity of the page in an active page revision, which427 /// The notebook-management identity of the page in an active page revision, which
335 /// internal links name.428 /// internal links name.
336 pub fn identity_of(revision: &Revision<'_>) -> Option<[u8; 16]> {429 pub fn identity_of(revision: &Revision<'_>) -> Option<[u8; 16]> {
crates/onestore/src/page/write.rs+136-8
...@@ -496,7 +496,10 @@ impl Lowering<'_> {...@@ -496,7 +496,10 @@ impl Lowering<'_> {
496 return Err(invalid("Page creation time and margins cannot be edited"));496 return Err(invalid("Page creation time and margins cannot be edited"));
497 }497 }
498 for (id, definition) in &after.definitions {498 for (id, definition) in &after.definitions {
499 if before.definitions.get(id) != Some(definition)499 if before
500 .definitions
501 .get(id)
502 .is_some_and(|stored| stored != definition)
500 && !matches!(503 && !matches!(
501 definition.kind,504 definition.kind,
502 Kind::List { .. } | Kind::TagDefinition { .. }505 Kind::List { .. } | Kind::TagDefinition { .. }
...@@ -540,6 +543,7 @@ impl Lowering<'_> {...@@ -540,6 +543,7 @@ impl Lowering<'_> {
540 self.delete(&old, &new, &consumed)?;543 self.delete(&old, &new, &consumed)?;
541 self.edit_equations(&new)?;544 self.edit_equations(&new)?;
542 self.edit_text(&new)?;545 self.edit_text(&new)?;
546 self.edit_paragraph_styles(&old, &new, after)?;
543 self.edit_lists(after, &new)?;547 self.edit_lists(after, &new)?;
544 self.edit_tags(after, &new)?;548 self.edit_tags(after, &new)?;
545 self.edit_paragraph_formatting(&new)?;549 self.edit_paragraph_formatting(&new)?;
...@@ -685,10 +689,23 @@ impl Lowering<'_> {...@@ -685,10 +689,23 @@ impl Lowering<'_> {
685 let Some(first) = new.children[id].first().copied() else {689 let Some(first) = new.children[id].first().copied() else {
686 return Err(invalid("A new outline needs a paragraph"));690 return Err(invalid("A new outline needs a paragraph"));
687 };691 };
688 let text = new692 // A new outline starts as one text paragraph; other content replaces it in
689 .text(first)693 // its own pass, as for a paragraph inserted into an existing outline.
690 .ok_or_else(|| invalid("A new outline starts with a text paragraph"))?;694 let (text, text_id) = match &new.paragraphs[&first].content {
691 let insertion = Insertion::outline(self.page, x, y, text.text.text(), self.author)?;695 ParagraphContent::Text(text) if text.date_field.is_none() => {
696 (text.text.text(), Some(text.id))
697 }
698 ParagraphContent::Table(_)
699 | ParagraphContent::Image(_)
700 | ParagraphContent::Attachment(_)
701 | ParagraphContent::Ink(_) => ("", None),
702 _ => {
703 return Err(invalid(
704 "A new outline starts with a paragraph the writer builds",
705 ));
706 }
707 };
708 let insertion = Insertion::outline(self.page, x, y, text, self.author)?;
692 let space = self.space;709 let space = self.space;
693 self.apply(|image| insertion.apply(image, space))?;710 self.apply(|image| insertion.apply(image, space))?;
694 let outline_id = insertion.object();711 let outline_id = insertion.object();
...@@ -700,7 +717,9 @@ impl Lowering<'_> {...@@ -700,7 +717,9 @@ impl Lowering<'_> {
700 n: 3,717 n: 3,
701 },718 },
702 );719 );
703 self.alias.insert(text.id, insertion.text_object());720 if let Some(text_id) = text_id {
721 self.alias.insert(text_id, insertion.text_object());
722 }
704 placed.insert(*id, vec![first]);723 placed.insert(*id, vec![first]);
705 placed.insert(first, Vec::new());724 placed.insert(first, Vec::new());
706 page_order.push(*id);725 page_order.push(*id);
...@@ -1428,9 +1447,16 @@ impl Lowering<'_> {...@@ -1428,9 +1447,16 @@ impl Lowering<'_> {
1428 let anchor = next.map(|n| self.id(n));1447 let anchor = next.map(|n| self.id(n));
1429 if !old.paragraphs.contains_key(id) && !self.alias.contains_key(id) {1448 if !old.paragraphs.contains_key(id) && !self.alias.contains_key(id) {
1430 let paragraph = new.paragraphs[id];1449 let paragraph = new.paragraphs[id];
1431 if paragraph.style.is_some() {1450 // A styled paragraph appended to a stored outline is a split whose
1451 // precondition failed under a merge; it stays for review. A new outline
1452 // takes styled paragraphs as built (a copied page).
1453 let mut root = *container;
1454 while let Some(parent) = new.container.get(&root) {
1455 root = *parent;
1456 }
1457 if paragraph.style.is_some() && old.outlines.contains_key(&root) {
1432 return Err(invalid(1458 return Err(invalid(
1433 "New paragraphs contain plain text without fields or styles",1459 "New paragraphs in a stored outline contain plain text without styles",
1434 ));1460 ));
1435 }1461 }
1436 // A new table starts as an empty text paragraph whose content the1462 // A new table starts as an empty text paragraph whose content the
...@@ -2285,6 +2311,93 @@ impl Lowering<'_> {...@@ -2285,6 +2311,93 @@ impl Lowering<'_> {
2285 Ok(())2311 Ok(())
2286 }2312 }
22872313
2314 /// A new paragraph, or one whose style changed, references its paragraph style from
2315 /// its text object; a style the page has not stored yet is created from its
2316 /// definition, as OneNote keeps quick styles.
2317 fn edit_paragraph_styles(
2318 &mut self,
2319 old: &View<'_>,
2320 new: &View<'_>,
2321 after: &Page,
2322 ) -> Result<(), Error> {
2323 let current = self.current()?;
2324 for (id, paragraph) in &new.paragraphs {
2325 let Some(definition) = paragraph.style else {
2326 continue;
2327 };
2328 if old
2329 .paragraphs
2330 .get(id)
2331 .is_some_and(|previous| previous.style == paragraph.style)
2332 {
2333 continue;
2334 }
2335 let Some(text) = paragraph.text() else {
2336 return Err(invalid("Only text paragraphs take a paragraph style"));
2337 };
2338 let known = current.definitions.contains_key(&definition)
2339 || self.alias.contains_key(&definition);
2340 let style_id = if known {
2341 self.id(definition)
2342 } else {
2343 let allocated = ExGuid {
2344 guid: crate::write::fresh_guid()?,
2345 n: 1,
2346 };
2347 self.alias.insert(definition, allocated);
2348 allocated
2349 };
2350 let values = if known {
2351 None
2352 } else {
2353 let Some(model) = after.definitions.get(&definition) else {
2354 return Err(invalid("A paragraph references a missing style definition"));
2355 };
2356 let Kind::Style { name } = &model.kind else {
2357 return Err(invalid("A paragraph style must be a style definition"));
2358 };
2359 let mut values = style_values(&model.format);
2360 if let Some(name) = name {
2361 values.push((0x1c00345a, crate::create::string(name)));
2362 }
2363 if let Some(alignment) = model.format.alignment {
2364 values.push((0x0c003477, vec![alignment]));
2365 }
2366 for (property, value) in [
2367 (0x1400342e, model.format.space_before),
2368 (0x1400342f, model.format.space_after),
2369 (0x14003430, model.format.line_spacing),
2370 ] {
2371 if let Some(points) = value {
2372 values.push((property, (points / 36.0).to_le_bytes().to_vec()));
2373 }
2374 }
2375 Some(values)
2376 };
2377 let (space, object) = (self.space, self.id(text.id));
2378 self.apply(|image| {
2379 crate::write::write_revision(image, space, |raw| {
2380 let mut changed = BTreeMap::new();
2381 if let Some(values) = &values {
2382 let mut node = PropertyObject {
2383 jcid: 0x12004d,
2384 bytes: crate::create::properties(values)?,
2385 global_ids: std::sync::Arc::new(BTreeMap::from([(0, style_id.guid)])),
2386 };
2387 node.reference(style_id)?;
2388 changed.insert(style_id, node);
2389 }
2390 let mut target = PropertyObject::from_object(&raw.objects[&object])?;
2391 let reference = target.reference(style_id)?;
2392 target.set(&[(0x2000342c, &reference)])?;
2393 changed.insert(object, target);
2394 Ok(changed)
2395 })
2396 })?;
2397 }
2398 Ok(())
2399 }
2400
2288 fn edit_paragraph_formatting(&mut self, new: &View<'_>) -> Result<(), Error> {2401 fn edit_paragraph_formatting(&mut self, new: &View<'_>) -> Result<(), Error> {
2289 let current = self.current()?;2402 let current = self.current()?;
2290 let current = View::new(&current)?;2403 let current = View::new(&current)?;
...@@ -2492,6 +2605,21 @@ impl Lowering<'_> {...@@ -2492,6 +2605,21 @@ impl Lowering<'_> {
2492 .get(&self.id(*id))2605 .get(&self.id(*id))
2493 .ok_or_else(|| invalid("An outline is missing after text edits"))?;2606 .ok_or_else(|| invalid("An outline is missing after text edits"))?;
2494 let (space, object) = (self.space, self.id(*id));2607 let (space, object) = (self.space, self.id(*id));
2608 // A new outline takes the model's indentation table (a copied outline keeps
2609 // its levels' offsets); a stored table stays as it is.
2610 if !old.outlines.contains_key(id)
2611 && !outline.indents.is_empty()
2612 && outline.indents != stored.indents
2613 {
2614 let indents = measurement_bytes(&outline.indents, 4)?;
2615 self.apply(|current| {
2616 crate::write::write_revision(current, space, |raw| {
2617 let mut node = PropertyObject::from_object(&raw.objects[&object])?;
2618 node.set(&[(0x1c001c12, &indents)])?;
2619 Ok(BTreeMap::from([(object, node)]))
2620 })
2621 })?;
2622 }
2495 if (outline.layout.x, outline.layout.y) != (stored.layout.x, stored.layout.y) {2623 if (outline.layout.x, outline.layout.y) != (stored.layout.x, stored.layout.y) {
2496 let (Some(x), Some(y)) = (outline.layout.x, outline.layout.y) else {2624 let (Some(x), Some(y)) = (outline.layout.x, outline.layout.y) else {
2497 return Err(invalid("An outline position needs both coordinates"));2625 return Err(invalid("An outline position needs both coordinates"));
tools/test_page_copy.py created+34
...@@ -0,0 +1,34 @@
1from pathlib import Path
2import runpy
3import shutil
4from tempfile import TemporaryDirectory
5import unittest
6import xml.etree.ElementTree as ET
7
8from native_xml import ns
9
10ROOT = Path(__file__).resolve().parent.parent
11FIXTURE = ROOT / 'corpus/page-copy'
12compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
13
14
15class PageCopyTest(unittest.TestCase):
16 def test_pages_copied_between_sections_read_natively(self):
17 with TemporaryDirectory() as temporary:
18 read = Path(temporary) / 'read'
19 shutil.copytree(FIXTURE / 'cold/read', read)
20 compare(FIXTURE / 'candidate', read)
21 pages = [ET.parse(path).getroot() for path in sorted((FIXTURE / 'cold/read').glob('page-*.xml'))]
22 self.assertEqual(len(pages), int((FIXTURE / 'candidate/expected-count.txt').read_text()))
23 kinds = {tag for page in pages for tag in {node.tag.rsplit('}', 1)[-1] for node in page.iter()}}
24 self.assertTrue({'InsertedFile', 'Image', 'Table', 'InkDrawing', 'Tag', 'List', 'Bullet', 'Number'} <= kinds, kinds)
25 hierarchy = ET.parse(FIXTURE / 'cold/read/hierarchy.xml').getroot()
26 titles = [page.get('name') for page in hierarchy.iter('{%s}Page' % ns['one'])]
27 self.assertEqual(titles[0], 'Destination')
28 self.assertNotIn('Paragraph formatting', titles)
29 self.assertEqual(len(set(titles)), len(titles))
30 self.assertEqual(len(set(titles)), len(titles))
31
32
33if __name__ == '__main__':
34 unittest.main()