authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-11 04:01:57-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-12 17:00:32-07:00
logb6b6e903d65a5b876c5c009ba390df25d9842040
treec287ee53d65a80bb47166da143fb560ecb8379e1
parentcffb5ce4bca95f8c242b5513d981a708206cee41
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: write paragraph alignment, direction and spacing through the page model

The page writer lowers paragraph-level formatting from the model: alignment, right-to-left direction and the space before, after and between lines. A value applies when every run of the paragraph carries it and the stored paragraph differs; alignment also refreshes the layout alignment bits, and ancestors take the modification time. Alignment outside left, centre and right, negative, non-finite or out-of-range spacing, runs that disagree on a paragraph value, and generated title fields are refused before any write. corpus/paragraph-format holds a native fixture of six paragraphs (defaults, explicit zeros, centred spacing, right-aligned RTL, an inherited named style and the same style overridden), the writer's candidate that centres every paragraph with 0/2/16 pt spacing, and a fresh OneNote 2010 read of both. The cold read shows each paragraph centred with the written spacing, the zero space before omitted, RTL and quick styles retained and the bold/italic runs unchanged; tools/test_paragraph_format.py checks this without a VM, and the notebook suite covers reopen, rebase against a remote text edit and a lost publication reply for each attribute. Assisted-by: gpt-6-astra Assisted-by: claude-fable-5.1

33 files changed, 677 insertions(+), 11 deletions(-)

corpus/paragraph-format/README.md created+29
......@@ -0,0 +1,29 @@
1# Native paragraph formatting controls
2
3`tools/native/paragraph-formatting.ps1` authored one page, "Paragraph
4formatting", with six paragraphs of mixed bold/italic/plain runs: native
5defaults, explicit zero values, centred spacing, right-aligned RTL, a named
6style (`h1`, Georgia 18 bold with spacing) inherited, and the same style with
7paragraph overrides. `before/` holds the authored notebook and its native read
8(`before/read`, captured with `--expected-pages 2 --collect-notebook`;
9`before-capture/` retains that run's records). OneNote reports the style's
10inherited `spaceBefore="8"`/`spaceAfter="4"` as 288/144 in the read, while
11explicit paragraph values read back in points.
12
13`candidate/` is the page writer's output for the model edit in
14`crates/onestore/tests/paragraph_formatting.rs`: every paragraph set to centre
15alignment, zero space before, 2 pt after and 16 pt line spacing, with runs,
16direction and quick styles untouched. `cold/` is its fresh OneNote 2010 read:
17each paragraph is centred with `spaceAfter="2.0"` and `spaceBetween="16.0"`,
18the zero space before is omitted, `RTL="true"` survives on the right-aligned
19case, `quickStyleIndex` and the bold/italic runs match `before/read`, and
20`tools/verify-document.py` finds no differences. `tools/test_paragraph_format.py`
21checks these without a VM.
22
23Regenerate the candidate with `ONESTORE_PARAGRAPH_FORMAT_EXPORT` set to a new
24absolute directory while running
25`paragraph_properties_preserve_native_mixed_runs_and_shared_styles`, then
26cold-open it with `tools/native_runner.py OUTPUT COLD --expected-pages 2
27--collect-notebook`. Regenerate `before/` by running the runner on
28`corpus/create-notebook` style input with `--author
29tools/native/paragraph-formatting.ps1 --expected-pages 2 --collect-notebook`.
corpus/paragraph-format/before-capture/machine.json created+1
......@@ -0,0 +1 @@
1{"name": "m6-b1f8fbb5", "hostname": "ONE-M6-B1F8FBB5"}
corpus/paragraph-format/before-capture/run.json created+18
......@@ -0,0 +1,18 @@
1{
2 "notebook": "/Users/clo/dev/one/corpus/paragraph-format/before",
3 "expected_pages": 2,
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/paragraph-format/before-capture/source.json created+14
......@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 3780,
5 "sha256": "bb68c91caf0c082536d5c6ad2cd970a1fe5e68592c528633f97ef8cf406e7915",
6 "mtime_ns": 1789187746810790205
7 },
8 {
9 "path": "synthetic.one",
10 "bytes": 14196,
11 "sha256": "780e169db47f5ec5561e7998052763445bca00635174561b76bcb2bb711951b2",
12 "mtime_ns": 1789187746811006329
13 }
14]
corpus/paragraph-format/before-capture/teardown.json created+1
......@@ -0,0 +1 @@
1{"absent": true}
corpus/paragraph-format/before/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/paragraph-format/before/Open Notebook.onetoc2 differ
corpus/paragraph-format/before/read/environment.json created+7
......@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-B1F8FBB5",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/paragraph-format/before/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="{C8D2B28C-E386-4419-818D-6C0F500ADFE2}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-12T07:35:43.000Z" color="#B49EDE"><one:Section name="synthetic" ID="{AC7206A9-C956-0B03-39EB-7D3E0AE21F4B}{1}{B0}" path="C:\one-tests\runs\capture\notebook\synthetic.one" lastModifiedTime="2026-09-12T07:35:43.000Z" color="#8AA8E4"><one:Page ID="{C3FC1B9B-CB4B-08F3-1DB6-5D74F46C56C2}{1}{B0}" name="A notebook created in Rust." dateTime="2026-09-05T07:11:28.000Z" lastModifiedTime="2026-09-12T07:35:34.000Z" pageLevel="1"/><one:Page ID="{3871D42F-5989-4DC7-BECA-D3CC8E021471}{1}{B0}" name="Paragraph formatting" dateTime="2026-09-12T04:33:58.000Z" lastModifiedTime="2026-09-12T07:35:34.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/paragraph-format/before/read/page-000.xml created+10
......@@ -0,0 +1,10 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{3871D42F-5989-4DC7-BECA-D3CC8E021471}{1}{B0}" name="Paragraph formatting" dateTime="2026-09-12T04:33:58.000Z" lastModifiedTime="2026-09-12T07:35:34.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="1" name="h1" fontColor="#123456" highlightColor="automatic" font="Georgia" fontSize="18.0" bold="true" spaceBefore="8.0" spaceAfter="4.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-12T04:33:59.000Z" lastModifiedTime="2026-09-12T04:33:59.000Z" objectID="{07B26E11-C123-4D85-941A-ED5C88D9EA07}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Paragraph formatting]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-12T04:33:59.000Z" objectID="{07B26E11-C123-4D85-941A-ED5C88D9EA07}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="468.0" height="699.7595825195312"/><one:OEChildren><one:OE creationTime="2026-09-12T04:33:59.000Z" lastModifiedTime="2026-09-12T04:33:59.000Z" objectID="{07B26E11-C123-4D85-941A-ED5C88D9EA07}{40}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Default: <span
3style='font-weight:bold'>bold</span> <span style='font-style:italic'>italic</span> plain]]></one:T></one:OE><one:OE creationTime="2026-09-12T04:33:59.000Z" lastModifiedTime="2026-09-12T04:33:59.000Z" objectID="{07B26E11-C123-4D85-941A-ED5C88D9EA07}{47}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Explicit zero: <span
4style='font-weight:bold'>bold</span> <span style='font-style:italic'>italic</span> plain]]></one:T></one:OE><one:OE creationTime="2026-09-12T04:33:59.000Z" lastModifiedTime="2026-09-12T04:33:59.000Z" objectID="{07B26E11-C123-4D85-941A-ED5C88D9EA07}{52}{B0}" alignment="center" spaceBefore="12.0" spaceAfter="6.0" spaceBetween="20.0" quickStyleIndex="0"><one:T><![CDATA[Center spaced: <span
5style='font-weight:bold'>bold</span> <span style='font-style:italic'>italic</span> plain]]></one:T></one:OE><one:OE RTL="true" creationTime="2026-09-12T04:33:59.000Z" lastModifiedTime="2026-09-12T04:33:59.000Z" objectID="{07B26E11-C123-4D85-941A-ED5C88D9EA07}{57}{B0}" alignment="right" spaceBefore="3.0" spaceAfter="9.0" spaceBetween="18.0" quickStyleIndex="0"><one:T><![CDATA[Right RTL: <span
6style='font-weight:bold'>bold</span> <span style='font-style:italic'>italic</span> plain]]></one:T></one:OE><one:OE creationTime="2026-09-12T04:33:59.000Z" lastModifiedTime="2026-09-12T04:33:59.000Z" objectID="{07B26E11-C123-4D85-941A-ED5C88D9EA07}{62}{B0}" alignment="left" spaceBefore="288.0" spaceAfter="144.0" quickStyleIndex="1"><one:T><![CDATA[<span
7style='font-weight:bold'>Style inherited: bold </span><span style='font-weight:
8bold;font-style:italic'>italic</span><span style='font-weight:bold'> plain</span>]]></one:T></one:OE><one:OE creationTime="2026-09-12T04:33:59.000Z" lastModifiedTime="2026-09-12T04:33:59.000Z" objectID="{07B26E11-C123-4D85-941A-ED5C88D9EA07}{70}{B0}" alignment="center" spaceBefore="288.0" spaceAfter="2.0" spaceBetween="16.0" quickStyleIndex="1"><one:T><![CDATA[<span style='font-weight:bold'>Style overridden: bold </span><span
9style='font-weight:bold;font-style:italic'>italic</span><span style='font-weight:
10bold'> plain</span>]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/paragraph-format/before/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="{C3FC1B9B-CB4B-08F3-1DB6-5D74F46C56C2}{1}{B0}" name="A notebook created in Rust." dateTime="2026-09-05T07:11:28.000Z" lastModifiedTime="2026-09-12T07:35:34.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="Fixture Author" lastModifiedBy="Fixture Author" lastModifiedTime="2026-09-05T07:11:28.000Z" objectID="{AC7206A9-C956-0B03-39EB-7D3E0AE21F4B}{23}{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-05T07:11:28.000Z" lastModifiedTime="2026-09-05T07:11:28.000Z" objectID="{AC7206A9-C956-0B03-39EB-7D3E0AE21F4B}{24}{B0}" alignment="left"><one:T><![CDATA[A notebook created in Rust.]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/paragraph-format/before/read/payloads.json created+3
......@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/paragraph-format/before/synthetic.one created
Binary files /dev/null and b/corpus/paragraph-format/before/synthetic.one differ
corpus/paragraph-format/candidate/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/paragraph-format/candidate/Open Notebook.onetoc2 differ
corpus/paragraph-format/candidate/synthetic.one created
Binary files /dev/null and b/corpus/paragraph-format/candidate/synthetic.one differ
corpus/paragraph-format/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-6A989DFC -ExpectedPages 2", "exit": 0, "stdout": "Read 1 sections and 2 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/paragraph-format/cold/machine.json created+1
......@@ -0,0 +1 @@
1{"name": "m6-6a989dfc", "hostname": "ONE-M6-6A989DFC"}
corpus/paragraph-format/cold/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/paragraph-format/cold/notebook/Open Notebook.onetoc2 differ
corpus/paragraph-format/cold/notebook/synthetic.one created
Binary files /dev/null and b/corpus/paragraph-format/cold/notebook/synthetic.one differ
corpus/paragraph-format/cold/read/environment.json created+7
......@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-M6-6A989DFC",
5 "cold": true,
6 "onenote": "14.0.4763.1000"
7}
corpus/paragraph-format/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="{38A69DF5-8BD7-4496-826D-C9F4965FBF4F}{1}{B0}" path="C:\one-tests\runs\capture\notebook" lastModifiedTime="2026-09-12T07:35:34.000Z" color="#B49EDE"><one:Section name="synthetic" ID="{35E92B8C-796A-075E-2973-95254E6491C0}{1}{B0}" path="C:\one-tests\runs\capture\notebook\synthetic.one" lastModifiedTime="2026-09-12T07:35:34.000Z" color="#8AA8E4"><one:Page ID="{5A6736BE-7B77-04AE-0D2E-B56FB0EAD849}{1}{B0}" name="A notebook created in Rust." dateTime="2026-09-05T07:11:28.000Z" lastModifiedTime="2026-09-12T07:35:34.000Z" pageLevel="1"/><one:Page ID="{A1EAF90A-E9B5-419A-AE52-3BD7CA849AFA}{1}{B0}" name="Paragraph formatting" dateTime="2026-09-12T04:33:58.000Z" lastModifiedTime="2026-09-12T07:35:34.000Z" pageLevel="1"/></one:Section></one:Notebook>
corpus/paragraph-format/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="{5A6736BE-7B77-04AE-0D2E-B56FB0EAD849}{1}{B0}" name="A notebook created in Rust." dateTime="2026-09-05T07:11:28.000Z" lastModifiedTime="2026-09-12T07:35:34.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="Fixture Author" lastModifiedBy="Fixture Author" lastModifiedTime="2026-09-05T07:11:28.000Z" objectID="{35E92B8C-796A-075E-2973-95254E6491C0}{23}{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-05T07:11:28.000Z" lastModifiedTime="2026-09-05T07:11:28.000Z" objectID="{35E92B8C-796A-075E-2973-95254E6491C0}{24}{B0}" alignment="left"><one:T><![CDATA[A notebook created in Rust.]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/paragraph-format/cold/read/page-001.xml created+10
......@@ -0,0 +1,10 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{A1EAF90A-E9B5-419A-AE52-3BD7CA849AFA}{1}{B0}" name="Paragraph formatting" dateTime="2026-09-12T04:33:58.000Z" lastModifiedTime="2026-09-12T07:35:34.000Z" pageLevel="1" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:QuickStyleDef index="1" name="h1" fontColor="#123456" highlightColor="automatic" font="Georgia" fontSize="18.0" bold="true" spaceBefore="8.0" spaceAfter="4.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Title lang="en-US"><one:OE author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" creationTime="2026-09-12T04:33:59.000Z" lastModifiedTime="2026-09-12T04:33:59.000Z" objectID="{9E294334-711F-41D8-8482-0547CC5F648C}{33}{B0}" alignment="left" quickStyleIndex="0" style="font-family:Calibri;font-size:17.0pt"><one:T><![CDATA[Paragraph formatting]]></one:T></one:OE></one:Title><one:Outline author="virtual" authorInitials="V" lastModifiedBy="virtual" lastModifiedByInitials="V" lastModifiedTime="2026-09-12T07:33:48.000Z" objectID="{9E294334-711F-41D8-8482-0547CC5F648C}{39}{B0}"><one:Position x="72.0" y="108.0" z="0"/><one:Size width="468.0" height="114.9042510986328"/><one:OEChildren><one:OE creationTime="2026-09-12T04:33:59.000Z" lastModifiedTime="2026-09-12T07:33:48.000Z" objectID="{9E294334-711F-41D8-8482-0547CC5F648C}{40}{B0}" alignment="center" spaceAfter="2.0" spaceBetween="16.0" quickStyleIndex="0"><one:T><![CDATA[Default: <span
3style='font-weight:bold'>bold</span> <span style='font-style:italic'>italic</span> plain]]></one:T></one:OE><one:OE creationTime="2026-09-12T04:33:59.000Z" lastModifiedTime="2026-09-12T07:33:48.000Z" objectID="{9E294334-711F-41D8-8482-0547CC5F648C}{47}{B0}" alignment="center" spaceAfter="2.0" spaceBetween="16.0" quickStyleIndex="0"><one:T><![CDATA[Explicit zero: <span
4style='font-weight:bold'>bold</span> <span style='font-style:italic'>italic</span> plain]]></one:T></one:OE><one:OE creationTime="2026-09-12T04:33:59.000Z" lastModifiedTime="2026-09-12T07:33:48.000Z" objectID="{9E294334-711F-41D8-8482-0547CC5F648C}{52}{B0}" alignment="center" spaceAfter="2.0" spaceBetween="16.0" quickStyleIndex="0"><one:T><![CDATA[Center spaced: <span
5style='font-weight:bold'>bold</span> <span style='font-style:italic'>italic</span> plain]]></one:T></one:OE><one:OE RTL="true" creationTime="2026-09-12T04:33:59.000Z" lastModifiedTime="2026-09-12T07:33:48.000Z" objectID="{9E294334-711F-41D8-8482-0547CC5F648C}{57}{B0}" alignment="center" spaceAfter="2.0" spaceBetween="16.0" quickStyleIndex="0"><one:T><![CDATA[Right RTL: <span
6style='font-weight:bold'>bold</span> <span style='font-style:italic'>italic</span> plain]]></one:T></one:OE><one:OE creationTime="2026-09-12T04:33:59.000Z" lastModifiedTime="2026-09-12T07:33:48.000Z" objectID="{9E294334-711F-41D8-8482-0547CC5F648C}{62}{B0}" alignment="center" spaceAfter="2.0" spaceBetween="16.0" quickStyleIndex="1"><one:T><![CDATA[<span style='font-weight:bold'>Style inherited: bold </span><span
7style='font-weight:bold;font-style:italic'>italic</span><span style='font-weight:
8bold'> plain</span>]]></one:T></one:OE><one:OE creationTime="2026-09-12T04:33:59.000Z" lastModifiedTime="2026-09-12T07:33:48.000Z" objectID="{9E294334-711F-41D8-8482-0547CC5F648C}{70}{B0}" alignment="center" spaceAfter="2.0" spaceBetween="16.0" quickStyleIndex="1"><one:T><![CDATA[<span style='font-weight:bold'>Style overridden: bold </span><span
9style='font-weight:bold;font-style:italic'>italic</span><span style='font-weight:
10bold'> plain</span>]]></one:T></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/paragraph-format/cold/read/payloads.json created+3
......@@ -0,0 +1,3 @@
1[
2
3]
\ No newline at end of file
corpus/paragraph-format/cold/run.json created+18
......@@ -0,0 +1,18 @@
1{
2 "notebook": "/private/tmp/m4/pf",
3 "expected_pages": 2,
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/paragraph-format/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/paragraph-format/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/paragraph-format/cold/source.json created+14
......@@ -0,0 +1,14 @@
1[
2 {
3 "path": "Open Notebook.onetoc2",
4 "bytes": 1612,
5 "sha256": "a2c5dbdebb4884003d98144259b2c376ed380fce1b4d3605ce404eb03c2879d4",
6 "mtime_ns": 1789198428127888130
7 },
8 {
9 "path": "synthetic.one",
10 "bytes": 16488,
11 "sha256": "dd352fed4d9e4aa02655f6500b4dc921e0c8557287aa32b907e1608894e4caac",
12 "mtime_ns": 1789198428127605129
13 }
14]
corpus/paragraph-format/cold/teardown.json created+1
......@@ -0,0 +1 @@
1{"absent": true}
crates/notebook/tests/sync_formatting.rs+41
......@@ -497,3 +497,44 @@ fn a_formatted_save_of_an_unchanged_model_queues_nothing() {
497497 assert_eq!(cache.save(OUTLINES, space, &page, AUTHOR).unwrap(), None);
498498 assert!(cache.pending().unwrap().is_empty());
499499}
500
501#[test]
502fn paragraph_formatting_survives_reopen_rebase_and_a_lost_publication_reply() {
503 let (space, page) = page_titled(OUTLINES, PAGE);
504 let ids = plain(&page);
505 let attributes: &[fn(&mut Format)] = &[
506 |f| f.alignment = Some(2),
507 |f| f.rtl = Some(true),
508 |f| f.space_before = Some(12.0),
509 |f| f.space_after = Some(6.0),
510 |f| f.line_spacing = Some(18.0),
511 ];
512 for apply in attributes {
513 let directory = tempfile::tempdir().unwrap();
514 let cache = cache(&directory);
515 let id = save(&cache, ids[0], |page| restyle(page, ids[0], 0..6, apply))
516 .unwrap()
517 .unwrap();
518 let expected = formats(&page_of(&cache.snapshot().unwrap(), space), ids[0]);
519 drop(cache);
520 let cache = Replica::open(directory.path().join("cache.sqlite")).unwrap();
521 let mut server = remote_with(space, |page| replace_text(page, ids[3], 0..0, "Remote "));
522 server.fault = Fault::UnknownAfter;
523 assert!(
524 matches!(cache.sync_once(&mut server), Err(notebook::Error::Remote(error)) if error.state == onestore::CommitState::Unknown)
525 );
526 assert!(matches!(
527 cache.status(id).unwrap(),
528 Some(EditStatus::AwaitingConfirmation { .. })
529 ));
530 drop(cache);
531 let cache = Replica::open(directory.path().join("cache.sqlite")).unwrap();
532 assert!(
533 matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id)
534 );
535 assert_eq!(server.publications, 1);
536 let page = page_of(&server.durable, space);
537 assert_eq!(formats(&page, ids[0]), expected);
538 assert!(text_of(&page, ids[3]).starts_with("Remote "));
539 }
540}
crates/onestore/src/formatting.rs+22-11
......@@ -244,21 +244,32 @@ pub(crate) fn format_text(
244244 (0x14001d7a, &modified),
245245 ])?;
246246 changed.insert(object, target);
247 let mut pending = parents.get(&object).cloned().unwrap_or_default();
248 let mut ancestors = BTreeSet::new();
249 while let Some(id) = pending.pop() {
250 if !ancestors.insert(id) {
251 continue;
252 }
253 let mut ancestor = PropertyObject::from_object(&raw.objects[&id])?;
254 ancestor.set(&[(0x14001d7a, &modified)])?;
255 changed.insert(id, ancestor);
256 pending.extend(parents.get(&id).into_iter().flatten().copied());
257 }
247 touch_ancestors(raw, &parents, object, &modified, &mut changed)?;
258248 Ok(changed)
259249 })
260250}
261251
252pub(crate) fn touch_ancestors(
253 raw: &crate::ResolvedRevision<'_>,
254 parents: &BTreeMap<ExGuid, Vec<ExGuid>>,
255 object: ExGuid,
256 modified: &[u8; 4],
257 changed: &mut BTreeMap<ExGuid, PropertyObject>,
258) -> Result<(), Error> {
259 let mut pending = parents.get(&object).cloned().unwrap_or_default();
260 let mut ancestors = BTreeSet::new();
261 while let Some(id) = pending.pop() {
262 if !ancestors.insert(id) {
263 continue;
264 }
265 let mut ancestor = PropertyObject::from_object(&raw.objects[&id])?;
266 ancestor.set(&[(0x14001d7a, modified)])?;
267 changed.insert(id, ancestor);
268 pending.extend(parents.get(&id).into_iter().flatten().copied());
269 }
270 Ok(())
271}
272
262273pub(crate) fn attribute_values(attributes: &[TextAttribute]) -> Result<Vec<(u32, Vec<u8>)>, Error> {
263274 if attributes.is_empty() {
264275 return Err(invalid("Choose at least one formatting attribute"));
crates/onestore/src/page/write.rs+115
......@@ -206,6 +206,7 @@ impl Lowering<'_> {
206206 self.place(&old, &new, &placed, &page_order)?;
207207 self.delete(&old, &new, &consumed)?;
208208 self.edit_text(&new)?;
209 self.edit_paragraph_formatting(&new)?;
209210 self.edit_formatting(&new)?;
210211 self.edit_layout(&old, &new)?;
211212 Ok(())
......@@ -658,6 +659,120 @@ impl Lowering<'_> {
658659 Ok(())
659660 }
660661
662 fn edit_paragraph_formatting(&mut self, new: &View<'_>) -> Result<(), Error> {
663 let current = self.current()?;
664 let current = View::new(&current)?;
665 for (id, paragraph) in &new.paragraphs {
666 let Some(text) = paragraph.text() else {
667 continue;
668 };
669 let stored = current
670 .text(self.id(*id))
671 .ok_or_else(|| invalid("A paragraph is missing after text edits"))?;
672 let mut values: Vec<(u32, Vec<u8>)> = Vec::new();
673 macro_rules! field {
674 ($field:ident, $value:ident, $encode:block) => {
675 let $value = format_in(&text.text, 0)?.$field.unwrap_or_default();
676 if text
677 .text
678 .spans()
679 .iter()
680 .all(|span| span.format.$field.unwrap_or_default() == $value)
681 && stored
682 .text
683 .spans()
684 .iter()
685 .any(|span| span.format.$field.unwrap_or_default() != $value)
686 {
687 values.push($encode);
688 }
689 };
690 }
691 field!(alignment, value, {
692 if value > 2 {
693 return Err(invalid("Paragraph alignment must be left, center or right"));
694 }
695 (0x0c003477, vec![value])
696 });
697 field!(rtl, value, {
698 (0x08003476 | (u32::from(value) << 31), Vec::new())
699 });
700 macro_rules! spacing {
701 ($field:ident, $property:expr) => {
702 field!($field, value, {
703 let stored = value / 36.0;
704 if !stored.is_finite() || !(0.0..=27777.777).contains(&stored) {
705 return Err(invalid("Paragraph spacing is outside the document range"));
706 }
707 ($property, stored.to_le_bytes().to_vec())
708 });
709 };
710 }
711 spacing!(space_before, 0x1400342e);
712 spacing!(space_after, 0x1400342f);
713 spacing!(line_spacing, 0x14003430);
714 if values.is_empty() {
715 continue;
716 }
717 if text.date_field.is_some() {
718 return Err(invalid(
719 "Generated title fields cannot be formatted as ordinary text",
720 ));
721 }
722 let (space, object) = (self.space, stored.id);
723 self.apply(|image| {
724 let store = Store::parse(image)?;
725 let index = RevisionIndex::parse(&store)?;
726 let document = Document::parse(&index)?;
727 let parents = crate::edit::editable_parents(
728 document.active(space)?,
729 &document.pages_in(space)?,
730 object,
731 )?;
732 let modified = crate::create::current_timestamps()?.0.to_le_bytes();
733 crate::write::write_revision(image, space, |raw| {
734 let mut target = PropertyObject::from_object(&raw.objects[&object])?;
735 target.set(
736 &values
737 .iter()
738 .map(|(id, bytes)| (*id, bytes.as_slice()))
739 .collect::<Vec<_>>(),
740 )?;
741 if let Some((_, alignment)) = values.iter().find(|(id, _)| *id == 0x0c003477) {
742 for property in [0x14001c3e, 0x14001c84] {
743 let fields = PropertySets::parse(&target.bytes)?;
744 let previous = fields.sets[0]
745 .iter()
746 .find(|field| field.id == property)
747 .map(|field| match field.value {
748 Value::Bytes(bytes) => bytes
749 .try_into()
750 .map(u32::from_le_bytes)
751 .map_err(|_| invalid("Invalid paragraph layout alignment")),
752 _ => Err(invalid("Invalid paragraph layout alignment")),
753 })
754 .transpose()?
755 .unwrap_or(0);
756 let value = (previous & !7) | (u32::from(alignment[0]) + 1);
757 target.set(&[(property, &value.to_le_bytes())])?;
758 }
759 }
760 target.set(&[(0x14001d7a, &modified)])?;
761 let mut changed = BTreeMap::from([(object, target)]);
762 crate::formatting::touch_ancestors(
763 raw,
764 &parents,
765 object,
766 &modified,
767 &mut changed,
768 )?;
769 Ok(changed)
770 })
771 })?;
772 }
773 Ok(())
774 }
775
661776 fn edit_formatting(&mut self, new: &View<'_>) -> Result<(), Error> {
662777 let current = self.current()?;
663778 let current = View::new(&current)?;
crates/onestore/tests/paragraph_formatting.rs created+125
......@@ -0,0 +1,125 @@
1use onestore::{
2 ExGuid, PreparedEdit, RevisionIndex, Store,
3 document::{Document, Kind},
4 page::{Page, PageObject, Paragraph},
5};
6
7const SOURCE: &[u8] = include_bytes!("../../../corpus/paragraph-format/before/synthetic.one");
8
9fn page(bytes: &[u8]) -> (ExGuid, Page) {
10 let store = Store::parse(bytes).unwrap();
11 assert!(store.checksum_mismatches.is_empty());
12 let index = RevisionIndex::parse(&store).unwrap();
13 index.validate_current().unwrap();
14 let document = Document::parse(&index).unwrap();
15 document
16 .pages()
17 .unwrap()
18 .into_iter()
19 .find_map(|(space, _)| {
20 let page = Page::from_space(&document, space).unwrap();
21 (page.title == "Paragraph formatting").then_some((space, page))
22 })
23 .unwrap()
24}
25
26#[test]
27fn paragraph_properties_preserve_native_mixed_runs_and_shared_styles() {
28 let (space, before) = page(SOURCE);
29 let mut after = before.clone();
30 let mut count = 0;
31 for object in &mut after.objects {
32 if let PageObject::Outline(outline) = object {
33 for paragraph in &mut outline.paragraphs {
34 let text = paragraph.text_mut().unwrap();
35 let mut start = 0;
36 text.text = Paragraph::from_runs(text.text.spans().iter().map(|span| {
37 let value = text.text.text()[start..span.end].to_owned();
38 start = span.end;
39 let mut format = span.format.clone();
40 format.alignment = Some(1);
41 format.space_before = Some(0.0);
42 format.space_after = Some(2.0);
43 format.line_spacing = Some(16.0);
44 (value, format)
45 }));
46 count += 1;
47 }
48 }
49 }
50 assert_eq!(count, 6);
51 let prepared = PreparedEdit::page(SOURCE, space, &after, "Format author").unwrap();
52 assert_eq!(page(prepared.as_bytes()).1, after);
53 let before_store = Store::parse(SOURCE).unwrap();
54 let before_index = RevisionIndex::parse(&before_store).unwrap();
55 let before_doc = Document::parse(&before_index).unwrap();
56 let after_store = Store::parse(prepared.as_bytes()).unwrap();
57 let after_index = RevisionIndex::parse(&after_store).unwrap();
58 let old = before_index.resolve_active(space).unwrap();
59 let new = after_index.resolve_active(space).unwrap();
60 for (id, node) in &before_doc.active(space).unwrap().nodes {
61 if matches!(node.kind, Kind::Style { .. }) {
62 assert_eq!(old.objects[id].data, new.objects[id].data);
63 }
64 }
65 if let Some(directory) = std::env::var_os("ONESTORE_PARAGRAPH_FORMAT_EXPORT") {
66 let directory = std::path::PathBuf::from(directory);
67 std::fs::create_dir(&directory).unwrap();
68 std::fs::write(directory.join("synthetic.one"), prepared.as_bytes()).unwrap();
69 std::fs::write(
70 directory.join("Open Notebook.onetoc2"),
71 onestore::create_table_of_contents(
72 "Open Notebook.onetoc2",
73 &[("synthetic.one", after_store.header.file_id)],
74 )
75 .unwrap(),
76 )
77 .unwrap();
78 }
79}
80
81#[test]
82fn paragraph_formatting_rejects_invalid_values_and_conflicting_run_settings() {
83 let (space, before) = page(SOURCE);
84 let invalid: &[fn(&mut onestore::document::Format)] = &[
85 |format| format.alignment = Some(3),
86 |format| format.space_before = Some(-1.0),
87 |format| format.space_after = Some(f32::NAN),
88 |format| format.line_spacing = Some(f32::INFINITY),
89 |format| format.space_after = Some(1_000_100.0),
90 ];
91 for change in invalid {
92 let mut after = before.clone();
93 let text = after
94 .objects
95 .iter_mut()
96 .find_map(|object| match object {
97 PageObject::Outline(outline) => {
98 outline.paragraphs.iter_mut().find_map(|p| p.text_mut())
99 }
100 _ => None,
101 })
102 .unwrap();
103 let mut format = text.text.format_at(0).unwrap().clone();
104 change(&mut format);
105 text.text = Paragraph::new(text.text.text().to_owned(), format);
106 assert!(PreparedEdit::page(SOURCE, space, &after, "Author").is_err());
107 }
108 let mut after = before.clone();
109 let text = after
110 .objects
111 .iter_mut()
112 .find_map(|object| match object {
113 PageObject::Outline(outline) => {
114 outline.paragraphs.iter_mut().find_map(|p| p.text_mut())
115 }
116 _ => None,
117 })
118 .unwrap();
119 let mut left = text.text.format_at(0).unwrap().clone();
120 let mut right = left.clone();
121 left.alignment = Some(1);
122 right.alignment = Some(2);
123 text.text = Paragraph::from_runs([("Left".into(), left), ("Right".into(), right)]);
124 assert!(PreparedEdit::page(SOURCE, space, &after, "Author").is_err());
125}
tools/test_paragraph_format.py created+57
......@@ -0,0 +1,57 @@
1from pathlib import Path
2import runpy
3import shutil
4from tempfile import TemporaryDirectory
5import unittest
6import xml.etree.ElementTree as ET
7
8from native_format import native_characters
9from native_xml import ns
10
11ROOT = Path(__file__).resolve().parent.parent
12FIXTURE = ROOT / 'corpus/paragraph-format'
13compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
14IGNORED = {'objectID', 'lastModifiedTime', 'creationTime'}
15
16
17def paragraphs(read):
18 for path in sorted(read.glob('page-*.xml')):
19 root = ET.parse(path).getroot()
20 if root.get('name') == 'Paragraph formatting':
21 outline, = root.findall('one:Outline', ns)
22 return [({k: v for k, v in oe.attrib.items() if k not in IGNORED}, oe)
23 for oe in outline.findall('one:OEChildren/one:OE', ns)], root
24 raise AssertionError('The paragraph formatting page is missing')
25
26
27class ParagraphFormatTest(unittest.TestCase):
28 def test_native_controls_and_rust_paragraph_properties_reopen_natively(self):
29 with TemporaryDirectory() as temporary:
30 for notebook, read in [(FIXTURE / 'before', FIXTURE / 'before/read'),
31 (FIXTURE / 'candidate', FIXTURE / 'cold/read')]:
32 folder = Path(temporary) / notebook.name
33 shutil.copytree(read, folder / 'read')
34 compare(notebook, folder / 'read')
35 before, before_page = paragraphs(FIXTURE / 'before/read')
36 after, after_page = paragraphs(FIXTURE / 'cold/read')
37 self.assertEqual(len(before), 6)
38 self.assertEqual([attributes for attributes, _ in before], [
39 {'alignment': 'left', 'quickStyleIndex': '0'},
40 {'alignment': 'left', 'quickStyleIndex': '0'},
41 {'alignment': 'center', 'spaceBefore': '12.0', 'spaceAfter': '6.0', 'spaceBetween': '20.0', 'quickStyleIndex': '0'},
42 {'RTL': 'true', 'alignment': 'right', 'spaceBefore': '3.0', 'spaceAfter': '9.0', 'spaceBetween': '18.0', 'quickStyleIndex': '0'},
43 {'alignment': 'left', 'spaceBefore': '288.0', 'spaceAfter': '144.0', 'quickStyleIndex': '1'},
44 {'alignment': 'center', 'spaceBefore': '288.0', 'spaceAfter': '2.0', 'spaceBetween': '16.0', 'quickStyleIndex': '1'},
45 ])
46 for index, ((original, _), (attributes, _)) in enumerate(zip(before, after, strict=True)):
47 expected = {'alignment': 'center', 'spaceAfter': '2.0', 'spaceBetween': '16.0',
48 'quickStyleIndex': original['quickStyleIndex']}
49 if original.get('RTL') == 'true':
50 expected['RTL'] = 'true'
51 self.assertEqual(attributes, expected, index)
52 self.assertEqual(native_characters(after_page, after_page.findall('one:Outline', ns)),
53 native_characters(before_page, before_page.findall('one:Outline', ns)))
54
55
56if __name__ == '__main__':
57 unittest.main()