diff --git a/corpus/paragraph-edit/README.md b/corpus/paragraph-edit/README.md
index 14333fa67ce761a4c93017134f400497e767249c..a79e9aa4b4ff5b1ccd19c5624b398bc504a4e85d 100644
--- a/corpus/paragraph-edit/README.md
+++ b/corpus/paragraph-edit/README.md
@@ -26,6 +26,14 @@ notebook before completing inspection. Cold-open the split notebook for the join
pass. Captured COM IDs belong to their originating sessions; discover fresh IDs
when regenerating the corpus.
-`tools/test_paragraph_edit.py` verifies these native controls without a VM. They
-establish application behavior; they do not advertise a library split/join API.
+`rust-split` retains twelve `ParagraphSplit` outputs, their cold native captures,
+and a second native session typing into four empty boundary paragraphs. The
+hyperlink case stays unchanged because this operation rejects fields. The Rust
+test `export_native_paragraph_splits` generates candidate notebooks; its output
+directory comes from `ONESTORE_PARAGRAPH_OUTPUT`. Native character/style comparisons
+use both the keyboard-generated controls and the Rust-written notebooks. Empty
+typing checks extend the preexisting empty run in an independent expected model.
+
+`tools/test_paragraph_edit.py` verifies these controls without a VM. The native
+join captures establish behavior for subsequent join implementation.
Identical captured files link to one canonical copy within this corpus.
diff --git a/corpus/paragraph-edit/rust-split/candidate/synthetic.one b/corpus/paragraph-edit/rust-split/candidate/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..b531feed7f641011262d6bc9eafaad31bdb863e9
Binary files /dev/null and b/corpus/paragraph-edit/rust-split/candidate/synthetic.one differ
diff --git a/corpus/paragraph-edit/rust-split/manifest.json b/corpus/paragraph-edit/rust-split/manifest.json
new file mode 100644
index 0000000000000000000000000000000000000000..22e5a0ebff3be8f6c8d1969b8ce4e45311969948
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/manifest.json
@@ -0,0 +1,352 @@
+{
+ "cases": [
+ {
+ "case": "Split middle",
+ "intent": {
+ "author": "Rust split author",
+ "created": 1473324660,
+ "guid": [
+ 228,
+ 60,
+ 65,
+ 246,
+ 194,
+ 244,
+ 184,
+ 76,
+ 170,
+ 5,
+ 19,
+ 130,
+ 32,
+ 122,
+ 148,
+ 241
+ ],
+ "offset": 2,
+ "text": "{DFE950EF-6BEB-0F5B-2403-7B74F1AE5002},44"
+ },
+ "new_paragraph": "{F6413CE4-F4C2-4CB8-AA05-1382207A94F1},1",
+ "new_text": "{F6413CE4-F4C2-4CB8-AA05-1382207A94F1},2"
+ },
+ {
+ "case": "Split style boundary",
+ "intent": {
+ "author": "Rust split author",
+ "created": 1473324660,
+ "guid": [
+ 163,
+ 91,
+ 197,
+ 73,
+ 195,
+ 225,
+ 53,
+ 68,
+ 140,
+ 152,
+ 83,
+ 168,
+ 172,
+ 124,
+ 226,
+ 213
+ ],
+ "offset": 4,
+ "text": "{5FB484E2-1E3E-0A9D-1989-271A3EBBEC42},44"
+ },
+ "new_paragraph": "{49C55BA3-E1C3-4435-8C98-53A8AC7CE2D5},1",
+ "new_text": "{49C55BA3-E1C3-4435-8C98-53A8AC7CE2D5},2"
+ },
+ {
+ "case": "Split start",
+ "intent": {
+ "author": "Rust split author",
+ "created": 1473324660,
+ "guid": [
+ 135,
+ 16,
+ 214,
+ 248,
+ 73,
+ 35,
+ 2,
+ 69,
+ 148,
+ 213,
+ 125,
+ 28,
+ 43,
+ 124,
+ 81,
+ 35
+ ],
+ "offset": 0,
+ "text": "{012ED94B-43B3-079A-3050-B1CBE25C1C09},44"
+ },
+ "new_paragraph": "{F8D61087-2349-4502-94D5-7D1C2B7C5123},1",
+ "new_text": "{F8D61087-2349-4502-94D5-7D1C2B7C5123},2"
+ },
+ {
+ "case": "Split end",
+ "intent": {
+ "author": "Rust split author",
+ "created": 1473324660,
+ "guid": [
+ 37,
+ 220,
+ 149,
+ 138,
+ 85,
+ 3,
+ 106,
+ 69,
+ 168,
+ 172,
+ 49,
+ 82,
+ 247,
+ 99,
+ 160,
+ 139
+ ],
+ "offset": 22,
+ "text": "{DAF723A3-C2A3-0798-2EAB-34613166A794},44"
+ },
+ "new_paragraph": "{8A95DC25-0355-456A-A8AC-3152F763A08B},1",
+ "new_text": "{8A95DC25-0355-456A-A8AC-3152F763A08B},2"
+ },
+ {
+ "case": "Split empty",
+ "intent": {
+ "author": "Rust split author",
+ "created": 1473324660,
+ "guid": [
+ 232,
+ 204,
+ 91,
+ 132,
+ 180,
+ 27,
+ 239,
+ 79,
+ 158,
+ 115,
+ 213,
+ 165,
+ 40,
+ 35,
+ 189,
+ 93
+ ],
+ "offset": 0,
+ "text": "{0B467F31-D287-08CD-09F4-A6FBB2F5418F},45"
+ },
+ "new_paragraph": "{845BCCE8-1BB4-4FEF-9E73-D5A52823BD5D},1",
+ "new_text": "{845BCCE8-1BB4-4FEF-9E73-D5A52823BD5D},2"
+ },
+ {
+ "case": "Split parent",
+ "intent": {
+ "author": "Rust split author",
+ "created": 1473324660,
+ "guid": [
+ 54,
+ 186,
+ 114,
+ 171,
+ 44,
+ 189,
+ 95,
+ 71,
+ 135,
+ 190,
+ 71,
+ 95,
+ 23,
+ 246,
+ 37,
+ 157
+ ],
+ "offset": 2,
+ "text": "{3860EA13-D444-0598-35D6-CF96769C6A21},44"
+ },
+ "new_paragraph": "{AB72BA36-BD2C-475F-87BE-475F17F6259D},1",
+ "new_text": "{AB72BA36-BD2C-475F-87BE-475F17F6259D},2"
+ },
+ {
+ "case": "Split child",
+ "intent": {
+ "author": "Rust split author",
+ "created": 1473324660,
+ "guid": [
+ 160,
+ 247,
+ 235,
+ 152,
+ 30,
+ 98,
+ 166,
+ 77,
+ 157,
+ 204,
+ 141,
+ 45,
+ 114,
+ 151,
+ 225,
+ 195
+ ],
+ "offset": 2,
+ "text": "{B587CBC6-0BCC-0A23-16F6-E1B0B3C5D061},49"
+ },
+ "new_paragraph": "{98EBF7A0-621E-4DA6-9DCC-8D2D7297E1C3},1",
+ "new_text": "{98EBF7A0-621E-4DA6-9DCC-8D2D7297E1C3},2"
+ },
+ {
+ "case": "Split bullet",
+ "intent": {
+ "author": "Rust split author",
+ "created": 1473324660,
+ "guid": [
+ 53,
+ 245,
+ 214,
+ 76,
+ 159,
+ 56,
+ 134,
+ 76,
+ 178,
+ 90,
+ 131,
+ 182,
+ 74,
+ 114,
+ 31,
+ 24
+ ],
+ "offset": 2,
+ "text": "{8C65E427-477C-02ED-1807-2F10E277019D},44"
+ },
+ "new_paragraph": "{4CD6F535-389F-4C86-B25A-83B64A721F18},1",
+ "new_text": "{4CD6F535-389F-4C86-B25A-83B64A721F18},2"
+ },
+ {
+ "case": "Split number",
+ "intent": {
+ "author": "Rust split author",
+ "created": 1473324661,
+ "guid": [
+ 196,
+ 133,
+ 251,
+ 251,
+ 193,
+ 175,
+ 8,
+ 65,
+ 175,
+ 180,
+ 126,
+ 16,
+ 31,
+ 185,
+ 237,
+ 66
+ ],
+ "offset": 2,
+ "text": "{D4ABBE4F-2165-0122-1F9F-ACE3FE92C703},44"
+ },
+ "new_paragraph": "{FBFB85C4-AFC1-4108-AFB4-7E101FB9ED42},1",
+ "new_text": "{FBFB85C4-AFC1-4108-AFB4-7E101FB9ED42},2"
+ },
+ {
+ "case": "Split tag",
+ "intent": {
+ "author": "Rust split author",
+ "created": 1473324661,
+ "guid": [
+ 40,
+ 122,
+ 110,
+ 77,
+ 185,
+ 255,
+ 122,
+ 69,
+ 128,
+ 15,
+ 14,
+ 166,
+ 54,
+ 156,
+ 154,
+ 91
+ ],
+ "offset": 2,
+ "text": "{37AA5877-2980-0227-169F-8B1E8EE0795A},44"
+ },
+ "new_paragraph": "{4D6E7A28-FFB9-457A-800F-0EA6369C9A5B},1",
+ "new_text": "{4D6E7A28-FFB9-457A-800F-0EA6369C9A5B},2"
+ },
+ {
+ "case": "Split cell",
+ "intent": {
+ "author": "Rust split author",
+ "created": 1473324661,
+ "guid": [
+ 150,
+ 222,
+ 89,
+ 134,
+ 80,
+ 167,
+ 167,
+ 67,
+ 179,
+ 23,
+ 93,
+ 176,
+ 27,
+ 184,
+ 119,
+ 185
+ ],
+ "offset": 2,
+ "text": "{F0832BC9-1578-0A96-08AF-99344644FFB8},50"
+ },
+ "new_paragraph": "{8659DE96-A750-43A7-B317-5DB01BB877B9},1",
+ "new_text": "{8659DE96-A750-43A7-B317-5DB01BB877B9},2"
+ },
+ {
+ "case": "Split soft break",
+ "intent": {
+ "author": "Rust split author",
+ "created": 1473324661,
+ "guid": [
+ 199,
+ 62,
+ 94,
+ 81,
+ 29,
+ 157,
+ 236,
+ 77,
+ 159,
+ 34,
+ 22,
+ 137,
+ 163,
+ 57,
+ 215,
+ 79
+ ],
+ "offset": 2,
+ "text": "{88189BA1-2F19-0282-05ED-A0B83153F143},44"
+ },
+ "new_paragraph": "{515E3EC7-9D1D-4DEC-9F22-1689A339D74F},1",
+ "new_text": "{515E3EC7-9D1D-4DEC-9F22-1689A339D74F},2"
+ }
+ ]
+}
diff --git a/corpus/paragraph-edit/rust-split/native/notebook/Open Notebook.onetoc2 b/corpus/paragraph-edit/rust-split/native/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..d098f9c144e2d763a97f3afb08dd64fbf40ca2ce
Binary files /dev/null and b/corpus/paragraph-edit/rust-split/native/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/paragraph-edit/rust-split/native/notebook/synthetic.one b/corpus/paragraph-edit/rust-split/native/notebook/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..d94fcd2d93f08baf60705f2f6efaf3382771ac60
Binary files /dev/null and b/corpus/paragraph-edit/rust-split/native/notebook/synthetic.one differ
diff --git a/corpus/paragraph-edit/rust-split/native/read/environment.json b/corpus/paragraph-edit/rust-split/native/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..811feab20c52431eac39f4fd8a9941c2792dab1e
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/native/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-AA441F91",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/paragraph-edit/rust-split/native/read/hierarchy.xml b/corpus/paragraph-edit/rust-split/native/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..1a1fa721e275b1ad6dba521965ab3465b036cb4b
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/native/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/paragraph-edit/rust-split/native/read/page-000.xml b/corpus/paragraph-edit/rust-split/native/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..a9668c8465aaa3402ee6ed0d137a7e9392c836dd
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/native/read/page-000.xml
@@ -0,0 +1,6 @@
+
+Bo]]>ld italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-split/native/read/page-001.xml b/corpus/paragraph-edit/rust-split/native/read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..f17994e11b3e8151253fe9d0fb30a61ea082985c
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/native/read/page-001.xml
@@ -0,0 +1,3 @@
+
+Link label tail]]>
diff --git a/corpus/paragraph-edit/rust-split/native/read/page-002.xml b/corpus/paragraph-edit/rust-split/native/read/page-002.xml
new file mode 100644
index 0000000000000000000000000000000000000000..0bf5f878baccf05bb7b1018097bb1c70a34ac26e
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/native/read/page-002.xml
@@ -0,0 +1,6 @@
+
+Bo]]>ld italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-split/native/read/page-003.xml b/corpus/paragraph-edit/rust-split/native/read/page-003.xml
new file mode 100644
index 0000000000000000000000000000000000000000..1edfd62909024b699557b53a4160cf85a18f90f2
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/native/read/page-003.xml
@@ -0,0 +1,5 @@
+
+Bold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-split/native/read/page-004.xml b/corpus/paragraph-edit/rust-split/native/read/page-004.xml
new file mode 100644
index 0000000000000000000000000000000000000000..2d11ba8bb4ef29fc5d176c5965c193c71c8586c1
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/native/read/page-004.xml
@@ -0,0 +1,20 @@
+
+Bold 🦀 italic é color 東京
+End]]>Fictitious: café, 東京, مرحبا]]>
diff --git a/corpus/paragraph-edit/rust-split/native/read/page-005.xml b/corpus/paragraph-edit/rust-split/native/read/page-005.xml
new file mode 100644
index 0000000000000000000000000000000000000000..842263609e2e64c135bb2a723336f4f123605a7a
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/native/read/page-005.xml
@@ -0,0 +1,6 @@
+
+Bo]]>ld italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-split/native/read/page-006.xml b/corpus/paragraph-edit/rust-split/native/read/page-006.xml
new file mode 100644
index 0000000000000000000000000000000000000000..44cff92fdb875ec70dca04e86c13cac1202bf029
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/native/read/page-006.xml
@@ -0,0 +1,6 @@
+
+Bo]]>ld italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-split/native/read/page-007.xml b/corpus/paragraph-edit/rust-split/native/read/page-007.xml
new file mode 100644
index 0000000000000000000000000000000000000000..d0478450f77cd743ee35fa7b24a8226a1c58da3f
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/native/read/page-007.xml
@@ -0,0 +1,6 @@
+
+Bo]]>ld italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-split/native/read/page-008.xml b/corpus/paragraph-edit/rust-split/native/read/page-008.xml
new file mode 100644
index 0000000000000000000000000000000000000000..2404f24838a0e02484aefc92fa9fcc210be4df32
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/native/read/page-008.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/paragraph-edit/rust-split/native/read/page-009.xml b/corpus/paragraph-edit/rust-split/native/read/page-009.xml
new file mode 100644
index 0000000000000000000000000000000000000000..03a8e82cd85b0c700a3eabe7361f012e8281f8a1
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/native/read/page-009.xml
@@ -0,0 +1,5 @@
+
+Bold]]> italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-split/native/read/page-010.xml b/corpus/paragraph-edit/rust-split/native/read/page-010.xml
new file mode 100644
index 0000000000000000000000000000000000000000..f53de11177ec872120eb26097e1bbe56479b0e33
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/native/read/page-010.xml
@@ -0,0 +1,3 @@
+
+
+soft break]]>
diff --git a/corpus/paragraph-edit/rust-split/native/read/page-011.xml b/corpus/paragraph-edit/rust-split/native/read/page-011.xml
new file mode 100644
index 0000000000000000000000000000000000000000..b4669863a60eda90fcea80955dce12ca279fb445
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/native/read/page-011.xml
@@ -0,0 +1,6 @@
+
+Bo]]>ld italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-split/native/read/page-012.xml b/corpus/paragraph-edit/rust-split/native/read/page-012.xml
new file mode 100644
index 0000000000000000000000000000000000000000..60c49da4442fb8967b0338bb261888c5d42f6cdb
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/native/read/page-012.xml
@@ -0,0 +1,6 @@
+
+Bo]]>ld italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-split/native/read/page-013.xml b/corpus/paragraph-edit/rust-split/native/read/page-013.xml
new file mode 100644
index 0000000000000000000000000000000000000000..e5b3cb0a2c43e8e62a5d55b50f90a9841e5f625a
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/native/read/page-013.xml
@@ -0,0 +1,5 @@
+
+Bold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-split/native/read/payloads.json b/corpus/paragraph-edit/rust-split/native/read/payloads.json
new file mode 120000
index 0000000000000000000000000000000000000000..3cf45dc1acb611d7f764e0340d73e87192f81407
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/native/read/payloads.json
@@ -0,0 +1 @@
+../../../before/read/payloads.json
\ No newline at end of file
diff --git a/corpus/paragraph-edit/rust-split/native/run.json b/corpus/paragraph-edit/rust-split/native/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..75cde082d61b20c444453d6e556ac188337f6644
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/native/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/Users/clo/dev/one/evidence/m10/paragraph-rust-split-01/candidate",
+ "expected_pages": 14,
+ "author": null,
+ "author_timeout_seconds": 600,
+ "inspect": false,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
+ }
+}
diff --git a/corpus/paragraph-edit/rust-split/native/source.json b/corpus/paragraph-edit/rust-split/native/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..8c94d98a7932d0c052fdab6a0838b23b034786b3
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/native/source.json
@@ -0,0 +1,8 @@
+[
+ {
+ "path": "synthetic.one",
+ "bytes": 110224,
+ "sha256": "c07cf1d31c658534b7cd3c6f77dd48dd52a1b9be4ef037b3d016b24336a4c3f4",
+ "mtime_ns": 1788857461138239881
+ }
+]
diff --git a/corpus/paragraph-edit/rust-split/native/teardown.json b/corpus/paragraph-edit/rust-split/native/teardown.json
new file mode 120000
index 0000000000000000000000000000000000000000..b0d2ee26322df655cbbf9e42f6c30b72a4d110a5
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/native/teardown.json
@@ -0,0 +1 @@
+../../joined/teardown.json
\ No newline at end of file
diff --git a/corpus/paragraph-edit/rust-split/typed/before-read/environment.json b/corpus/paragraph-edit/rust-split/typed/before-read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..85febdc32b4c62e1097a8a3e8093d4a8a867ece6
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/before-read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-02D586B5",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/paragraph-edit/rust-split/typed/before-read/hierarchy.xml b/corpus/paragraph-edit/rust-split/typed/before-read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..d06f27e3d2490f6ba635ca49f278b8e75251af56
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/before-read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/paragraph-edit/rust-split/typed/before-read/page-000.xml b/corpus/paragraph-edit/rust-split/typed/before-read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..1f527e736cdfc381990e9305fa39195e5aeee6e6
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/before-read/page-000.xml
@@ -0,0 +1,5 @@
+
+Bold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-split/typed/before-read/page-001.xml b/corpus/paragraph-edit/rust-split/typed/before-read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..ee57a8f384bdb5c38bcf79afa057bb436b2c6334
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/before-read/page-001.xml
@@ -0,0 +1,6 @@
+
+Bo]]>ld italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-split/typed/before-read/page-002.xml b/corpus/paragraph-edit/rust-split/typed/before-read/page-002.xml
new file mode 100644
index 0000000000000000000000000000000000000000..19fdd7bc8f33e638e160766c0d86c187e88aa070
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/before-read/page-002.xml
@@ -0,0 +1,3 @@
+
+Link label tail]]>
diff --git a/corpus/paragraph-edit/rust-split/typed/before-read/page-003.xml b/corpus/paragraph-edit/rust-split/typed/before-read/page-003.xml
new file mode 100644
index 0000000000000000000000000000000000000000..ecafe255e8a8c3b04adcade2b7a0556a6720c734
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/before-read/page-003.xml
@@ -0,0 +1,6 @@
+
+Bo]]>ld italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-split/typed/before-read/page-004.xml b/corpus/paragraph-edit/rust-split/typed/before-read/page-004.xml
new file mode 100644
index 0000000000000000000000000000000000000000..9e064f0a16b64644a69d3776f3ed051dce651d62
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/before-read/page-004.xml
@@ -0,0 +1,20 @@
+
+Bold 🦀 italic é color 東京
+End]]>Fictitious: café, 東京, مرحبا]]>
diff --git a/corpus/paragraph-edit/rust-split/typed/before-read/page-005.xml b/corpus/paragraph-edit/rust-split/typed/before-read/page-005.xml
new file mode 100644
index 0000000000000000000000000000000000000000..5243351b67b5be8d005d15c516caa5bc0faefc7c
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/before-read/page-005.xml
@@ -0,0 +1,6 @@
+
+Bo]]>ld italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-split/typed/before-read/page-006.xml b/corpus/paragraph-edit/rust-split/typed/before-read/page-006.xml
new file mode 100644
index 0000000000000000000000000000000000000000..4ef49e6e614a744a497f4e378556c0889ad34627
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/before-read/page-006.xml
@@ -0,0 +1,6 @@
+
+Bo]]>ld italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-split/typed/before-read/page-007.xml b/corpus/paragraph-edit/rust-split/typed/before-read/page-007.xml
new file mode 100644
index 0000000000000000000000000000000000000000..377b10aa1567c2c493d94c393b4f2ec75e698665
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/before-read/page-007.xml
@@ -0,0 +1,6 @@
+
+Bo]]>ld italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-split/typed/before-read/page-008.xml b/corpus/paragraph-edit/rust-split/typed/before-read/page-008.xml
new file mode 100644
index 0000000000000000000000000000000000000000..99aa56a3aa5af936111fb180369103a8ffcf4b0e
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/before-read/page-008.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/paragraph-edit/rust-split/typed/before-read/page-009.xml b/corpus/paragraph-edit/rust-split/typed/before-read/page-009.xml
new file mode 100644
index 0000000000000000000000000000000000000000..997b60af44673400616150639a200ba051b04221
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/before-read/page-009.xml
@@ -0,0 +1,5 @@
+
+Bold]]> italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-split/typed/before-read/page-010.xml b/corpus/paragraph-edit/rust-split/typed/before-read/page-010.xml
new file mode 100644
index 0000000000000000000000000000000000000000..526b4eabee04951b37345a30b8271320d99c1137
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/before-read/page-010.xml
@@ -0,0 +1,3 @@
+
+
+soft break]]>
diff --git a/corpus/paragraph-edit/rust-split/typed/before-read/page-011.xml b/corpus/paragraph-edit/rust-split/typed/before-read/page-011.xml
new file mode 100644
index 0000000000000000000000000000000000000000..d0479a4a7ba278852ec7bd0f477b32558f531b28
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/before-read/page-011.xml
@@ -0,0 +1,6 @@
+
+Bo]]>ld italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-split/typed/before-read/page-012.xml b/corpus/paragraph-edit/rust-split/typed/before-read/page-012.xml
new file mode 100644
index 0000000000000000000000000000000000000000..6971a83b841b68f68153e456bc9f364da7ee1f8d
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/before-read/page-012.xml
@@ -0,0 +1,6 @@
+
+Bo]]>ld italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-split/typed/before-read/page-013.xml b/corpus/paragraph-edit/rust-split/typed/before-read/page-013.xml
new file mode 100644
index 0000000000000000000000000000000000000000..59689f1af558b09919de7f39bf4cd5c7fef83341
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/before-read/page-013.xml
@@ -0,0 +1,5 @@
+
+Bold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-split/typed/before-read/payloads.json b/corpus/paragraph-edit/rust-split/typed/before-read/payloads.json
new file mode 120000
index 0000000000000000000000000000000000000000..3cf45dc1acb611d7f764e0340d73e87192f81407
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/before-read/payloads.json
@@ -0,0 +1 @@
+../../../before/read/payloads.json
\ No newline at end of file
diff --git a/corpus/paragraph-edit/rust-split/typed/notebook/Open Notebook.onetoc2 b/corpus/paragraph-edit/rust-split/typed/notebook/Open Notebook.onetoc2
new file mode 120000
index 0000000000000000000000000000000000000000..a36d6b55613d6521df68617616a6bc584ff3bf48
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/notebook/Open Notebook.onetoc2
@@ -0,0 +1 @@
+../../native/notebook/Open Notebook.onetoc2
\ No newline at end of file
diff --git a/corpus/paragraph-edit/rust-split/typed/notebook/synthetic.one b/corpus/paragraph-edit/rust-split/typed/notebook/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..a76c576ffb1abf83c1ac548f076b71077274db13
Binary files /dev/null and b/corpus/paragraph-edit/rust-split/typed/notebook/synthetic.one differ
diff --git a/corpus/paragraph-edit/rust-split/typed/read/environment.json b/corpus/paragraph-edit/rust-split/typed/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..5ca67f6fb1d036ac95773ceb0f63fa4054269c43
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-02D586B5",
+ "cold": false,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/paragraph-edit/rust-split/typed/read/hierarchy.xml b/corpus/paragraph-edit/rust-split/typed/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..128219ee630ac2ac04188f22df8e05fe06a732ea
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/paragraph-edit/rust-split/typed/read/page-000.xml b/corpus/paragraph-edit/rust-split/typed/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..3917e00aded6a5e2fdd79d873ddf1d9b2354e5f9
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/read/page-000.xml
@@ -0,0 +1,5 @@
+
+Bold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-split/typed/read/page-001.xml b/corpus/paragraph-edit/rust-split/typed/read/page-001.xml
new file mode 120000
index 0000000000000000000000000000000000000000..145194458110015f127fb3a46bc43b26504912ca
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/read/page-001.xml
@@ -0,0 +1 @@
+../before-read/page-001.xml
\ No newline at end of file
diff --git a/corpus/paragraph-edit/rust-split/typed/read/page-002.xml b/corpus/paragraph-edit/rust-split/typed/read/page-002.xml
new file mode 120000
index 0000000000000000000000000000000000000000..7abfb30faff0251ae0ef47e7ecd825b352db3681
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/read/page-002.xml
@@ -0,0 +1 @@
+../before-read/page-002.xml
\ No newline at end of file
diff --git a/corpus/paragraph-edit/rust-split/typed/read/page-003.xml b/corpus/paragraph-edit/rust-split/typed/read/page-003.xml
new file mode 120000
index 0000000000000000000000000000000000000000..39c1498839527129786dde8c7a2ea388309c5e98
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/read/page-003.xml
@@ -0,0 +1 @@
+../before-read/page-003.xml
\ No newline at end of file
diff --git a/corpus/paragraph-edit/rust-split/typed/read/page-004.xml b/corpus/paragraph-edit/rust-split/typed/read/page-004.xml
new file mode 100644
index 0000000000000000000000000000000000000000..e0e354a605e0c94cc1b063cc0aec6977d7141c09
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/read/page-004.xml
@@ -0,0 +1,20 @@
+
+Bold 🦀 italic é color 東京
+End]]>Fictitious: café, 東京, مرحبا]]>
diff --git a/corpus/paragraph-edit/rust-split/typed/read/page-005.xml b/corpus/paragraph-edit/rust-split/typed/read/page-005.xml
new file mode 120000
index 0000000000000000000000000000000000000000..28293914b5e97a92909e2dd758ae7ec90d626069
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/read/page-005.xml
@@ -0,0 +1 @@
+../before-read/page-005.xml
\ No newline at end of file
diff --git a/corpus/paragraph-edit/rust-split/typed/read/page-006.xml b/corpus/paragraph-edit/rust-split/typed/read/page-006.xml
new file mode 120000
index 0000000000000000000000000000000000000000..d238f4bffd7ce07f0077616b2fd91132f21cecf5
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/read/page-006.xml
@@ -0,0 +1 @@
+../before-read/page-006.xml
\ No newline at end of file
diff --git a/corpus/paragraph-edit/rust-split/typed/read/page-007.xml b/corpus/paragraph-edit/rust-split/typed/read/page-007.xml
new file mode 120000
index 0000000000000000000000000000000000000000..8ca327c46e02e25ad46fd0cd632c0246b69b2858
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/read/page-007.xml
@@ -0,0 +1 @@
+../before-read/page-007.xml
\ No newline at end of file
diff --git a/corpus/paragraph-edit/rust-split/typed/read/page-008.xml b/corpus/paragraph-edit/rust-split/typed/read/page-008.xml
new file mode 100644
index 0000000000000000000000000000000000000000..cc6661a5df25bff662c77431973d37e3248542f3
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/read/page-008.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/paragraph-edit/rust-split/typed/read/page-009.xml b/corpus/paragraph-edit/rust-split/typed/read/page-009.xml
new file mode 120000
index 0000000000000000000000000000000000000000..d7a70e4fc176f81797798e161c6ffaac7dd4f066
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/read/page-009.xml
@@ -0,0 +1 @@
+../before-read/page-009.xml
\ No newline at end of file
diff --git a/corpus/paragraph-edit/rust-split/typed/read/page-010.xml b/corpus/paragraph-edit/rust-split/typed/read/page-010.xml
new file mode 120000
index 0000000000000000000000000000000000000000..037ed30fb57e17b3e19d3f9af87d476c2ff02e77
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/read/page-010.xml
@@ -0,0 +1 @@
+../before-read/page-010.xml
\ No newline at end of file
diff --git a/corpus/paragraph-edit/rust-split/typed/read/page-011.xml b/corpus/paragraph-edit/rust-split/typed/read/page-011.xml
new file mode 120000
index 0000000000000000000000000000000000000000..5d6e25667baca93299dffa8ee65a1f002997b4b0
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/read/page-011.xml
@@ -0,0 +1 @@
+../before-read/page-011.xml
\ No newline at end of file
diff --git a/corpus/paragraph-edit/rust-split/typed/read/page-012.xml b/corpus/paragraph-edit/rust-split/typed/read/page-012.xml
new file mode 120000
index 0000000000000000000000000000000000000000..9c9cfd64e953f4b49f8c3868dea5d3b4ba24dfc2
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/read/page-012.xml
@@ -0,0 +1 @@
+../before-read/page-012.xml
\ No newline at end of file
diff --git a/corpus/paragraph-edit/rust-split/typed/read/page-013.xml b/corpus/paragraph-edit/rust-split/typed/read/page-013.xml
new file mode 100644
index 0000000000000000000000000000000000000000..364cc0e312597602131d3875b21b9be6068f1c6d
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/read/page-013.xml
@@ -0,0 +1,5 @@
+
+Bold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-split/typed/read/payloads.json b/corpus/paragraph-edit/rust-split/typed/read/payloads.json
new file mode 120000
index 0000000000000000000000000000000000000000..3cf45dc1acb611d7f764e0340d73e87192f81407
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/read/payloads.json
@@ -0,0 +1 @@
+../../../before/read/payloads.json
\ No newline at end of file
diff --git a/corpus/paragraph-edit/rust-split/typed/run.json b/corpus/paragraph-edit/rust-split/typed/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..57b54ecb5b215fd335e95ac18d3ff92d09e84f99
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/Users/clo/dev/one/evidence/m10/paragraph-rust-split-01/native/notebook",
+ "expected_pages": 14,
+ "author": null,
+ "author_timeout_seconds": 600,
+ "inspect": true,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
+ }
+}
diff --git a/corpus/paragraph-edit/rust-split/typed/source.json b/corpus/paragraph-edit/rust-split/typed/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..50ad111d8faa56e60244e599c7a1a972e49ddcb3
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/source.json
@@ -0,0 +1,14 @@
+[
+ {
+ "path": "Open Notebook.onetoc2",
+ "bytes": 4800,
+ "sha256": "7c258d318aaa6089989a7beb427a820beb99ebd7833f2a76ecbbb3713df07dbb",
+ "mtime_ns": 1788857651415033964
+ },
+ {
+ "path": "synthetic.one",
+ "bytes": 110224,
+ "sha256": "29a64584c4d7ac72958595769854a3df199156cb89541df354d9e9f484450712",
+ "mtime_ns": 1788857651415524756
+ }
+]
diff --git a/corpus/paragraph-edit/rust-split/typed/teardown.json b/corpus/paragraph-edit/rust-split/typed/teardown.json
new file mode 120000
index 0000000000000000000000000000000000000000..b0d2ee26322df655cbbf9e42f6c30b72a4d110a5
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/teardown.json
@@ -0,0 +1 @@
+../../joined/teardown.json
\ No newline at end of file
diff --git a/corpus/paragraph-edit/rust-split/typed/ui/selections.json b/corpus/paragraph-edit/rust-split/typed/ui/selections.json
new file mode 100644
index 0000000000000000000000000000000000000000..604e7b9f345a4899dc0fc581056828633618a509
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/ui/selections.json
@@ -0,0 +1,30 @@
+[
+ {
+ "case": "Split start",
+ "index": 0,
+ "page": "{058E24C9-6521-4913-9088-94646869E7EC}{1}{B0}",
+ "object": "{7DB914B1-2E9B-4E51-B78C-87B90E14A385}{40}{B0}",
+ "text": "Typed "
+ },
+ {
+ "case": "Split end",
+ "index": 1,
+ "page": "{EEE8E5F3-7A14-4AA5-B06A-56D4BAC7C8AF}{1}{B0}",
+ "object": "{F60211DF-6E7D-0CA1-2F70-07201B2B1F07}{1}{B0}",
+ "text": "Typed "
+ },
+ {
+ "case": "Split empty",
+ "index": 0,
+ "page": "{984F0936-3DA3-4D19-B112-F3CA259A4148}{1}{B0}",
+ "object": "{77D1B2CB-BFAF-4106-8E28-90895EBDFE03}{40}{B0}",
+ "text": "Typed "
+ },
+ {
+ "case": "Split empty",
+ "index": 1,
+ "page": "{984F0936-3DA3-4D19-B112-F3CA259A4148}{1}{B0}",
+ "object": "{F8CC0112-769C-0624-19AF-E3D7C46B02D1}{1}{B0}",
+ "text": "Typed "
+ }
+]
\ No newline at end of file
diff --git a/corpus/paragraph-edit/rust-split/typed/ui/split-empty-0.ahk b/corpus/paragraph-edit/rust-split/typed/ui/split-empty-0.ahk
new file mode 100644
index 0000000000000000000000000000000000000000..cd7e3139b8b371dbfccc8e6df83d5240bbadee8c
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/ui/split-empty-0.ahk
@@ -0,0 +1,23 @@
+#Requires AutoHotkey v2.0
+OnError((exception, mode) => (FileAppend(exception.Message, "**"), ExitApp(1)))
+dm := Buffer(220, 0)
+NumPut("UShort", 220, dm, 68)
+if !DllCall("EnumDisplaySettingsW", "Ptr", 0, "UInt", 0xFFFFFFFF, "Ptr", dm)
+ throw Error("Cannot inspect display mode")
+NumPut("UInt", NumGet(dm, 72, "UInt") | 0x180000, dm, 72)
+NumPut("UInt", 1280, dm, 172)
+NumPut("UInt", 720, dm, 176)
+if DllCall("ChangeDisplaySettingsW", "Ptr", dm, "UInt", 0, "Int") != 0
+ throw Error("Display mode rejected")
+app := ComObject("OneNote.Application")
+app.NavigateTo("{984F0936-3DA3-4D19-B112-F3CA259A4148}{1}{B0}", "{77D1B2CB-BFAF-4106-8E28-90895EBDFE03}{40}{B0}", false)
+hwnd := WinWait("ahk_class Framework::CFrame ahk_exe ONENOTE.EXE",, 10)
+if !hwnd
+ throw Error("OneNote window did not appear")
+WinMaximize(hwnd)
+WinActivate(hwnd)
+if !WinWaitActive(hwnd,, 10)
+ throw Error("OneNote window did not become active")
+Sleep 300
+Send "{Left}"
+SendText "Typed "
diff --git a/corpus/paragraph-edit/rust-split/typed/ui/split-empty-0.json b/corpus/paragraph-edit/rust-split/typed/ui/split-empty-0.json
new file mode 120000
index 0000000000000000000000000000000000000000..e05935d2e8052dbcef4ce8e5a4c0f7cc6af8ce3c
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/ui/split-empty-0.json
@@ -0,0 +1 @@
+../../../joined/ui/split-empty.json
\ No newline at end of file
diff --git a/corpus/paragraph-edit/rust-split/typed/ui/split-empty-0.png b/corpus/paragraph-edit/rust-split/typed/ui/split-empty-0.png
new file mode 100644
index 0000000000000000000000000000000000000000..94c12d501890f4c760c0db9b34c087eba3420ed6
Binary files /dev/null and b/corpus/paragraph-edit/rust-split/typed/ui/split-empty-0.png differ
diff --git a/corpus/paragraph-edit/rust-split/typed/ui/split-empty-1.ahk b/corpus/paragraph-edit/rust-split/typed/ui/split-empty-1.ahk
new file mode 100644
index 0000000000000000000000000000000000000000..e0e075d2a05a22c9ba696c2412b39d8eac82d19c
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/ui/split-empty-1.ahk
@@ -0,0 +1,23 @@
+#Requires AutoHotkey v2.0
+OnError((exception, mode) => (FileAppend(exception.Message, "**"), ExitApp(1)))
+dm := Buffer(220, 0)
+NumPut("UShort", 220, dm, 68)
+if !DllCall("EnumDisplaySettingsW", "Ptr", 0, "UInt", 0xFFFFFFFF, "Ptr", dm)
+ throw Error("Cannot inspect display mode")
+NumPut("UInt", NumGet(dm, 72, "UInt") | 0x180000, dm, 72)
+NumPut("UInt", 1280, dm, 172)
+NumPut("UInt", 720, dm, 176)
+if DllCall("ChangeDisplaySettingsW", "Ptr", dm, "UInt", 0, "Int") != 0
+ throw Error("Display mode rejected")
+app := ComObject("OneNote.Application")
+app.NavigateTo("{984F0936-3DA3-4D19-B112-F3CA259A4148}{1}{B0}", "{F8CC0112-769C-0624-19AF-E3D7C46B02D1}{1}{B0}", false)
+hwnd := WinWait("ahk_class Framework::CFrame ahk_exe ONENOTE.EXE",, 10)
+if !hwnd
+ throw Error("OneNote window did not appear")
+WinMaximize(hwnd)
+WinActivate(hwnd)
+if !WinWaitActive(hwnd,, 10)
+ throw Error("OneNote window did not become active")
+Sleep 300
+Send "{Left}"
+SendText "Typed "
diff --git a/corpus/paragraph-edit/rust-split/typed/ui/split-empty-1.json b/corpus/paragraph-edit/rust-split/typed/ui/split-empty-1.json
new file mode 120000
index 0000000000000000000000000000000000000000..e05935d2e8052dbcef4ce8e5a4c0f7cc6af8ce3c
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/ui/split-empty-1.json
@@ -0,0 +1 @@
+../../../joined/ui/split-empty.json
\ No newline at end of file
diff --git a/corpus/paragraph-edit/rust-split/typed/ui/split-empty-1.png b/corpus/paragraph-edit/rust-split/typed/ui/split-empty-1.png
new file mode 100644
index 0000000000000000000000000000000000000000..a0b933ccf1c78cbe39065f091bb8a541e5553524
Binary files /dev/null and b/corpus/paragraph-edit/rust-split/typed/ui/split-empty-1.png differ
diff --git a/corpus/paragraph-edit/rust-split/typed/ui/split-end-1.ahk b/corpus/paragraph-edit/rust-split/typed/ui/split-end-1.ahk
new file mode 100644
index 0000000000000000000000000000000000000000..c2961c0d7722b32680ba53bc822a8d2641d6e031
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/ui/split-end-1.ahk
@@ -0,0 +1,23 @@
+#Requires AutoHotkey v2.0
+OnError((exception, mode) => (FileAppend(exception.Message, "**"), ExitApp(1)))
+dm := Buffer(220, 0)
+NumPut("UShort", 220, dm, 68)
+if !DllCall("EnumDisplaySettingsW", "Ptr", 0, "UInt", 0xFFFFFFFF, "Ptr", dm)
+ throw Error("Cannot inspect display mode")
+NumPut("UInt", NumGet(dm, 72, "UInt") | 0x180000, dm, 72)
+NumPut("UInt", 1280, dm, 172)
+NumPut("UInt", 720, dm, 176)
+if DllCall("ChangeDisplaySettingsW", "Ptr", dm, "UInt", 0, "Int") != 0
+ throw Error("Display mode rejected")
+app := ComObject("OneNote.Application")
+app.NavigateTo("{EEE8E5F3-7A14-4AA5-B06A-56D4BAC7C8AF}{1}{B0}", "{F60211DF-6E7D-0CA1-2F70-07201B2B1F07}{1}{B0}", false)
+hwnd := WinWait("ahk_class Framework::CFrame ahk_exe ONENOTE.EXE",, 10)
+if !hwnd
+ throw Error("OneNote window did not appear")
+WinMaximize(hwnd)
+WinActivate(hwnd)
+if !WinWaitActive(hwnd,, 10)
+ throw Error("OneNote window did not become active")
+Sleep 300
+Send "{Left}"
+SendText "Typed "
diff --git a/corpus/paragraph-edit/rust-split/typed/ui/split-end-1.json b/corpus/paragraph-edit/rust-split/typed/ui/split-end-1.json
new file mode 120000
index 0000000000000000000000000000000000000000..09fdbf0e38d0228a3e637bd7e9644b9d366549f9
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/ui/split-end-1.json
@@ -0,0 +1 @@
+../../../joined/ui/split-end.json
\ No newline at end of file
diff --git a/corpus/paragraph-edit/rust-split/typed/ui/split-end-1.png b/corpus/paragraph-edit/rust-split/typed/ui/split-end-1.png
new file mode 100644
index 0000000000000000000000000000000000000000..c38f66a9dbee53b087821b60b7498a42661dc1b0
Binary files /dev/null and b/corpus/paragraph-edit/rust-split/typed/ui/split-end-1.png differ
diff --git a/corpus/paragraph-edit/rust-split/typed/ui/split-start-0.ahk b/corpus/paragraph-edit/rust-split/typed/ui/split-start-0.ahk
new file mode 100644
index 0000000000000000000000000000000000000000..c374ff646a887af04b9830fc8bacc7c28a776ae7
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/ui/split-start-0.ahk
@@ -0,0 +1,23 @@
+#Requires AutoHotkey v2.0
+OnError((exception, mode) => (FileAppend(exception.Message, "**"), ExitApp(1)))
+dm := Buffer(220, 0)
+NumPut("UShort", 220, dm, 68)
+if !DllCall("EnumDisplaySettingsW", "Ptr", 0, "UInt", 0xFFFFFFFF, "Ptr", dm)
+ throw Error("Cannot inspect display mode")
+NumPut("UInt", NumGet(dm, 72, "UInt") | 0x180000, dm, 72)
+NumPut("UInt", 1280, dm, 172)
+NumPut("UInt", 720, dm, 176)
+if DllCall("ChangeDisplaySettingsW", "Ptr", dm, "UInt", 0, "Int") != 0
+ throw Error("Display mode rejected")
+app := ComObject("OneNote.Application")
+app.NavigateTo("{058E24C9-6521-4913-9088-94646869E7EC}{1}{B0}", "{7DB914B1-2E9B-4E51-B78C-87B90E14A385}{40}{B0}", false)
+hwnd := WinWait("ahk_class Framework::CFrame ahk_exe ONENOTE.EXE",, 10)
+if !hwnd
+ throw Error("OneNote window did not appear")
+WinMaximize(hwnd)
+WinActivate(hwnd)
+if !WinWaitActive(hwnd,, 10)
+ throw Error("OneNote window did not become active")
+Sleep 300
+Send "{Left}"
+SendText "Typed "
diff --git a/corpus/paragraph-edit/rust-split/typed/ui/split-start-0.json b/corpus/paragraph-edit/rust-split/typed/ui/split-start-0.json
new file mode 120000
index 0000000000000000000000000000000000000000..3d98eda4565545331e305ce936e9c787448aa0dc
--- /dev/null
+++ b/corpus/paragraph-edit/rust-split/typed/ui/split-start-0.json
@@ -0,0 +1 @@
+../../../joined/ui/split-start.json
\ No newline at end of file
diff --git a/corpus/paragraph-edit/rust-split/typed/ui/split-start-0.png b/corpus/paragraph-edit/rust-split/typed/ui/split-start-0.png
new file mode 100644
index 0000000000000000000000000000000000000000..576e945b2a98eed8e208d7e7d847d6251430f33d
Binary files /dev/null and b/corpus/paragraph-edit/rust-split/typed/ui/split-start-0.png differ
diff --git a/crates/onestore/README.md b/crates/onestore/README.md
index 2e63064f53df82ca64ab15fb2a70604d78bd5ce6..e029c43cadc0257e54ea518ef6d1e30f6632f27c 100644
--- a/crates/onestore/README.md
+++ b/crates/onestore/README.md
@@ -49,6 +49,7 @@ harness also accepts `--client-profile release`.
| `replace_property_bytes` | Append one scalar-property revision; preserve prior revisions and unrelated property values and references |
| `replace_text`, `commit_text`, `commit_file_text` | Replace a UTF-16 range across ordinary text runs; publish text, run boundaries and modification time together |
| `Insertion`, `PreparedEdit::insert` | Insert paragraphs into editable containers or positioned outlines into a page, retaining intent identities across rebases |
+| `ParagraphSplit`, `PreparedEdit::split` | Split ordinary text at a UTF-16 scalar boundary, retaining the original left identities and moving children to the right |
| `TextAttribute`, `PreparedEdit::format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs |
| `PreparedEdit::commit`, `PreparedEdit::commit_file` | Publish the exact prepared image under caller-held exclusion or the conservative filesystem adapter |
| `read_file` | Read a snapshot under whole-file exclusion |
@@ -77,6 +78,13 @@ accepts one `0..0` span for subsequent typing. Retain the `Insertion` value for
value creates different object identities. Duplicate insertion identities require
reconciliation. Formatting accepts explicit attributes, preserves inherited values,
and gives retired immutable styles zero current references while retaining history.
+Paragraph splits preserve character formatting, retain tags on the left, and clone
+mutable list objects without restarting numbering. The new right paragraph/text
+identities belong to the retained `ParagraphSplit` intent. Its publication includes
+the complete child graph and title metadata; repeating an existing identity requires
+reconciliation. Title containers, generated fields, recording-linked text and
+associated run metadata are rejected before I/O. Native split controls and subsequent
+typing checks reside in [the paragraph corpus](../../corpus/paragraph-edit/README.md).
Generated fields, protected targets and unsupported run-data boundary changes are
rejected before publication. Local caches expose text, insertion and formatting edits;
the [document-writer acceptance](../../evidence/MILESTONE9.md#document-writer-and-offline-acceptance)
@@ -215,6 +223,7 @@ subsets of unflushed bytes and is shared with the stateful commit fuzzer.
```sh
cargo +nightly fuzz run revisions -- -max_total_time=120 -max_len=262144 -rss_limit_mb=2048
cargo +nightly fuzz run commit -- -max_total_time=300 -max_len=4096 -rss_limit_mb=2048
+cargo +nightly fuzz run paragraph -- -max_total_time=120 -max_len=160 -rss_limit_mb=2048
```
Fuzz targets cover storage, properties, revisions, scalar edits, creation, and
diff --git a/crates/onestore/src/commit.rs b/crates/onestore/src/commit.rs
index ccd041f819c516faa7c76dfc7740d4e69ef687cc..dbc621e4e78b394a7cf6df0fd6cf7a56099b682c 100644
--- a/crates/onestore/src/commit.rs
+++ b/crates/onestore/src/commit.rs
@@ -183,6 +183,19 @@ pub struct PreparedEdit<'a> {
}
impl<'a> PreparedEdit<'a> {
+ /// Splits a paragraph and updates its children, lists, tags and title metadata atomically.
+ /// Fields and associated run metadata are rejected before I/O.
+ pub fn split(
+ source: &'a [u8],
+ space: ExGuid,
+ split: &crate::ParagraphSplit,
+ ) -> Result {
+ Ok(Self {
+ source,
+ written: split.apply(source, space)?,
+ })
+ }
+
/// Prepares an insertion and its dependent metadata in one revision, without I/O.
pub fn insert(
source: &'a [u8],
diff --git a/crates/onestore/src/lib.rs b/crates/onestore/src/lib.rs
index 70eb0e618e32bc1e3f9eea14ea1ad90ba1bbd717..9eac9aa570e7f35e36f497f9f2d791d773d38af8 100644
--- a/crates/onestore/src/lib.rs
+++ b/crates/onestore/src/lib.rs
@@ -11,6 +11,7 @@ mod flush;
mod formatting;
mod insertion;
mod objects;
+mod paragraph;
mod properties;
#[cfg(feature = "protected")]
pub mod protected;
@@ -31,6 +32,7 @@ pub use files::FileDataReference;
pub use formatting::TextAttribute;
pub use insertion::Insertion;
pub use objects::{Object, ObjectData, ObjectReferences, ResolvedRevision};
+pub use paragraph::ParagraphSplit;
pub use properties::{IdStream, Property, PropertySets, Value};
pub use revisions::{ExGuid, ObjectSpace, Revision, RevisionIndex};
pub use snapshot::{read_snapshot, read_storage_snapshot};
diff --git a/crates/onestore/src/paragraph.rs b/crates/onestore/src/paragraph.rs
new file mode 100644
index 0000000000000000000000000000000000000000..0d96074ac3ecbddf7b3f14376fb6d2c37914d625
--- /dev/null
+++ b/crates/onestore/src/paragraph.rs
@@ -0,0 +1,383 @@
+use crate::{
+ Error, ExGuid, Object, ObjectData, PropertySets, RevisionIndex, Store,
+ create::{current_timestamps, properties, string},
+ document::{Document, Element, Kind},
+ edit::{editable_parents, page_title},
+ write::{PropertyObject, fresh_guid, write_revision},
+};
+use serde::{Deserialize, Serialize};
+use std::{
+ collections::{BTreeMap, BTreeSet},
+ ops::Range,
+ sync::Arc,
+};
+
+fn invalid(message: &'static str) -> Error {
+ Error { offset: 0, message }
+}
+
+/// Splits ordinary paragraph text while retaining the new objects' identities across retries.
+/// The original paragraph/text remain on the left; nested children move to the right.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct ParagraphSplit {
+ guid: [u8; 16],
+ text: ExGuid,
+ offset: u32,
+ author: String,
+ created: u32,
+}
+
+impl ParagraphSplit {
+ /// The offset is measured in UTF-16 code units and must lie between Unicode scalars.
+ pub fn new(text: ExGuid, offset: u32, author: &str) -> Result {
+ if text.guid == [0; 16] || author.contains('\0') {
+ return Err(invalid(
+ "Choose paragraph text and an author name without NUL",
+ ));
+ }
+ Ok(Self {
+ guid: fresh_guid()?,
+ text,
+ offset,
+ author: author.to_owned(),
+ created: current_timestamps()?.0,
+ })
+ }
+
+ /// Identity of the new right paragraph.
+ pub fn object(&self) -> ExGuid {
+ ExGuid {
+ guid: self.guid,
+ n: 1,
+ }
+ }
+
+ /// Identity of the new right paragraph's text.
+ pub fn text_object(&self) -> ExGuid {
+ ExGuid {
+ guid: self.guid,
+ n: 2,
+ }
+ }
+
+ pub(crate) fn apply(&self, source: &[u8], space: ExGuid) -> Result, Error> {
+ if self.guid == [0; 16] || self.text.guid == [0; 16] || self.author.contains('\0') {
+ return Err(invalid(
+ "Choose paragraph text and an author name without NUL",
+ ));
+ }
+ let store = Store::parse(source)?;
+ let index = RevisionIndex::parse(&store)?;
+ index.validate_current()?;
+ let mut document = Document::parse(&index)?;
+ let pages: Vec<_> = document
+ .pages()?
+ .into_iter()
+ .filter_map(|(sid, page)| (sid == space).then_some(page))
+ .collect();
+ let [page] = pages.as_slice() else {
+ return Err(invalid("Splitting requires a single active page"));
+ };
+ let semantic = document
+ .spaces
+ .remove(&space)
+ .ok_or_else(|| invalid("The active page is unavailable"))?;
+ let rid = semantic.contexts[&ExGuid::default()];
+ let mut view = semantic
+ .revisions
+ .into_iter()
+ .find_map(|(id, view)| (id == rid).then_some(view))
+ .unwrap();
+ let parents = editable_parents(&view, &pages, self.text)?;
+ let [paragraph] = parents
+ .get(&self.text)
+ .map(Vec::as_slice)
+ .unwrap_or_default()
+ else {
+ return Err(invalid("Select text belonging to one paragraph"));
+ };
+ let [parent] = parents
+ .get(paragraph)
+ .map(Vec::as_slice)
+ .unwrap_or_default()
+ else {
+ return Err(invalid("Select a paragraph with one parent"));
+ };
+ let left = &view.nodes[paragraph];
+ let Kind::Paragraph { lists, .. } = &left.kind else {
+ return Err(invalid("Select ordinary paragraph text"));
+ };
+ if left.content != [self.text]
+ || !matches!(
+ view.nodes[parent].kind,
+ Kind::Outline { .. }
+ | Kind::OutlineGroup
+ | Kind::Paragraph { .. }
+ | Kind::Cell { .. }
+ )
+ {
+ return Err(invalid(
+ "Select ordinary paragraph text inside an outline or table cell",
+ ));
+ }
+ let mut ancestors = BTreeSet::new();
+ let mut pending = vec![*paragraph];
+ while let Some(id) = pending.pop() {
+ if !ancestors.insert(id) {
+ continue;
+ }
+ if matches!(view.nodes[&id].kind, Kind::Title) {
+ return Err(invalid(
+ "Title containers cannot be split into ordinary paragraphs",
+ ));
+ }
+ pending.extend(parents.get(&id).into_iter().flatten().copied());
+ }
+ let node = &view.nodes[&self.text];
+ let Kind::RichText {
+ text,
+ runs,
+ boilerplate,
+ ..
+ } = &node.kind
+ else {
+ return Err(invalid("Select ordinary paragraph text"));
+ };
+ if *boilerplate || !node.media_ids.is_empty() || node.media_time_ms.is_some() {
+ return Err(invalid(
+ "Generated or recording-linked text cannot be split",
+ ));
+ }
+ for run in view.text_runs(self.text)? {
+ if [
+ run.format.hidden,
+ run.format.hyperlink,
+ run.format.math,
+ run.format.embedded_object,
+ ]
+ .contains(&Some(true))
+ || run.text.contains(['\u{fffc}', '\u{fddf}'])
+ {
+ return Err(invalid(
+ "This paragraph contains a field or embedded object that cannot be split",
+ ));
+ }
+ }
+ let raw = index.resolve(space, rid)?;
+ let ObjectData::Properties(blob) = raw.objects[&self.text].data else {
+ unreachable!()
+ };
+ if PropertySets::parse(blob)?.sets[0]
+ .iter()
+ .any(|p| matches!(p.id, 0x40003499 | 0x24003458))
+ {
+ return Err(invalid(
+ "This paragraph contains run metadata that cannot be split",
+ ));
+ }
+ let length = u32::try_from(text.encode_utf16().count())
+ .map_err(|_| invalid("Paragraph exceeds the UTF-16 offset range"))?;
+ if self.offset > length || lists.len() > 251 {
+ return Err(invalid(
+ "Choose a position within the paragraph and at most 251 list levels",
+ ));
+ }
+ let author_id = ExGuid {
+ guid: self.guid,
+ n: 3,
+ };
+ let normal_id = ExGuid {
+ guid: self.guid,
+ n: 4,
+ };
+ let typing = runs.last().and_then(|run| run.format).unwrap_or(normal_id);
+ let modified = current_timestamps()?.0.to_le_bytes();
+ let mut changed = BTreeMap::new();
+ if (self.offset == 0 && !text.is_empty()) || runs.iter().any(|run| run.format.is_none()) {
+ changed.insert(
+ normal_id,
+ PropertyObject {
+ jcid: 0x12004d,
+ bytes: properties(&[])?,
+ global_ids: Arc::new(BTreeMap::from([(0, self.guid)])),
+ },
+ );
+ }
+ let mut prefix = fragment(
+ &raw.objects[&self.text],
+ node,
+ 0..self.offset,
+ if text.is_empty() { typing } else { normal_id },
+ )?;
+ let mut suffix = fragment(&raw.objects[&self.text], node, self.offset..length, typing)?;
+ prefix.set(&[(0x14001d7a, &modified)])?;
+ suffix.remove(&[0x40003489])?;
+ suffix.set(&[(0x14001d7a, &modified)])?;
+ suffix.reference(self.text_object())?;
+ changed.insert(self.text, prefix);
+ changed.insert(self.text_object(), suffix);
+ changed.insert(
+ author_id,
+ PropertyObject {
+ jcid: 0x120001,
+ bytes: properties(&[(0x1c001d75, string(&self.author))])?,
+ global_ids: Arc::new(BTreeMap::from([(0, self.guid)])),
+ },
+ );
+ let mut right = PropertyObject::from_object(&raw.objects[paragraph])?;
+ let content = right.reference(self.text_object())?;
+ let author = right.reference(author_id)?;
+ right.set(&[
+ (0x24001c1f, &content),
+ (0x14001d09, &self.created.to_le_bytes()),
+ (0x20001d78, &author),
+ (0x20001d79, &author),
+ (0x14001d7a, &modified),
+ ])?;
+ if !lists.is_empty() {
+ let mut references = Vec::new();
+ for (i, old) in lists.iter().enumerate() {
+ if !matches!(view.nodes[old].kind, Kind::List { .. }) {
+ return Err(invalid("The paragraph list is unavailable"));
+ }
+ let id = ExGuid {
+ guid: self.guid,
+ n: 5 + u32::try_from(i).unwrap(),
+ };
+ let mut list = PropertyObject::from_object(&raw.objects[old])?;
+ list.remove(&[0x14001cb7])?;
+ list.reference(id)?;
+ references.extend_from_slice(&right.reference(id)?);
+ changed.insert(id, list);
+ }
+ right.set(&[(0x24001c26, &references)])?;
+ }
+ changed.insert(self.object(), right);
+ let mut original = PropertyObject::from_object(&raw.objects[paragraph])?;
+ original.remove(&[0x24001c20])?;
+ let author = original.reference(author_id)?;
+ original.set(&[(0x20001d79, &author), (0x14001d7a, &modified)])?;
+ changed.insert(*paragraph, original);
+ let mut parent_object = PropertyObject::from_object(&raw.objects[parent])?;
+ let mut children = Vec::new();
+ for child in &view.nodes[parent].children {
+ children.extend_from_slice(&parent_object.reference(*child)?);
+ if child == paragraph {
+ children.extend_from_slice(&parent_object.reference(self.object())?);
+ }
+ }
+ parent_object.set(&[(0x24001c20, &children)])?;
+ changed.insert(*parent, parent_object);
+ if changed
+ .keys()
+ .any(|id| id.guid == self.guid && raw.objects.contains_key(id))
+ {
+ return Err(invalid(
+ "A split identity already exists; reconcile the existing edit",
+ ));
+ }
+ for id in ancestors {
+ let object = match changed.entry(id) {
+ std::collections::btree_map::Entry::Occupied(entry) => entry.into_mut(),
+ std::collections::btree_map::Entry::Vacant(entry) => {
+ entry.insert(PropertyObject::from_object(&raw.objects[&id])?)
+ }
+ };
+ object.set(&[(0x14001d7a, &modified)])?;
+ }
+ for (id, object) in &changed {
+ view.nodes.insert(
+ *id,
+ Element::parse(
+ &Object {
+ jcid: object.jcid,
+ reference_count: 0,
+ data: ObjectData::Properties(&object.bytes),
+ global_ids: Arc::clone(&object.global_ids),
+ },
+ &store,
+ )?,
+ );
+ }
+ let title = page_title(&view, &pages, None)?;
+ drop(view);
+ if let Some((_, automatic, title)) = title {
+ let metadata = raw
+ .roots
+ .get(&2)
+ .ok_or_else(|| invalid("Page title metadata is unavailable"))?;
+ if raw.objects[metadata].jcid != 0x20030 {
+ return Err(invalid("Page title metadata is unavailable"));
+ }
+ let title = string(&title);
+ let mut object = PropertyObject::from_object(&raw.objects[metadata])?;
+ object.set(&[(0x1c001cf3, &title)])?;
+ changed.insert(*metadata, object);
+ changed
+ .get_mut(page)
+ .unwrap()
+ .set(&[(0x1c001d3c, if automatic { &title } else { &[0, 0] })])?;
+ }
+ write_revision(source, space, |_| Ok(changed))
+ }
+}
+
+fn fragment(
+ raw: &Object<'_>,
+ node: &Element<'_>,
+ range: Range,
+ empty: ExGuid,
+) -> Result {
+ let Kind::RichText { text, runs, .. } = &node.kind else {
+ return Err(invalid("Select ordinary paragraph text"));
+ };
+ let units: Vec<_> = text.encode_utf16().collect();
+ let start = usize::try_from(range.start)
+ .map_err(|_| invalid("Choose a position within the paragraph"))?;
+ let end = usize::try_from(range.end)
+ .map_err(|_| invalid("Choose a position within the paragraph"))?;
+ let slice = units
+ .get(start..end)
+ .ok_or_else(|| invalid("Choose a position within the paragraph"))?;
+ let text = String::from_utf16(slice)
+ .map_err(|_| invalid("Choose a position between Unicode scalars"))?;
+ let mut segments = Vec::new();
+ for run in runs {
+ if run.start < range.end && range.start < run.end {
+ segments.push((
+ run.end.min(range.end) - range.start,
+ run.format.unwrap_or(empty),
+ ));
+ }
+ }
+ if segments.is_empty() {
+ segments.push((0, empty));
+ }
+ if !range.is_empty() && end == units.len() {
+ let last = runs.last().unwrap();
+ if last.start == last.end {
+ segments.push((range.end - range.start, last.format.unwrap_or(empty)));
+ }
+ }
+ if segments[..segments.len() - 1]
+ .windows(2)
+ .any(|pair| pair[0].0 >= pair[1].0)
+ {
+ return Err(invalid("Text-run boundaries must be strictly increasing"));
+ }
+ let mut object = PropertyObject::from_object(raw)?;
+ let mut ends = Vec::new();
+ let mut references = Vec::new();
+ for (end, id) in segments {
+ ends.extend_from_slice(&end.to_le_bytes());
+ references.extend_from_slice(&object.reference(id)?);
+ }
+ ends.truncate(ends.len() - 4);
+ object.set(&[
+ (0x1c001c22, &string(&text)),
+ (0x1c001e12, &ends),
+ (0x24001e13, &references),
+ ])?;
+ Ok(object)
+}
diff --git a/crates/onestore/src/write.rs b/crates/onestore/src/write.rs
index 19d078326fd9404b759eb1b861369da66a22a77d..581a72460722c325de61b9e9bbef0b5e00bcf1a3 100644
--- a/crates/onestore/src/write.rs
+++ b/crates/onestore/src/write.rs
@@ -120,6 +120,15 @@ fn field_length(property: &crate::Property<'_>, set_lengths: &[usize]) -> usize
}
}
+fn property_set_lengths(properties: &PropertySets<'_>) -> Vec {
+ let mut lengths = vec![0; properties.sets.len()];
+ for (i, set) in properties.sets.iter().enumerate().rev() {
+ lengths[i] =
+ 2 + set.len() * 4 + set.iter().map(|p| field_length(p, &lengths)).sum::();
+ }
+ lengths
+}
+
fn patch_properties(
blob: &[u8],
updates: &[(u32, &[u8])],
@@ -130,15 +139,7 @@ fn patch_properties(
let ids = properties.root_ids.as_ptr().addr() - blob.as_ptr().addr();
let ids_end = ids + properties.root_ids.len();
let body_end = blob.len() - properties.padding.len();
- let mut set_lengths = vec![0; properties.sets.len()];
- for (i, set) in properties.sets.iter().enumerate().rev() {
- set_lengths[i] = 2
- + set.len() * 4
- + set
- .iter()
- .map(|p| field_length(p, &set_lengths))
- .sum::();
- }
+ let set_lengths = property_set_lengths(&properties);
let mut offsets = Vec::with_capacity(root.len());
let mut offset = ids_end;
for property in root {
@@ -379,6 +380,79 @@ impl PropertyObject {
Ok(())
}
+ pub fn remove(&mut self, ids: &[u32]) -> Result<()> {
+ let properties = PropertySets::parse(&self.bytes)?;
+ let removed = |id: u32| {
+ ids.iter()
+ .any(|wanted| id & 0x7fffffff == wanted & 0x7fffffff)
+ };
+ if !properties.sets[0].iter().any(|p| removed(p.id)) {
+ return Ok(());
+ }
+ let lengths = property_set_lengths(&properties);
+ let mut offset = properties.root_ids.as_ptr().addr() - self.bytes.as_ptr().addr()
+ + properties.root_ids.len();
+ let mut retained_ids = Vec::new();
+ let mut fields = Vec::new();
+ let mut references: [Vec; 3] = std::array::from_fn(|_| Vec::new());
+ for property in &properties.sets[0] {
+ let end = offset + field_length(property, &lengths);
+ if !removed(property.id) {
+ retained_ids.extend_from_slice(&property.id.to_le_bytes());
+ fields.extend_from_slice(&self.bytes[offset..end]);
+ let mut pending = vec![property];
+ while let Some(field) = pending.pop() {
+ match &field.value {
+ Value::References {
+ stream,
+ compact_ids,
+ } => {
+ let index = match stream {
+ crate::IdStream::Objects => 0,
+ crate::IdStream::ObjectSpaces => 1,
+ crate::IdStream::Contexts => 2,
+ };
+ references[index].extend_from_slice(compact_ids);
+ }
+ Value::Sets(children) => {
+ for child in children.clone().rev() {
+ pending.extend(properties.sets[child].iter().rev());
+ }
+ }
+ _ => {}
+ }
+ }
+ }
+ offset = end;
+ }
+ let mut cursor = crate::bytes::Cursor {
+ bytes: &self.bytes,
+ offset: 0,
+ };
+ let streams = crate::properties::reference_streams(&mut cursor)?;
+ let mut bytes = Vec::new();
+ for (stream, retained) in streams.iter().zip(&references) {
+ if stream.offset == 0 {
+ continue;
+ }
+ let header = u32::from_le_bytes(
+ self.bytes[stream.offset - 4..stream.offset]
+ .try_into()
+ .unwrap(),
+ );
+ let count = u32::try_from(retained.len() / 4).unwrap();
+ bytes.extend_from_slice(&((header & 0xff000000) | count).to_le_bytes());
+ bytes.extend_from_slice(retained);
+ }
+ bytes.extend_from_slice(&u16::try_from(retained_ids.len() / 4).unwrap().to_le_bytes());
+ bytes.extend_from_slice(&retained_ids);
+ bytes.extend_from_slice(&fields);
+ bytes.resize(bytes.len().next_multiple_of(8), 0);
+ PropertySets::parse(&bytes)?;
+ self.bytes = bytes;
+ Ok(())
+ }
+
pub fn reference(&mut self, id: ExGuid) -> Result<[u8; 4]> {
if !self.global_ids.values().any(|guid| *guid == id.guid) {
let mut index = 0;
diff --git a/crates/onestore/src/write/tests.rs b/crates/onestore/src/write/tests.rs
index 845064f1c0da2afbadca12938a8496554f36eb78..5e996875596247be4c62f01f55f8f8c6c619c30a 100644
--- a/crates/onestore/src/write/tests.rs
+++ b/crates/onestore/src/write/tests.rs
@@ -46,6 +46,8 @@ fn document_insertions_and_formatting_respect_readonly_ancestors() {
.iter()
.find_map(|(id, object)| (object.jcid == 0x6000e).then_some(*id))
.unwrap();
+ let split = crate::ParagraphSplit::new(text, 1, "Author").unwrap();
+ assert!(PreparedEdit::split(&protected, sid, &split).is_err());
assert!(
PreparedEdit::format(
&protected,
@@ -531,6 +533,45 @@ fn nested_fields_and_other_reference_streams_remain_byte_exact() {
);
assert_eq!(&changed[20..36], &original[16..32]);
assert_eq!(parsed.sets[0][3].value, Value::Bytes(&[96, 97, 98, 99]));
+
+ for removed in 0..16 {
+ let ids: Vec<_> = previous.sets[0]
+ .iter()
+ .enumerate()
+ .filter_map(|(i, property)| (removed & (1 << i) != 0).then_some(property.id))
+ .collect();
+ let mut object = super::PropertyObject {
+ jcid: 0x6000e,
+ bytes: original.clone(),
+ global_ids: Default::default(),
+ };
+ object.remove(&ids).unwrap();
+ let parsed = PropertySets::parse(&object.bytes).unwrap();
+ assert!(
+ parsed.sets[0]
+ .iter()
+ .eq(previous.sets[0].iter().filter(|p| !ids.contains(&p.id)))
+ );
+ if removed & 2 == 0 {
+ assert_eq!(parsed.sets[1], previous.sets[1]);
+ assert_eq!(
+ object
+ .bytes
+ .windows(nested.len())
+ .filter(|bytes| *bytes == nested)
+ .count(),
+ 1
+ );
+ } else {
+ assert_eq!(parsed.sets.len(), 1);
+ }
+ if removed == 0 {
+ assert_eq!(object.bytes, original);
+ }
+ let before = object.bytes.clone();
+ object.remove(&ids).unwrap();
+ assert_eq!(object.bytes, before);
+ }
}
#[test]
@@ -549,6 +590,19 @@ fn deep_property_splices_do_not_use_the_call_stack() {
&changed[14..changed.len() - parsed.padding.len()],
&bytes[10..]
);
+ let mut object = super::PropertyObject {
+ jcid: 0x6000e,
+ bytes: changed,
+ global_ids: Default::default(),
+ };
+ object.remove(&[0x08000002]).unwrap();
+ let parsed = PropertySets::parse(&object.bytes).unwrap();
+ assert_eq!(parsed.sets.len(), 100_001);
+ assert_eq!(&object.bytes[..bytes.len()], &bytes);
+ object.remove(&[0x44000001]).unwrap();
+ let parsed = PropertySets::parse(&object.bytes).unwrap();
+ assert_eq!(parsed.sets.len(), 1);
+ assert!(parsed.sets[0].is_empty());
}
#[test]
diff --git a/crates/onestore/tests/paragraph.rs b/crates/onestore/tests/paragraph.rs
new file mode 100644
index 0000000000000000000000000000000000000000..4767022d053c4b677a6cd49151b564757c906bd9
--- /dev/null
+++ b/crates/onestore/tests/paragraph.rs
@@ -0,0 +1,343 @@
+use onestore::{
+ ExGuid, ParagraphSplit, PreparedEdit, RevisionIndex, Store,
+ document::{Document, Kind, Revision},
+};
+use serde_json::Value;
+
+#[path = "support/checkpoint.rs"]
+mod checkpoint;
+#[path = "support/current.rs"]
+mod current;
+#[path = "support/disk.rs"]
+mod disk;
+
+const SOURCE: &[u8] =
+ include_bytes!("../../../corpus/paragraph-edit/before/notebook/synthetic.one");
+
+fn characters(view: &Revision<'_>, id: ExGuid) -> Vec<(char, Value)> {
+ view.text_runs(id)
+ .unwrap()
+ .into_iter()
+ .flat_map(|run| {
+ let format = serde_json::to_value(run.format).unwrap();
+ run.text.chars().map(move |c| (c, format.clone()))
+ })
+ .collect()
+}
+
+#[test]
+fn splits_partition_native_paragraphs_at_every_scalar_boundary() {
+ let manifest: Value =
+ serde_json::from_str(include_str!("../../../corpus/paragraph-edit/manifest.json")).unwrap();
+ let store = Store::parse(SOURCE).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ let document = Document::parse(&index).unwrap();
+ for case in manifest["cases"].as_array().unwrap() {
+ let text: ExGuid = serde_json::from_value(case["original_text"].clone()).unwrap();
+ let paragraph: ExGuid = serde_json::from_value(case["original_paragraph"].clone()).unwrap();
+ let (sid, space) = document
+ .spaces
+ .iter()
+ .find(|(_, space)| {
+ space.revisions[&space.contexts[&ExGuid::default()]]
+ .nodes
+ .contains_key(&text)
+ })
+ .unwrap();
+ let before = &space.revisions[&space.contexts[&ExGuid::default()]];
+ let (parent, parent_node) = before
+ .nodes
+ .iter()
+ .find(|(_, node)| node.children.contains(¶graph))
+ .unwrap();
+ let expected = characters(before, text);
+ let offsets: Vec = std::iter::once(0)
+ .chain(expected.iter().scan(0, |offset, (c, _)| {
+ *offset += u32::try_from(c.len_utf16()).unwrap();
+ Some(*offset)
+ }))
+ .collect();
+ for (position, offset) in offsets.iter().enumerate() {
+ let intent = ParagraphSplit::new(text, *offset, "Split author").unwrap();
+ let restored = serde_json::from_value(serde_json::to_value(&intent).unwrap()).unwrap();
+ assert_eq!(intent, restored);
+ let edited = PreparedEdit::split(SOURCE, *sid, &restored);
+ if case["case"] == "Split before hyperlink" {
+ assert!(edited.is_err());
+ continue;
+ }
+ let edited =
+ edited.unwrap_or_else(|error| panic!("{} at {offset}: {error}", case["case"]));
+ let current_store = Store::parse(edited.as_bytes()).unwrap();
+ assert_eq!(
+ current_store.header.transaction_count,
+ store.header.transaction_count + 1
+ );
+ let current_index = RevisionIndex::parse(¤t_store).unwrap();
+ current_index.validate_current().unwrap();
+ let current_document = Document::parse(¤t_index).unwrap();
+ let current_space = ¤t_document.spaces[sid];
+ let after = ¤t_space.revisions[¤t_space.contexts[&ExGuid::default()]];
+ let mut changed = std::collections::BTreeSet::from([text, before.roots[&2]]);
+ let mut pending = vec![paragraph];
+ while let Some(id) = pending.pop() {
+ if !changed.insert(id) {
+ continue;
+ }
+ pending.extend(before.nodes.iter().filter_map(|(parent, node)| {
+ node.children
+ .iter()
+ .chain(&node.content)
+ .chain(&node.structure)
+ .any(|child| *child == id)
+ .then_some(*parent)
+ }));
+ }
+ let old_raw = index
+ .resolve(*sid, space.contexts[&ExGuid::default()])
+ .unwrap();
+ let new_raw = current_index
+ .resolve(*sid, current_space.contexts[&ExGuid::default()])
+ .unwrap();
+ for (id, object) in old_raw.objects {
+ if !changed.contains(&id) {
+ assert_eq!(object.data, new_raw.objects[&id].data, "untouched {id}");
+ }
+ }
+ assert_eq!(
+ characters(after, text),
+ expected[..position],
+ "{} prefix at {offset}",
+ case["case"]
+ );
+ assert_eq!(
+ characters(after, intent.text_object()),
+ expected[position..],
+ "{} suffix at {offset}",
+ case["case"]
+ );
+ let mut children = parent_node.children.clone();
+ children.insert(
+ children.iter().position(|id| *id == paragraph).unwrap() + 1,
+ intent.object(),
+ );
+ assert_eq!(after.nodes[parent].children, children);
+ assert_eq!(after.nodes[¶graph].content, [text]);
+ assert!(after.nodes[¶graph].children.is_empty());
+ assert_eq!(
+ after.nodes[&intent.object()].content,
+ [intent.text_object()]
+ );
+ assert_eq!(
+ after.nodes[&intent.object()].children,
+ before.nodes[¶graph].children
+ );
+ assert_eq!(
+ serde_json::to_value(&after.nodes[&text].tags).unwrap(),
+ serde_json::to_value(&before.nodes[&text].tags).unwrap()
+ );
+ assert!(after.nodes[&intent.text_object()].tags.is_empty());
+ let Kind::Paragraph {
+ lists: old_lists, ..
+ } = &before.nodes[¶graph].kind
+ else {
+ panic!()
+ };
+ let Kind::Paragraph { lists, .. } = &after.nodes[&intent.object()].kind else {
+ panic!()
+ };
+ assert_eq!(old_lists.len(), lists.len());
+ for (old, new) in old_lists.iter().zip(lists) {
+ assert_ne!(old, new);
+ let mut old = serde_json::to_value(&before.nodes[old]).unwrap();
+ old["kind"]["restart"] = Value::Null;
+ assert_eq!(serde_json::to_value(&after.nodes[new]).unwrap(), old);
+ }
+ for (space_id, old_space) in &index.spaces {
+ for revision in old_space.revisions.keys() {
+ let old = index.resolve(*space_id, *revision).unwrap();
+ let retained = current_index.resolve(*space_id, *revision).unwrap();
+ assert_eq!(old.roots, retained.roots);
+ for (id, object) in old.objects {
+ assert_eq!(object.data, retained.objects[&id].data);
+ }
+ }
+ }
+ assert!(PreparedEdit::split(edited.as_bytes(), *sid, &intent).is_err());
+ }
+ for offset in 0..=*offsets.last().unwrap() + 1 {
+ if offsets.contains(&offset) {
+ continue;
+ }
+ let intent = ParagraphSplit::new(text, offset, "Author").unwrap();
+ assert!(PreparedEdit::split(SOURCE, *sid, &intent).is_err());
+ }
+ }
+}
+
+#[test]
+fn invalid_split_identities_and_title_targets_are_rejected() {
+ assert!(ParagraphSplit::new(ExGuid::default(), 0, "Author").is_err());
+ let manifest: Value =
+ serde_json::from_str(include_str!("../../../corpus/paragraph-edit/manifest.json")).unwrap();
+ let text: ExGuid =
+ serde_json::from_value(manifest["cases"][0]["original_text"].clone()).unwrap();
+ assert!(ParagraphSplit::new(text, 0, "a\0b").is_err());
+ let store = Store::parse(SOURCE).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ let document = Document::parse(&index).unwrap();
+ let sid = *document
+ .spaces
+ .iter()
+ .find(|(_, space)| {
+ space.revisions[&space.contexts[&ExGuid::default()]]
+ .nodes
+ .contains_key(&text)
+ })
+ .unwrap()
+ .0;
+ let intent = ParagraphSplit::new(text, 1, "Author").unwrap();
+ for (field, value) in [
+ ("guid", serde_json::to_value([0_u8; 16]).unwrap()),
+ ("guid", serde_json::to_value(text.guid).unwrap()),
+ ("text", serde_json::to_value(ExGuid::default()).unwrap()),
+ ("author", serde_json::json!("a\0b")),
+ ("offset", serde_json::json!(u32::MAX)),
+ ] {
+ let mut encoded = serde_json::to_value(&intent).unwrap();
+ encoded[field] = value;
+ let forged = serde_json::from_value(encoded).unwrap();
+ assert!(PreparedEdit::split(SOURCE, sid, &forged).is_err());
+ }
+ let mut titles = 0;
+ for (sid, _) in document.pages().unwrap() {
+ let space = &document.spaces[&sid];
+ let view = &space.revisions[&space.contexts[&ExGuid::default()]];
+ let mut pending: Vec<_> = view
+ .nodes
+ .iter()
+ .filter_map(|(id, n)| matches!(n.kind, Kind::Title).then_some(*id))
+ .collect();
+ let mut seen = std::collections::BTreeSet::new();
+ while let Some(id) = pending.pop() {
+ if !seen.insert(id) {
+ continue;
+ }
+ let node = &view.nodes[&id];
+ pending.extend(node.children.iter().chain(&node.content).copied());
+ if matches!(node.kind, Kind::RichText { .. }) {
+ let intent = ParagraphSplit::new(id, 0, "Author").unwrap();
+ assert!(PreparedEdit::split(SOURCE, sid, &intent).is_err());
+ titles += 1;
+ }
+ }
+ }
+ assert!(titles >= 14);
+}
+
+#[test]
+#[ignore = "exports paragraph splits for independent native validation"]
+fn export_native_paragraph_splits() {
+ use std::{fs, path::PathBuf};
+ let output = PathBuf::from(std::env::var_os("ONESTORE_PARAGRAPH_OUTPUT").unwrap());
+ assert!(output.is_absolute());
+ fs::create_dir(&output).unwrap();
+ let manifest: Value =
+ serde_json::from_str(include_str!("../../../corpus/paragraph-edit/manifest.json")).unwrap();
+ let mut source = SOURCE.to_vec();
+ let mut written = Vec::new();
+ for case in manifest["cases"].as_array().unwrap() {
+ if case["case"] == "Split before hyperlink" {
+ continue;
+ }
+ let text: ExGuid = serde_json::from_value(case["original_text"].clone()).unwrap();
+ let store = Store::parse(&source).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ let document = Document::parse(&index).unwrap();
+ let sid = *document
+ .spaces
+ .iter()
+ .find(|(_, space)| {
+ space.revisions[&space.contexts[&ExGuid::default()]]
+ .nodes
+ .contains_key(&text)
+ })
+ .unwrap()
+ .0;
+ let intent = ParagraphSplit::new(
+ text,
+ serde_json::from_value(case["offset_utf16"].clone()).unwrap(),
+ "Rust split author",
+ )
+ .unwrap();
+ let edited = PreparedEdit::split(&source, sid, &intent).unwrap();
+ written.push(serde_json::json!({"case": case["case"], "intent": intent,
+ "new_paragraph": intent.object(), "new_text": intent.text_object()}));
+ source = edited.as_bytes().to_vec();
+ }
+ let candidate = output.join("candidate");
+ fs::create_dir(&candidate).unwrap();
+ fs::write(candidate.join("synthetic.one"), source).unwrap();
+ fs::write(
+ output.join("manifest.json"),
+ serde_json::to_vec_pretty(&serde_json::json!({"cases": written})).unwrap(),
+ )
+ .unwrap();
+}
+
+#[test]
+fn interrupted_splits_publish_a_complete_graph_or_retain_the_original() {
+ let original = onestore::create_section("split.one", "Original", "Author").unwrap();
+ let store = Store::parse(&original).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ let document = Document::parse(&index).unwrap();
+ let (sid, page) = document.pages().unwrap()[0];
+ let space = &document.spaces[&sid];
+ let view = &space.revisions[&space.contexts[&ExGuid::default()]];
+ let outline = view.nodes[&page]
+ .children
+ .iter()
+ .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. }))
+ .unwrap();
+ let paragraph = view.nodes[outline].children[0];
+ let insertion = onestore::Insertion::paragraph(paragraph, None, "a🦀b", "Author")
+ .unwrap()
+ .with_formatting(1..3, &[onestore::TextAttribute::Bold(true)])
+ .unwrap();
+ let inserted = PreparedEdit::insert(&original, sid, &insertion).unwrap();
+ let source = inserted.as_bytes();
+ let checkpoint = checkpoint::pending(source, sid, insertion.text_object(), 0x14001d7a);
+ for source in [source, &checkpoint] {
+ let intent = ParagraphSplit::new(insertion.text_object(), 1, "Author").unwrap();
+ let edit = PreparedEdit::split(source, sid, &intent).unwrap();
+ let before = current::current(source);
+ let after = current::current(edit.as_bytes());
+ for write_limit in [17, 4096] {
+ let disk = |fail_at| disk::Disk {
+ visible: source.to_vec(),
+ durable: source.to_vec(),
+ operation: 0,
+ fail_at,
+ write_limit,
+ random: 347,
+ };
+ let mut successful = disk(None);
+ edit.commit(&mut successful).unwrap();
+ assert_eq!(successful.durable, edit.as_bytes());
+ for at in 1..=successful.operation {
+ let mut interrupted = disk(Some(at));
+ let error = edit.commit(&mut interrupted).unwrap_err();
+ let recovered = current::current(&interrupted.durable);
+ assert!(
+ recovered == before || recovered == after,
+ "interruption {at}"
+ );
+ match error.state {
+ onestore::CommitState::NotCommitted => assert_eq!(recovered, before),
+ onestore::CommitState::Committed => assert_eq!(recovered, after),
+ onestore::CommitState::Unknown => {}
+ }
+ }
+ }
+ }
+}
diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml
index edaf899d52a4762dbc9615ddefbf58c2baed74ef..3c0a6bb007fa7d2349e20ebf0db8b3539a536c65 100644
--- a/fuzz/Cargo.toml
+++ b/fuzz/Cargo.toml
@@ -84,3 +84,10 @@ path = "fuzz_targets/insert.rs"
test = false
doc = false
bench = false
+
+[[bin]]
+name = "paragraph"
+path = "fuzz_targets/paragraph.rs"
+test = false
+doc = false
+bench = false
diff --git a/fuzz/fuzz_targets/paragraph.rs b/fuzz/fuzz_targets/paragraph.rs
new file mode 100644
index 0000000000000000000000000000000000000000..fcc5b46163d65f926a61b277abe432623a0410df
--- /dev/null
+++ b/fuzz/fuzz_targets/paragraph.rs
@@ -0,0 +1,144 @@
+#![no_main]
+use libfuzzer_sys::fuzz_target;
+use onestore::{
+ CommitState, ExGuid, Insertion, ParagraphSplit, PreparedEdit, RevisionIndex, Store,
+ TextAttribute as A,
+ document::{Document, Kind},
+};
+use std::sync::LazyLock;
+
+#[path = "../../crates/onestore/tests/support/current.rs"]
+mod current;
+#[path = "../../crates/onestore/tests/support/disk.rs"]
+mod disk;
+
+static SOURCE: LazyLock<(Vec, ExGuid, ExGuid)> = LazyLock::new(|| {
+ let source = onestore::create_section("paragraph.one", "Original", "Author").unwrap();
+ let store = Store::parse(&source).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ let document = Document::parse(&index).unwrap();
+ let (sid, page) = document.pages().unwrap()[0];
+ let space = &document.spaces[&sid];
+ let view = &space.revisions[&space.contexts[&ExGuid::default()]];
+ let outline = *view.nodes[&page]
+ .children
+ .iter()
+ .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. }))
+ .unwrap();
+ let parent = view.nodes[&outline].children[0];
+ let intent = Insertion::paragraph(parent, None, "a🦀 e\u{301} 東京\rEnd", "Author")
+ .unwrap()
+ .with_formatting(0..4, &[A::Bold(true)])
+ .unwrap()
+ .with_formatting(4..7, &[A::Italic(true), A::Color(Some([12, 34, 56]))])
+ .unwrap();
+ let edited = PreparedEdit::insert(&source, sid, &intent).unwrap();
+ (edited.as_bytes().to_vec(), sid, outline)
+});
+
+fuzz_target!(|input: &[u8]| {
+ let (source, sid, outline) = &*SOURCE;
+ if let Ok(intent) = serde_json::from_slice::(input)
+ && let Ok(edit) = PreparedEdit::split(source, *sid, &intent)
+ {
+ current::current(edit.as_bytes());
+ }
+ let mut persisted = source.clone();
+ let mut caches = std::array::from_fn::<_, 12, _>(|_| source.clone());
+ for step in input.chunks_exact(8).take(20) {
+ let actor = usize::from(step[0]) % caches.len();
+ if step[1] % 3 == 0 {
+ caches[actor].clone_from(&persisted);
+ }
+ let source = &caches[actor];
+ let store = Store::parse(source).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ let document = Document::parse(&index).unwrap();
+ let space = &document.spaces[sid];
+ let view = &space.revisions[&space.contexts[&ExGuid::default()]];
+ let mut pending = vec![*outline];
+ let mut paragraphs = Vec::new();
+ while let Some(id) = pending.pop() {
+ let node = &view.nodes[&id];
+ pending.extend(node.children.iter().copied());
+ if matches!(node.kind, Kind::Paragraph { .. }) {
+ paragraphs.push(id);
+ }
+ }
+ let paragraph = paragraphs[usize::from(step[2]) % paragraphs.len()];
+ let text = view.nodes[¶graph].content[0];
+ let characters: Vec<_> = view
+ .text_runs(text)
+ .unwrap()
+ .into_iter()
+ .flat_map(|run| {
+ let style = serde_json::to_value(run.format).unwrap();
+ run.text.chars().map(move |c| (c, style.clone()))
+ })
+ .collect();
+ let offsets: Vec = std::iter::once(0)
+ .chain(characters.iter().scan(0, |n, (c, _)| {
+ *n += u32::try_from(c.len_utf16()).unwrap();
+ Some(*n)
+ }))
+ .collect();
+ let offset = u32::from(step[3]) % (offsets.last().unwrap() + 2);
+ let intent = ParagraphSplit::new(text, offset, "Paragraph fuzz").unwrap();
+ let intent = serde_json::from_value(serde_json::to_value(intent).unwrap()).unwrap();
+ let edit = PreparedEdit::split(source, *sid, &intent);
+ let Some(position) = offsets.iter().position(|n| *n == offset) else {
+ assert!(edit.is_err());
+ continue;
+ };
+ let edit = edit.unwrap();
+ let after_store = Store::parse(edit.as_bytes()).unwrap();
+ let after_index = RevisionIndex::parse(&after_store).unwrap();
+ let after_document = Document::parse(&after_index).unwrap();
+ let space = &after_document.spaces[sid];
+ let after_view = &space.revisions[&space.contexts[&ExGuid::default()]];
+ for (id, expected) in [
+ (text, &characters[..position]),
+ (intent.text_object(), &characters[position..]),
+ ] {
+ let actual: Vec<_> = after_view
+ .text_runs(id)
+ .unwrap()
+ .into_iter()
+ .flat_map(|run| {
+ let style = serde_json::to_value(run.format).unwrap();
+ run.text.chars().map(move |c| (c, style.clone()))
+ })
+ .collect();
+ assert_eq!(actual, expected);
+ }
+ assert!(after_view.nodes[¶graph].children.is_empty());
+ assert_eq!(
+ after_view.nodes[&intent.object()].children,
+ view.nodes[¶graph].children
+ );
+ let before = current::current(&persisted);
+ let after = current::current(edit.as_bytes());
+ let mut disk = disk::Disk {
+ visible: persisted.clone(),
+ durable: persisted.clone(),
+ operation: 0,
+ fail_at: (step[4] != 0).then_some(usize::from(u16::from_le_bytes([step[4], step[5]]))),
+ write_limit: if step[6] & 1 == 0 { 17 } else { 4096 },
+ random: u64::from(step[7]) + 1,
+ };
+ let result = edit.commit(&mut disk);
+ let observed = current::current(&disk.durable);
+ match result {
+ Ok(()) => assert_eq!(observed, after),
+ Err(error) => {
+ assert!(observed == before || observed == after);
+ match error.state {
+ CommitState::NotCommitted => assert_eq!(observed, before),
+ CommitState::Committed => assert_eq!(observed, after),
+ CommitState::Unknown => {}
+ }
+ }
+ }
+ persisted = disk.durable;
+ }
+});
diff --git a/tools/test_paragraph_edit.py b/tools/test_paragraph_edit.py
index 04e36e9ca80be005699aa8ec842c10225bf4f286..e14d5c3ae8d591f8d8469a6dd4cee120ea85e337 100644
--- a/tools/test_paragraph_edit.py
+++ b/tools/test_paragraph_edit.py
@@ -8,7 +8,7 @@ import unittest
import xml.etree.ElementTree as ET
from document_model import EXPORTER, ordered_pages, walk
-from native_format import native_characters
+from native_format import compare_formats, native_characters
from native_xml import ns
ROOT = Path(__file__).resolve().parent.parent
@@ -17,6 +17,65 @@ compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
class ParagraphEditTest(unittest.TestCase):
+ def test_rust_splits_match_native_controls_and_preserve_empty_typing_styles(self):
+ fixture = FIXTURE / 'rust-split'
+ manifest = json.loads((fixture / 'manifest.json').read_text())
+ with TemporaryDirectory() as temporary:
+ for source, capture in [('candidate', 'native'), ('native/notebook', 'native'),
+ ('typed/notebook', 'typed')]:
+ native = Path(temporary) / source.replace('/', '-') / 'read'
+ shutil.copytree(fixture / capture / 'read', native)
+ compare(fixture / source, native)
+ subprocess.run([EXPORTER, fixture / 'candidate/synthetic.one', Path(temporary) / 'model'], check=True)
+ model = json.loads((Path(temporary) / 'model/document.json').read_text())
+ subprocess.run([EXPORTER, fixture / 'native/notebook/synthetic.one', Path(temporary) / 'saved'], check=True)
+ saved = json.loads((Path(temporary) / 'saved/document.json').read_text())
+ views = {r['nodes'][r['roots']['2']]['kind']['title']: (r, page)
+ for _, _, r, page in ordered_pages(model)}
+ saved_views = {r['nodes'][r['roots']['2']]['kind']['title']: (r, page)
+ for _, _, r, page in ordered_pages(saved)}
+ text_nodes = {}
+ for name, (revision, page) in views.items():
+ current, current_page = saved_views[name]
+ self.assertEqual(page, current_page)
+ text_nodes[name] = []
+ for outline in revision['nodes'][page]['children']:
+ if revision['nodes'][outline]['kind']['type'] != 'Outline': continue
+ for oid, node in walk(revision, outline):
+ self.assertEqual(current['nodes'][oid]['children'], node['children'])
+ self.assertEqual(current['nodes'][oid]['content'], node['content'])
+ if node['kind']['type'] == 'RichText': text_nodes[name].append(node)
+ captures = {}
+ for phase, folder in [('native', fixture / 'native/read'), ('typed', fixture / 'typed/read'),
+ ('control', FIXTURE / 'cold-split/read')]:
+ captures[phase] = {}
+ for path in folder.glob('page-*.xml'):
+ page = ET.parse(path).getroot()
+ captures[phase][page.get('name')] = native_characters(page, page.findall('one:Outline', ns))
+ self.assertEqual(len(captures[phase]), 14)
+ cases = [c for c in manifest['cases'] if 'intent' in c]
+ self.assertEqual(len(cases), 12)
+ for case in cases:
+ name = case['case']
+ with self.subTest(case=name):
+ for a, b in zip(captures['native'][name], captures['control'][name], strict=True):
+ for (c, left), (d, right) in zip(a, b, strict=True):
+ self.assertEqual(c, d)
+ for key in left.keys() | right.keys():
+ default = 'automatic' if key in ('color', 'highlight') else False
+ self.assertEqual(left.get(key, default), right.get(key, default))
+ selections = json.loads((fixture / 'typed/ui/selections.json').read_text())
+ self.assertEqual(len(selections), 4)
+ for selection in selections:
+ node = text_nodes[selection['case']][selection['index']]
+ self.assertEqual(node['kind']['text'], '')
+ self.assertEqual(len(node['kind']['runs']), 1)
+ node['kind']['text'] = selection['text']
+ node['kind']['runs'][0]['end'] = len(selection['text'].encode('utf-16-le')) // 2
+ for name, (revision, page) in views.items():
+ _, differences = compare_formats(revision, text_nodes[name], captures['typed'][name])
+ self.assertEqual(differences, [], name)
+
def test_native_split_join_graphs_styles_and_identity_boundaries(self):
manifest = json.loads((FIXTURE / 'manifest.json').read_text())
models, native = {}, {}