| author | |
| committer | |
| log | b6b6e903d65a5b876c5c009ba390df25d9842040 |
| tree | c287ee53d65a80bb47166da143fb560ecb8379e1 |
| parent | cffb5ce4bca95f8c242b5513d981a708206cee41 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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.133 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 | ||
| 4 | formatting", with six paragraphs of mixed bold/italic/plain runs: native | ||
| 5 | defaults, explicit zero values, centred spacing, right-aligned RTL, a named | ||
| 6 | style (`h1`, Georgia 18 bold with spacing) inherited, and the same style with | ||
| 7 | paragraph 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 | ||
| 10 | inherited `spaceBefore="8"`/`spaceAfter="4"` as 288/144 in the read, while | ||
| 11 | explicit 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 | ||
| 15 | alignment, zero space before, 2 pt after and 16 pt line spacing, with runs, | ||
| 16 | direction and quick styles untouched. `cold/` is its fresh OneNote 2010 read: | ||
| 17 | each paragraph is centred with `spaceAfter="2.0"` and `spaceBetween="16.0"`, | ||
| 18 | the zero space before is omitted, `RTL="true"` survives on the right-aligned | ||
| 19 | case, `quickStyleIndex` and the bold/italic runs match `before/read`, and | ||
| 20 | `tools/verify-document.py` finds no differences. `tools/test_paragraph_format.py` | ||
| 21 | checks these without a VM. | ||
| 22 | |||
| 23 | Regenerate the candidate with `ONESTORE_PARAGRAPH_FORMAT_EXPORT` set to a new | ||
| 24 | absolute directory while running | ||
| 25 | `paragraph_properties_preserve_native_mixed_runs_and_shared_styles`, then | ||
| 26 | cold-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 | ||
| 29 | tools/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 | ||
| 3 | style='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 | ||
| 4 | style='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 | ||
| 5 | style='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 | ||
| 6 | style='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 | ||
| 7 | style='font-weight:bold'>Style inherited: bold </span><span style='font-weight: | ||
| 8 | bold;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 | ||
| 9 | style='font-weight:bold;font-style:italic'>italic</span><span style='font-weight: | ||
| 10 | bold'> 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 | ||
| 3 | style='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 | ||
| 4 | style='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 | ||
| 5 | style='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 | ||
| 6 | style='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 | ||
| 7 | style='font-weight:bold;font-style:italic'>italic</span><span style='font-weight: | ||
| 8 | bold'> 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 | ||
| 9 | style='font-weight:bold;font-style:italic'>italic</span><span style='font-weight: | ||
| 10 | bold'> 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 @@ | ||
| 1 | param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') | ||
| 2 | Set-StrictMode -Version Latest | ||
| 3 | $ErrorActionPreference = 'Stop' | ||
| 4 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | ||
| 5 | if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { | ||
| 6 | throw 'Choose a run directly below C:\one-tests\runs.' | ||
| 7 | } | ||
| 8 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } | ||
| 9 | $key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' | ||
| 10 | if ($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)) | ||
| 22 | if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } | ||
| 23 | New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null | ||
| 24 | if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } | ||
| 25 | if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } | ||
| 26 | New-Item "$key\OpenNotebooks" | Out-Null | ||
| 27 | New-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 @@ | ||
| 1 | param( | ||
| 2 | [Parameter(Mandatory=$true)][string]$Root, | ||
| 3 | [int]$ExpectedPages = -1, | ||
| 4 | [switch]$UseCurrentCache, | ||
| 5 | [switch]$Pdf, | ||
| 6 | [switch]$KeepOpen, | ||
| 7 | [string]$CloneHost = '' | ||
| 8 | ) | ||
| 9 | Set-StrictMode -Version Latest | ||
| 10 | $ErrorActionPreference = 'Stop' | ||
| 11 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') | ||
| 12 | if ([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' | ||
| 17 | if (Test-Path $output) { throw 'Choose a new read destination.' } | ||
| 18 | if ($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 | } | ||
| 25 | New-Item -ItemType Directory -Path $output | Out-Null | ||
| 26 | $app = New-Object -ComObject OneNote.Application | ||
| 27 | $notebookId = '' | ||
| 28 | $failure = $null | ||
| 29 | try { | ||
| 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() { | ... | @@ -497,3 +497,44 @@ fn a_formatted_save_of_an_unchanged_model_queues_nothing() { |
| 497 | assert_eq!(cache.save(OUTLINES, space, &page, AUTHOR).unwrap(), None); | 497 | assert_eq!(cache.save(OUTLINES, space, &page, AUTHOR).unwrap(), None); |
| 498 | assert!(cache.pending().unwrap().is_empty()); | 498 | assert!(cache.pending().unwrap().is_empty()); |
| 499 | } | 499 | } |
| 500 | |||
| 501 | #[test] | ||
| 502 | fn 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( | ... | @@ -244,21 +244,32 @@ pub(crate) fn format_text( |
| 244 | (0x14001d7a, &modified), | 244 | (0x14001d7a, &modified), |
| 245 | ])?; | 245 | ])?; |
| 246 | changed.insert(object, target); | 246 | changed.insert(object, target); |
| 247 | let mut pending = parents.get(&object).cloned().unwrap_or_default(); | 247 | touch_ancestors(raw, &parents, object, &modified, &mut changed)?; |
| 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 | } | ||
| 258 | Ok(changed) | 248 | Ok(changed) |
| 259 | }) | 249 | }) |
| 260 | } | 250 | } |
| 261 | 251 | ||
| 252 | pub(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 | |||
| 262 | pub(crate) fn attribute_values(attributes: &[TextAttribute]) -> Result<Vec<(u32, Vec<u8>)>, Error> { | 273 | pub(crate) fn attribute_values(attributes: &[TextAttribute]) -> Result<Vec<(u32, Vec<u8>)>, Error> { |
| 263 | if attributes.is_empty() { | 274 | if attributes.is_empty() { |
| 264 | return Err(invalid("Choose at least one formatting attribute")); | 275 | return Err(invalid("Choose at least one formatting attribute")); |
crates/onestore/src/page/write.rs+115| ... | @@ -206,6 +206,7 @@ impl Lowering<'_> { | ... | @@ -206,6 +206,7 @@ impl Lowering<'_> { |
| 206 | self.place(&old, &new, &placed, &page_order)?; | 206 | self.place(&old, &new, &placed, &page_order)?; |
| 207 | self.delete(&old, &new, &consumed)?; | 207 | self.delete(&old, &new, &consumed)?; |
| 208 | self.edit_text(&new)?; | 208 | self.edit_text(&new)?; |
| 209 | self.edit_paragraph_formatting(&new)?; | ||
| 209 | self.edit_formatting(&new)?; | 210 | self.edit_formatting(&new)?; |
| 210 | self.edit_layout(&old, &new)?; | 211 | self.edit_layout(&old, &new)?; |
| 211 | Ok(()) | 212 | Ok(()) |
| ... | @@ -658,6 +659,120 @@ impl Lowering<'_> { | ... | @@ -658,6 +659,120 @@ impl Lowering<'_> { |
| 658 | Ok(()) | 659 | Ok(()) |
| 659 | } | 660 | } |
| 660 | 661 | ||
| 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 | |||
| 661 | fn edit_formatting(&mut self, new: &View<'_>) -> Result<(), Error> { | 776 | fn edit_formatting(&mut self, new: &View<'_>) -> Result<(), Error> { |
| 662 | let current = self.current()?; | 777 | let current = self.current()?; |
| 663 | let current = View::new(&current)?; | 778 | let current = View::new(&current)?; |
crates/onestore/tests/paragraph_formatting.rs created+125| ... | @@ -0,0 +1,125 @@ | ||
| 1 | use onestore::{ | ||
| 2 | ExGuid, PreparedEdit, RevisionIndex, Store, | ||
| 3 | document::{Document, Kind}, | ||
| 4 | page::{Page, PageObject, Paragraph}, | ||
| 5 | }; | ||
| 6 | |||
| 7 | const SOURCE: &[u8] = include_bytes!("../../../corpus/paragraph-format/before/synthetic.one"); | ||
| 8 | |||
| 9 | fn 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] | ||
| 27 | fn 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] | ||
| 82 | fn 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 @@ | ||
| 1 | from pathlib import Path | ||
| 2 | import runpy | ||
| 3 | import shutil | ||
| 4 | from tempfile import TemporaryDirectory | ||
| 5 | import unittest | ||
| 6 | import xml.etree.ElementTree as ET | ||
| 7 | |||
| 8 | from native_format import native_characters | ||
| 9 | from native_xml import ns | ||
| 10 | |||
| 11 | ROOT = Path(__file__).resolve().parent.parent | ||
| 12 | FIXTURE = ROOT / 'corpus/paragraph-format' | ||
| 13 | compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] | ||
| 14 | IGNORED = {'objectID', 'lastModifiedTime', 'creationTime'} | ||
| 15 | |||
| 16 | |||
| 17 | def 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 | |||
| 27 | class 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 | |||
| 56 | if __name__ == '__main__': | ||
| 57 | unittest.main() | ||