diff --git a/corpus/paragraph-edit/README.md b/corpus/paragraph-edit/README.md
index bd0c08d60565a9d42b08016c4b3067b6c9966feb..162562834f34293f17959d97333e96bee9d54e71 100644
--- a/corpus/paragraph-edit/README.md
+++ b/corpus/paragraph-edit/README.md
@@ -34,8 +34,7 @@ directory comes from `ONESTORE_PARAGRAPH_OUTPUT`. Native character/style compari
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.
+`tools/test_paragraph_edit.py` verifies these controls without a VM.
Identical captured files link to one canonical copy within this corpus.
`join-edges`, authored with `tools/native/paragraph-joins.ps1`, adds five native
@@ -48,3 +47,16 @@ The untouched parent's text gains a timestamp and `0x880034dd`, as in the earlie
native controls; its content and other properties stay unchanged. The test fixes
these observed graph, identity and metadata effects alongside independent native
character-style comparisons.
+
+`join-tags`, authored with `tools/native/paragraph-tag-joins.ps1`, confirms the
+left-tag rule for an empty untagged left paragraph, an empty tagged left paragraph
+with a different right tag, and two nonempty paragraphs with different tags.
+Right-side tags disappear in all three cases; only the left tag survives.
+
+`rust-join` retains twenty Rust joins across the original splits, inheritance
+edges and additional tag controls. Each group has a cold OneNote capture and
+saved notebook. Public tests compare the Rust and native-saved images to native
+XML, preserve exact child/content identities across reopening, and compare
+the native-rendered result to the corresponding keyboard-generated control.
+`export_native_paragraph_joins` regenerates candidates in a new directory specified
+by `ONESTORE_PARAGRAPH_JOIN_OUTPUT`.
diff --git a/corpus/paragraph-edit/join-tags/before/notebook/Open Notebook.onetoc2 b/corpus/paragraph-edit/join-tags/before/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..064330348dfb7dd96b98ad3f0271099cba301e34
Binary files /dev/null and b/corpus/paragraph-edit/join-tags/before/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/paragraph-edit/join-tags/before/notebook/synthetic.one b/corpus/paragraph-edit/join-tags/before/notebook/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..fd8f46b91324c8903165bf061b93f60d8381f329
Binary files /dev/null and b/corpus/paragraph-edit/join-tags/before/notebook/synthetic.one differ
diff --git a/corpus/paragraph-edit/join-tags/before/read/environment.json b/corpus/paragraph-edit/join-tags/before/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..071b20d3dd9ce19fa79bfc9f7e55dbf524246b93
--- /dev/null
+++ b/corpus/paragraph-edit/join-tags/before/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-57E1FB2A",
+ "cold": false,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/paragraph-edit/join-tags/before/read/hierarchy.xml b/corpus/paragraph-edit/join-tags/before/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..593493e32f7c5cb4c5abcb2505a132c6ec2c9cc5
--- /dev/null
+++ b/corpus/paragraph-edit/join-tags/before/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/paragraph-edit/join-tags/before/read/page-000.xml b/corpus/paragraph-edit/join-tags/before/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..1d7a403358436a0e4b3eb3b42b4d9a1d9521bb57
--- /dev/null
+++ b/corpus/paragraph-edit/join-tags/before/read/page-000.xml
@@ -0,0 +1,5 @@
+
+Bold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/join-tags/before/read/page-001.xml b/corpus/paragraph-edit/join-tags/before/read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..fc636e01a16d90ec85fb0d7e9f9ecb723582ca97
--- /dev/null
+++ b/corpus/paragraph-edit/join-tags/before/read/page-001.xml
@@ -0,0 +1,5 @@
+
+Bold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/join-tags/before/read/page-002.xml b/corpus/paragraph-edit/join-tags/before/read/page-002.xml
new file mode 100644
index 0000000000000000000000000000000000000000..6d727eb5dd1a3515795c1ff680c1ef288e37519b
--- /dev/null
+++ b/corpus/paragraph-edit/join-tags/before/read/page-002.xml
@@ -0,0 +1,20 @@
+
+Bold 🦀 italic é color 東京
+End]]>Fictitious: café, 東京, مرحبا]]>
diff --git a/corpus/paragraph-edit/join-tags/before/read/page-003.xml b/corpus/paragraph-edit/join-tags/before/read/page-003.xml
new file mode 100644
index 0000000000000000000000000000000000000000..d4206f1d03b81dac0ce27b398d4192217c46cb27
--- /dev/null
+++ b/corpus/paragraph-edit/join-tags/before/read/page-003.xml
@@ -0,0 +1,5 @@
+
+Bold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/join-tags/before/read/payloads.json b/corpus/paragraph-edit/join-tags/before/read/payloads.json
new file mode 120000
index 0000000000000000000000000000000000000000..3cf45dc1acb611d7f764e0340d73e87192f81407
--- /dev/null
+++ b/corpus/paragraph-edit/join-tags/before/read/payloads.json
@@ -0,0 +1 @@
+../../../before/read/payloads.json
\ No newline at end of file
diff --git a/corpus/paragraph-edit/join-tags/cases.json b/corpus/paragraph-edit/join-tags/cases.json
new file mode 100644
index 0000000000000000000000000000000000000000..56d9b3d53dbe3a1c2ba1a84b037700a289ecec77
--- /dev/null
+++ b/corpus/paragraph-edit/join-tags/cases.json
@@ -0,0 +1,17 @@
+[
+ {
+ "body": "\u003cone:OE\u003e\u003cone:T\u003e\u003c![CDATA[]]\u003e\u003c/one:T\u003e\u003c/one:OE\u003e\u003cone:OE\u003e\u003cone:Tag index=\"1\" completed=\"true\" disabled=\"false\" creationDate=\"2020-01-02T03:04:05.000Z\" completionDate=\"2020-01-02T04:05:06.000Z\"/\u003e\u003cone:T\u003e\u003c![CDATA[\u003cb\u003eBold\u003c/b\u003e \u003ci\u003eitalic\u003c/i\u003e 🦀 é \u003cspan style=\"color:#123456\"\u003etail\u003c/span\u003e]]\u003e\u003c/one:T\u003e\u003c/one:OE\u003e",
+ "name": "Join empty left right tag",
+ "page": "{110D64A5-F4AB-4699-B620-CF39E7595E3D}{1}{B0}"
+ },
+ {
+ "body": "\u003cone:OE\u003e\u003cone:Tag index=\"0\" completed=\"true\" disabled=\"false\" creationDate=\"2020-01-02T03:04:05.000Z\" completionDate=\"2020-01-02T04:05:06.000Z\"/\u003e\u003cone:T\u003e\u003c![CDATA[]]\u003e\u003c/one:T\u003e\u003c/one:OE\u003e\u003cone:OE\u003e\u003cone:Tag index=\"1\" completed=\"true\" disabled=\"false\" creationDate=\"2020-01-02T03:04:05.000Z\" completionDate=\"2020-01-02T04:05:06.000Z\"/\u003e\u003cone:T\u003e\u003c![CDATA[\u003cb\u003eBold\u003c/b\u003e \u003ci\u003eitalic\u003c/i\u003e 🦀 é \u003cspan style=\"color:#123456\"\u003etail\u003c/span\u003e]]\u003e\u003c/one:T\u003e\u003c/one:OE\u003e",
+ "name": "Join empty left two tags",
+ "page": "{4D3539F2-613D-47F1-AD5A-6D8EAD9D9901}{1}{B0}"
+ },
+ {
+ "body": "\u003cone:OE\u003e\u003cone:Tag index=\"0\" completed=\"true\" disabled=\"false\" creationDate=\"2020-01-02T03:04:05.000Z\" completionDate=\"2020-01-02T04:05:06.000Z\"/\u003e\u003cone:T\u003eLeft\u003c/one:T\u003e\u003c/one:OE\u003e\u003cone:OE\u003e\u003cone:Tag index=\"1\" completed=\"true\" disabled=\"false\" creationDate=\"2020-01-02T03:04:05.000Z\" completionDate=\"2020-01-02T04:05:06.000Z\"/\u003e\u003cone:T\u003e\u003c![CDATA[\u003cb\u003eBold\u003c/b\u003e \u003ci\u003eitalic\u003c/i\u003e 🦀 é \u003cspan style=\"color:#123456\"\u003etail\u003c/span\u003e]]\u003e\u003c/one:T\u003e\u003c/one:OE\u003e",
+ "name": "Join different tags",
+ "page": "{8266EEF9-BFB3-4061-B62F-67D83F1A49FF}{1}{B0}"
+ }
+]
diff --git a/corpus/paragraph-edit/join-tags/joined/notebook/Open Notebook.onetoc2 b/corpus/paragraph-edit/join-tags/joined/notebook/Open Notebook.onetoc2
new file mode 120000
index 0000000000000000000000000000000000000000..a6c7d197861f52dda5ecaf4da2ab7e471f409518
--- /dev/null
+++ b/corpus/paragraph-edit/join-tags/joined/notebook/Open Notebook.onetoc2
@@ -0,0 +1 @@
+../../before/notebook/Open Notebook.onetoc2
\ No newline at end of file
diff --git a/corpus/paragraph-edit/join-tags/joined/notebook/synthetic.one b/corpus/paragraph-edit/join-tags/joined/notebook/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..858448c79a0ad1bc5f50a95f163873f9e62040a9
Binary files /dev/null and b/corpus/paragraph-edit/join-tags/joined/notebook/synthetic.one differ
diff --git a/corpus/paragraph-edit/join-tags/joined/read/environment.json b/corpus/paragraph-edit/join-tags/joined/read/environment.json
new file mode 120000
index 0000000000000000000000000000000000000000..032ba3b41dd4114f7b416f0781d9c5fb61796d7f
--- /dev/null
+++ b/corpus/paragraph-edit/join-tags/joined/read/environment.json
@@ -0,0 +1 @@
+../../before/read/environment.json
\ No newline at end of file
diff --git a/corpus/paragraph-edit/join-tags/joined/read/hierarchy.xml b/corpus/paragraph-edit/join-tags/joined/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..ec9545937600adfba928ffcdf4c17f7d22497c18
--- /dev/null
+++ b/corpus/paragraph-edit/join-tags/joined/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/paragraph-edit/join-tags/joined/read/page-000.xml b/corpus/paragraph-edit/join-tags/joined/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..84e778b834e7190545c998832644e5d83c98b8c0
--- /dev/null
+++ b/corpus/paragraph-edit/join-tags/joined/read/page-000.xml
@@ -0,0 +1,5 @@
+
+Bold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/join-tags/joined/read/page-001.xml b/corpus/paragraph-edit/join-tags/joined/read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..0fdc79afba55934ca76360ee627a657709889a9e
--- /dev/null
+++ b/corpus/paragraph-edit/join-tags/joined/read/page-001.xml
@@ -0,0 +1,5 @@
+
+Bold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/join-tags/joined/read/page-002.xml b/corpus/paragraph-edit/join-tags/joined/read/page-002.xml
new file mode 100644
index 0000000000000000000000000000000000000000..291c17183d5b531f4dad9c71f2ed65cf64b91a11
--- /dev/null
+++ b/corpus/paragraph-edit/join-tags/joined/read/page-002.xml
@@ -0,0 +1,20 @@
+
+Bold 🦀 italic é color 東京
+End]]>Fictitious: café, 東京, مرحبا]]>
diff --git a/corpus/paragraph-edit/join-tags/joined/read/page-003.xml b/corpus/paragraph-edit/join-tags/joined/read/page-003.xml
new file mode 100644
index 0000000000000000000000000000000000000000..98134b3e8ee9aa22f1ddb6b4e695d04af80c1a40
--- /dev/null
+++ b/corpus/paragraph-edit/join-tags/joined/read/page-003.xml
@@ -0,0 +1,5 @@
+
+LeftBold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/join-tags/joined/read/payloads.json b/corpus/paragraph-edit/join-tags/joined/read/payloads.json
new file mode 120000
index 0000000000000000000000000000000000000000..3cf45dc1acb611d7f764e0340d73e87192f81407
--- /dev/null
+++ b/corpus/paragraph-edit/join-tags/joined/read/payloads.json
@@ -0,0 +1 @@
+../../../before/read/payloads.json
\ No newline at end of file
diff --git a/corpus/paragraph-edit/join-tags/joined/ui/join-different-tags.ahk b/corpus/paragraph-edit/join-tags/joined/ui/join-different-tags.ahk
new file mode 100644
index 0000000000000000000000000000000000000000..4cd99f2353e5997f4bb7efb5d1f1927705e40cdd
--- /dev/null
+++ b/corpus/paragraph-edit/join-tags/joined/ui/join-different-tags.ahk
@@ -0,0 +1,22 @@
+#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("{B93EF35F-621E-4F9A-90D4-B55A0CF7E4AA}{1}{B0}", "{998EEB43-1A80-48BB-B928-2AE6052587A2}{46}{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}{Backspace}"
diff --git a/corpus/paragraph-edit/join-tags/joined/ui/join-different-tags.json b/corpus/paragraph-edit/join-tags/joined/ui/join-different-tags.json
new file mode 100644
index 0000000000000000000000000000000000000000..a7131b561a3dc5e5afc8eae4b4100e6b42e02e20
--- /dev/null
+++ b/corpus/paragraph-edit/join-tags/joined/ui/join-different-tags.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 1280,
+ "h": 720,
+ "error": null,
+ "win": {
+ "title": "Join different tags - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/paragraph-edit/join-tags/joined/ui/join-different-tags.png b/corpus/paragraph-edit/join-tags/joined/ui/join-different-tags.png
new file mode 100644
index 0000000000000000000000000000000000000000..7afde0130f593b6762527cc1f73a16739e2aa2eb
Binary files /dev/null and b/corpus/paragraph-edit/join-tags/joined/ui/join-different-tags.png differ
diff --git a/corpus/paragraph-edit/join-tags/joined/ui/join-empty-left-right-tag.ahk b/corpus/paragraph-edit/join-tags/joined/ui/join-empty-left-right-tag.ahk
new file mode 100644
index 0000000000000000000000000000000000000000..49844f898faf78fab6c63bbda071c3c6e0d6eed8
--- /dev/null
+++ b/corpus/paragraph-edit/join-tags/joined/ui/join-empty-left-right-tag.ahk
@@ -0,0 +1,22 @@
+#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("{2A557903-2906-4962-90DB-1DBBD4B4F368}{1}{B0}", "{DB20B227-1FF5-4341-9621-D7C3E0EEE79B}{46}{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}{Backspace}"
diff --git a/corpus/paragraph-edit/join-tags/joined/ui/join-empty-left-right-tag.json b/corpus/paragraph-edit/join-tags/joined/ui/join-empty-left-right-tag.json
new file mode 100644
index 0000000000000000000000000000000000000000..1b7b1e5e4f0dbdca9c6dff38dbf3dd087b547a96
--- /dev/null
+++ b/corpus/paragraph-edit/join-tags/joined/ui/join-empty-left-right-tag.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 1280,
+ "h": 720,
+ "error": null,
+ "win": {
+ "title": "Join empty left right tag - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/paragraph-edit/join-tags/joined/ui/join-empty-left-right-tag.png b/corpus/paragraph-edit/join-tags/joined/ui/join-empty-left-right-tag.png
new file mode 100644
index 0000000000000000000000000000000000000000..7def31a3e394687523edf16b4d9537af80862ff4
Binary files /dev/null and b/corpus/paragraph-edit/join-tags/joined/ui/join-empty-left-right-tag.png differ
diff --git a/corpus/paragraph-edit/join-tags/joined/ui/join-empty-left-two-tags.ahk b/corpus/paragraph-edit/join-tags/joined/ui/join-empty-left-two-tags.ahk
new file mode 100644
index 0000000000000000000000000000000000000000..c851fdf2c0e3272bb51e512cb3269632ffe07273
--- /dev/null
+++ b/corpus/paragraph-edit/join-tags/joined/ui/join-empty-left-two-tags.ahk
@@ -0,0 +1,22 @@
+#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("{766D2454-BC90-480A-8BA1-BF0C9E703454}{1}{B0}", "{5237862E-022E-4FC4-8E36-D9C8E010D9E1}{47}{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}{Backspace}"
diff --git a/corpus/paragraph-edit/join-tags/joined/ui/join-empty-left-two-tags.json b/corpus/paragraph-edit/join-tags/joined/ui/join-empty-left-two-tags.json
new file mode 100644
index 0000000000000000000000000000000000000000..1faac657daf930348836578a92c8d73bf39fa538
--- /dev/null
+++ b/corpus/paragraph-edit/join-tags/joined/ui/join-empty-left-two-tags.json
@@ -0,0 +1,13 @@
+{
+ "exit": 0,
+ "stdout": "",
+ "stderr": "",
+ "w": 1280,
+ "h": 720,
+ "error": null,
+ "win": {
+ "title": "Join empty left two tags - Microsoft OneNote",
+ "class": "Framework::CFrame",
+ "dialog": false
+ }
+}
\ No newline at end of file
diff --git a/corpus/paragraph-edit/join-tags/joined/ui/join-empty-left-two-tags.png b/corpus/paragraph-edit/join-tags/joined/ui/join-empty-left-two-tags.png
new file mode 100644
index 0000000000000000000000000000000000000000..07207d8ae0b7612aea91a6c1717b3e79b0700281
Binary files /dev/null and b/corpus/paragraph-edit/join-tags/joined/ui/join-empty-left-two-tags.png differ
diff --git a/corpus/paragraph-edit/join-tags/joined/ui/selections.json b/corpus/paragraph-edit/join-tags/joined/ui/selections.json
new file mode 100644
index 0000000000000000000000000000000000000000..0ef76b57c78091049b0d48d0920bef1a3c9a4eb1
--- /dev/null
+++ b/corpus/paragraph-edit/join-tags/joined/ui/selections.json
@@ -0,0 +1,20 @@
+[
+ {
+ "case": "Join empty left right tag",
+ "page": "{2A557903-2906-4962-90DB-1DBBD4B4F368}{1}{B0}",
+ "object": "{DB20B227-1FF5-4341-9621-D7C3E0EEE79B}{46}{B0}",
+ "keys": "{Left}{Backspace}"
+ },
+ {
+ "case": "Join empty left two tags",
+ "page": "{766D2454-BC90-480A-8BA1-BF0C9E703454}{1}{B0}",
+ "object": "{5237862E-022E-4FC4-8E36-D9C8E010D9E1}{47}{B0}",
+ "keys": "{Left}{Backspace}"
+ },
+ {
+ "case": "Join different tags",
+ "page": "{B93EF35F-621E-4F9A-90D4-B55A0CF7E4AA}{1}{B0}",
+ "object": "{998EEB43-1A80-48BB-B928-2AE6052587A2}{46}{B0}",
+ "keys": "{Left}{Backspace}"
+ }
+]
\ No newline at end of file
diff --git a/corpus/paragraph-edit/join-tags/run.json b/corpus/paragraph-edit/join-tags/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..6ba9afe3f14a07d8fb9b73fcae81069719dbd9c4
--- /dev/null
+++ b/corpus/paragraph-edit/join-tags/run.json
@@ -0,0 +1,19 @@
+{
+ "notebook": "/Users/clo/dev/one/corpus/formatted-insertion/native-paragraph/candidate",
+ "expected_pages": 4,
+ "author": "evidence/m10/native-paragraph-tag-joins.ps1",
+ "author_timeout_seconds": 600,
+ "inspect": true,
+ "collect_notebook": true,
+ "base": {
+ "file": "win7-office-base.qcow2",
+ "format": "qcow2",
+ "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346",
+ "virtual_size": 68719476736
+ },
+ "scripts": {
+ "author.ps1": "f975577d6abdb21e6cc8e2a6f605ab239047108e6da2e2f804ab8197991d86e7",
+ "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331",
+ "read.ps1": "04013bfcccee40a17e2a225f9b9e96f40a3a8daad9e350609654f8eda3ccbb41"
+ }
+}
diff --git a/corpus/paragraph-edit/join-tags/source.json b/corpus/paragraph-edit/join-tags/source.json
new file mode 120000
index 0000000000000000000000000000000000000000..4a9bb6bad713be61d465b5795764dd2e121941a8
--- /dev/null
+++ b/corpus/paragraph-edit/join-tags/source.json
@@ -0,0 +1 @@
+../join-edges/source.json
\ No newline at end of file
diff --git a/corpus/paragraph-edit/join-tags/teardown.json b/corpus/paragraph-edit/join-tags/teardown.json
new file mode 120000
index 0000000000000000000000000000000000000000..e7edcabb62ab061e4e24fd304ce5b814f51c282e
--- /dev/null
+++ b/corpus/paragraph-edit/join-tags/teardown.json
@@ -0,0 +1 @@
+../join-edges/teardown.json
\ No newline at end of file
diff --git a/corpus/paragraph-edit/rust-join/inheritance/candidate/synthetic.one b/corpus/paragraph-edit/rust-join/inheritance/candidate/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..1711afce11399174743d602c7c9a66789f663343
Binary files /dev/null and b/corpus/paragraph-edit/rust-join/inheritance/candidate/synthetic.one differ
diff --git a/corpus/paragraph-edit/rust-join/inheritance/manifest.json b/corpus/paragraph-edit/rust-join/inheritance/manifest.json
new file mode 100644
index 0000000000000000000000000000000000000000..5a7e1cd0111c4dea215677704946a71105260cbd
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/inheritance/manifest.json
@@ -0,0 +1,47 @@
+[
+ {
+ "case": "Join inherited styles",
+ "intent": {
+ "author": "Rust join author",
+ "left": "{10952FC5-A849-0DE5-03A7-5D9304627DA7},44",
+ "right": "{10952FC5-A849-0DE5-03A7-5D9304627DA7},50"
+ },
+ "space": "{D6CEB12D-D1B7-033B-20A2-E9B6FF501602},1"
+ },
+ {
+ "case": "Join empty left tag",
+ "intent": {
+ "author": "Rust join author",
+ "left": "{8BAFC64A-8457-0253-3906-57D27F8546B7},45",
+ "right": "{8BAFC64A-8457-0253-3906-57D27F8546B7},51"
+ },
+ "space": "{F6E7A6F0-805F-0858-2B5A-4B1260940CF9},1"
+ },
+ {
+ "case": "Join right tag",
+ "intent": {
+ "author": "Rust join author",
+ "left": "{2E9A20A9-1E0E-0F04-0FCE-8497CF032068},44",
+ "right": "{2E9A20A9-1E0E-0F04-0FCE-8497CF032068},49"
+ },
+ "space": "{04EE80E5-584B-04E0-1F05-07D86902DFA1},1"
+ },
+ {
+ "case": "Join both tags",
+ "intent": {
+ "author": "Rust join author",
+ "left": "{7A5195E3-F792-0C49-1F3C-EBBFCE774FE6},44",
+ "right": "{7A5195E3-F792-0C49-1F3C-EBBFCE774FE6},50"
+ },
+ "space": "{631A0B54-DBB1-0988-22E1-96E8B1202B59},1"
+ },
+ {
+ "case": "Join both children",
+ "intent": {
+ "author": "Rust join author",
+ "left": "{0384F5F6-5F0B-0164-32C6-6E97A4F1B661},49",
+ "right": "{0384F5F6-5F0B-0164-32C6-6E97A4F1B661},54"
+ },
+ "space": "{D7D982D1-A3D3-0690-1181-A9703CF717E5},1"
+ }
+]
\ No newline at end of file
diff --git a/corpus/paragraph-edit/rust-join/inheritance/native/notebook/Open Notebook.onetoc2 b/corpus/paragraph-edit/rust-join/inheritance/native/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..b0893a34c508a3537f3d2854a0f0e040034a97e8
Binary files /dev/null and b/corpus/paragraph-edit/rust-join/inheritance/native/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/paragraph-edit/rust-join/inheritance/native/notebook/synthetic.one b/corpus/paragraph-edit/rust-join/inheritance/native/notebook/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..9df4b97872e8ecc4b5ca09e2924edf639639e2ba
Binary files /dev/null and b/corpus/paragraph-edit/rust-join/inheritance/native/notebook/synthetic.one differ
diff --git a/corpus/paragraph-edit/rust-join/inheritance/native/read/environment.json b/corpus/paragraph-edit/rust-join/inheritance/native/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..a131c4590186f953625489f6ee0491aa2bbb4782
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/inheritance/native/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-36919857",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/paragraph-edit/rust-join/inheritance/native/read/hierarchy.xml b/corpus/paragraph-edit/rust-join/inheritance/native/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..43d800750395b12aa75a91372e4b9c71a0bef23c
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/inheritance/native/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/paragraph-edit/rust-join/inheritance/native/read/page-000.xml b/corpus/paragraph-edit/rust-join/inheritance/native/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..190bc6162af54e692b4f3f5ff984bb7ddcb2b694
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/inheritance/native/read/page-000.xml
@@ -0,0 +1,5 @@
+
+LeftBold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-join/inheritance/native/read/page-001.xml b/corpus/paragraph-edit/rust-join/inheritance/native/read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..b9f4e7f4664373221d9be021e23c8037e981272a
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/inheritance/native/read/page-001.xml
@@ -0,0 +1,20 @@
+
+Bold 🦀 italic é color 東京
+End]]>Fictitious: café, 東京, مرحبا]]>
diff --git a/corpus/paragraph-edit/rust-join/inheritance/native/read/page-002.xml b/corpus/paragraph-edit/rust-join/inheritance/native/read/page-002.xml
new file mode 100644
index 0000000000000000000000000000000000000000..bead0314c4f44865b27916f59f3571c91c851c5f
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/inheritance/native/read/page-002.xml
@@ -0,0 +1,5 @@
+
+LeftBold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-join/inheritance/native/read/page-003.xml b/corpus/paragraph-edit/rust-join/inheritance/native/read/page-003.xml
new file mode 100644
index 0000000000000000000000000000000000000000..d14c76a42aed7d91c2a05b10de9214c850b82690
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/inheritance/native/read/page-003.xml
@@ -0,0 +1,5 @@
+
+Bold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-join/inheritance/native/read/page-004.xml b/corpus/paragraph-edit/rust-join/inheritance/native/read/page-004.xml
new file mode 100644
index 0000000000000000000000000000000000000000..8a02d17b989865a26a76c6c2b19fc04e5288172a
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/inheritance/native/read/page-004.xml
@@ -0,0 +1,5 @@
+
+Left childBold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-join/inheritance/native/read/page-005.xml b/corpus/paragraph-edit/rust-join/inheritance/native/read/page-005.xml
new file mode 100644
index 0000000000000000000000000000000000000000..3a1c39eba71067e803c41b44105816ffde404fc4
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/inheritance/native/read/page-005.xml
@@ -0,0 +1,5 @@
+
+LeftRight italic]]>
diff --git a/corpus/paragraph-edit/rust-join/inheritance/native/read/payloads.json b/corpus/paragraph-edit/rust-join/inheritance/native/read/payloads.json
new file mode 120000
index 0000000000000000000000000000000000000000..787b4bae85038cda922f8befac6c8ed361fa8035
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/inheritance/native/read/payloads.json
@@ -0,0 +1 @@
+../../../../before/read/payloads.json
\ No newline at end of file
diff --git a/corpus/paragraph-edit/rust-join/inheritance/native/run.json b/corpus/paragraph-edit/rust-join/inheritance/native/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..c2f772a0b8978fa99392d3d82049a03eeb6848ba
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/inheritance/native/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/Users/clo/dev/one/evidence/m10/paragraph-rust-join-01/inheritance/candidate",
+ "expected_pages": 6,
+ "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-join/inheritance/native/source.json b/corpus/paragraph-edit/rust-join/inheritance/native/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..4ff673f31d5c63ccbca4e09520e1f20459168b94
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/inheritance/native/source.json
@@ -0,0 +1,8 @@
+[
+ {
+ "path": "synthetic.one",
+ "bytes": 56344,
+ "sha256": "50318b4b3d4f4850dd578518eea4cc7649d29cc0c84b0d6a9b41811f91e6e2e1",
+ "mtime_ns": 1788860404399203298
+ }
+]
diff --git a/corpus/paragraph-edit/rust-join/inheritance/native/teardown.json b/corpus/paragraph-edit/rust-join/inheritance/native/teardown.json
new file mode 120000
index 0000000000000000000000000000000000000000..4a260ae81ad209364b9d4ace600eec3f48f27aba
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/inheritance/native/teardown.json
@@ -0,0 +1 @@
+../../../join-edges/teardown.json
\ No newline at end of file
diff --git a/corpus/paragraph-edit/rust-join/split/candidate/synthetic.one b/corpus/paragraph-edit/rust-join/split/candidate/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..f6e260945fd148d5bf3002a9fb3605c51de2e865
Binary files /dev/null and b/corpus/paragraph-edit/rust-join/split/candidate/synthetic.one differ
diff --git a/corpus/paragraph-edit/rust-join/split/manifest.json b/corpus/paragraph-edit/rust-join/split/manifest.json
new file mode 100644
index 0000000000000000000000000000000000000000..3575fb6149837a356d4981dcf981cfa31ae3d2d1
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/split/manifest.json
@@ -0,0 +1,110 @@
+[
+ {
+ "case": "Split middle",
+ "intent": {
+ "author": "Rust join author",
+ "left": "{DFE950EF-6BEB-0F5B-2403-7B74F1AE5002},44",
+ "right": "{0BA342F5-0690-087C-24FE-8EE6D81C2041},12"
+ },
+ "space": "{FE224563-B517-023E-0D75-9904B1952A26},1"
+ },
+ {
+ "case": "Split style boundary",
+ "intent": {
+ "author": "Rust join author",
+ "left": "{5FB484E2-1E3E-0A9D-1989-271A3EBBEC42},44",
+ "right": "{051BC1DE-5AE6-0D04-0CEF-9D05F53C8EAE},12"
+ },
+ "space": "{DED48A05-0DB1-0712-086D-09237C982341},1"
+ },
+ {
+ "case": "Split start",
+ "intent": {
+ "author": "Rust join author",
+ "left": "{012ED94B-43B3-079A-3050-B1CBE25C1C09},44",
+ "right": "{B230A510-9428-0CE7-1B89-7CA970F8F676},12"
+ },
+ "space": "{7919E933-0809-00D8-1754-A21684215860},1"
+ },
+ {
+ "case": "Split end",
+ "intent": {
+ "author": "Rust join author",
+ "left": "{DAF723A3-C2A3-0798-2EAB-34613166A794},44",
+ "right": "{601DAE0A-2A53-0C28-3077-1FE8FCB26FC2},12"
+ },
+ "space": "{927F2809-173C-036E-37B6-60A6568F7723},1"
+ },
+ {
+ "case": "Split empty",
+ "intent": {
+ "author": "Rust join author",
+ "left": "{0B467F31-D287-08CD-09F4-A6FBB2F5418F},45",
+ "right": "{EACB4A2C-2444-0A1F-03D6-68187F2FB345},12"
+ },
+ "space": "{E4D8C4CC-508B-04D2-36CE-C5B8C9D2FEC4},1"
+ },
+ {
+ "case": "Split parent",
+ "intent": {
+ "author": "Rust join author",
+ "left": "{3860EA13-D444-0598-35D6-CF96769C6A21},44",
+ "right": "{C84CA5DB-61A8-0F9B-3F91-D6E0C74536F7},12"
+ },
+ "space": "{7A363D4C-73C2-0ABE-0931-E6F62A90B95E},1"
+ },
+ {
+ "case": "Split child",
+ "intent": {
+ "author": "Rust join author",
+ "left": "{B587CBC6-0BCC-0A23-16F6-E1B0B3C5D061},49",
+ "right": "{DC240C63-FC8E-0267-0AFD-A53062C25702},12"
+ },
+ "space": "{3CC2C4DC-651A-07C9-2F7B-375C6663F3CA},1"
+ },
+ {
+ "case": "Split bullet",
+ "intent": {
+ "author": "Rust join author",
+ "left": "{8C65E427-477C-02ED-1807-2F10E277019D},44",
+ "right": "{28F3F5EC-E687-02CA-2CFD-E73A184B875F},12"
+ },
+ "space": "{942EF5E4-FFD1-0FE4-23D2-7429899577BD},1"
+ },
+ {
+ "case": "Split number",
+ "intent": {
+ "author": "Rust join author",
+ "left": "{D4ABBE4F-2165-0122-1F9F-ACE3FE92C703},44",
+ "right": "{DD3FA0E7-15A6-01C4-1B77-F85F779BCBC8},12"
+ },
+ "space": "{A4AE40FF-DD80-0003-2856-17D8295C1B91},1"
+ },
+ {
+ "case": "Split tag",
+ "intent": {
+ "author": "Rust join author",
+ "left": "{37AA5877-2980-0227-169F-8B1E8EE0795A},44",
+ "right": "{1480427A-E9ED-04EC-0409-0653AE11FF78},12"
+ },
+ "space": "{739DB38B-4239-0A7C-2652-4E525D219638},1"
+ },
+ {
+ "case": "Split cell",
+ "intent": {
+ "author": "Rust join author",
+ "left": "{F0832BC9-1578-0A96-08AF-99344644FFB8},50",
+ "right": "{1E353497-5C26-0459-3C0C-0E8D29ED8C5A},12"
+ },
+ "space": "{4432E3EC-5E5F-0A04-2108-E9A2C17BDBEC},1"
+ },
+ {
+ "case": "Split soft break",
+ "intent": {
+ "author": "Rust join author",
+ "left": "{88189BA1-2F19-0282-05ED-A0B83153F143},44",
+ "right": "{C1568E82-003F-016B-041A-CA304E1A5138},12"
+ },
+ "space": "{D8E15673-A45E-0914-291A-7767C3222F19},1"
+ }
+]
\ No newline at end of file
diff --git a/corpus/paragraph-edit/rust-join/split/native/notebook/Open Notebook.onetoc2 b/corpus/paragraph-edit/rust-join/split/native/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..6b7942568a0471d110b51e14c98a37ff157ddc0a
Binary files /dev/null and b/corpus/paragraph-edit/rust-join/split/native/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/paragraph-edit/rust-join/split/native/notebook/synthetic.one b/corpus/paragraph-edit/rust-join/split/native/notebook/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..c628f10eb6e05aa86b8b8303798be23bb3fd6bb9
Binary files /dev/null and b/corpus/paragraph-edit/rust-join/split/native/notebook/synthetic.one differ
diff --git a/corpus/paragraph-edit/rust-join/split/native/read/environment.json b/corpus/paragraph-edit/rust-join/split/native/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..6e1172469add7fcc821a372c9d64f7211dded5ba
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/split/native/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-6A0771AD",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/paragraph-edit/rust-join/split/native/read/hierarchy.xml b/corpus/paragraph-edit/rust-join/split/native/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..e34c036c05b3bf2d925bccd0a8564c9d1e9e0588
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/split/native/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/paragraph-edit/rust-join/split/native/read/page-000.xml b/corpus/paragraph-edit/rust-join/split/native/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..9c5b5e5620baf4d2875b475c2e32ab7eb3228f15
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/split/native/read/page-000.xml
@@ -0,0 +1,5 @@
+
+Bold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-join/split/native/read/page-001.xml b/corpus/paragraph-edit/rust-join/split/native/read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..ffd2fc371f4b506d2b9c50ee8b3f513074920f82
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/split/native/read/page-001.xml
@@ -0,0 +1,20 @@
+
+Bold 🦀 italic é color 東京
+End]]>Fictitious: café, 東京, مرحبا]]>
diff --git a/corpus/paragraph-edit/rust-join/split/native/read/page-002.xml b/corpus/paragraph-edit/rust-join/split/native/read/page-002.xml
new file mode 100644
index 0000000000000000000000000000000000000000..42ce5dc2d35a421f95c5ff57d1656fd8bc15facf
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/split/native/read/page-002.xml
@@ -0,0 +1,5 @@
+
+Bold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-join/split/native/read/page-003.xml b/corpus/paragraph-edit/rust-join/split/native/read/page-003.xml
new file mode 100644
index 0000000000000000000000000000000000000000..f44f5a693145d9cd3e7f13915dd75976865eda0d
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/split/native/read/page-003.xml
@@ -0,0 +1,5 @@
+
+Bold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-join/split/native/read/page-004.xml b/corpus/paragraph-edit/rust-join/split/native/read/page-004.xml
new file mode 100644
index 0000000000000000000000000000000000000000..d3e9383b0cd0c25408c0fe4287b01935d20e2d6a
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/split/native/read/page-004.xml
@@ -0,0 +1,3 @@
+
+Link label tail]]>
diff --git a/corpus/paragraph-edit/rust-join/split/native/read/page-005.xml b/corpus/paragraph-edit/rust-join/split/native/read/page-005.xml
new file mode 100644
index 0000000000000000000000000000000000000000..c2ea1f1da0663e0195e7cd66a40115d4c0b801cf
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/split/native/read/page-005.xml
@@ -0,0 +1,5 @@
+
+Bold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-join/split/native/read/page-006.xml b/corpus/paragraph-edit/rust-join/split/native/read/page-006.xml
new file mode 100644
index 0000000000000000000000000000000000000000..a978087ea853f5223c0747101fc5becef4c63c79
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/split/native/read/page-006.xml
@@ -0,0 +1,5 @@
+
+Bold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-join/split/native/read/page-007.xml b/corpus/paragraph-edit/rust-join/split/native/read/page-007.xml
new file mode 100644
index 0000000000000000000000000000000000000000..d2c17cb3894672743c763c7ed7496409498cba06
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/split/native/read/page-007.xml
@@ -0,0 +1,3 @@
+
+
+soft break]]>
diff --git a/corpus/paragraph-edit/rust-join/split/native/read/page-008.xml b/corpus/paragraph-edit/rust-join/split/native/read/page-008.xml
new file mode 100644
index 0000000000000000000000000000000000000000..f3257b00d3350ab51c1309c37c19d2b683261b0b
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/split/native/read/page-008.xml
@@ -0,0 +1,5 @@
+
+Bold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-join/split/native/read/page-009.xml b/corpus/paragraph-edit/rust-join/split/native/read/page-009.xml
new file mode 100644
index 0000000000000000000000000000000000000000..22eed272f83abbc4a9d75c3f456c051dce48c9ea
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/split/native/read/page-009.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/paragraph-edit/rust-join/split/native/read/page-010.xml b/corpus/paragraph-edit/rust-join/split/native/read/page-010.xml
new file mode 100644
index 0000000000000000000000000000000000000000..6cacd112a07fcceeea14d9e3bf2d55cc453e8bda
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/split/native/read/page-010.xml
@@ -0,0 +1,5 @@
+
+Bold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-join/split/native/read/page-011.xml b/corpus/paragraph-edit/rust-join/split/native/read/page-011.xml
new file mode 100644
index 0000000000000000000000000000000000000000..c120a78c84de6cc5ddd62e849aef3c184149054c
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/split/native/read/page-011.xml
@@ -0,0 +1,6 @@
+
+Bold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-join/split/native/read/page-012.xml b/corpus/paragraph-edit/rust-join/split/native/read/page-012.xml
new file mode 100644
index 0000000000000000000000000000000000000000..c008ca62bde4526513ce51503a8bda59e9c7bf5f
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/split/native/read/page-012.xml
@@ -0,0 +1,5 @@
+
+Bold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-join/split/native/read/page-013.xml b/corpus/paragraph-edit/rust-join/split/native/read/page-013.xml
new file mode 100644
index 0000000000000000000000000000000000000000..37af0f5ceb319734cf8300e9ccaf64274a5ca054
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/split/native/read/page-013.xml
@@ -0,0 +1,5 @@
+
+Bold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-join/split/native/read/payloads.json b/corpus/paragraph-edit/rust-join/split/native/read/payloads.json
new file mode 120000
index 0000000000000000000000000000000000000000..787b4bae85038cda922f8befac6c8ed361fa8035
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/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-join/split/native/run.json b/corpus/paragraph-edit/rust-join/split/native/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..e75fc623f38ae54ac646344148893cc773e4c830
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/split/native/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/Users/clo/dev/one/evidence/m10/paragraph-rust-join-01/split/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-join/split/native/source.json b/corpus/paragraph-edit/rust-join/split/native/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..bfe81953672ef7d652c7c15c9b6a293083899bc6
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/split/native/source.json
@@ -0,0 +1,8 @@
+[
+ {
+ "path": "synthetic.one",
+ "bytes": 139752,
+ "sha256": "6677af274e17de1ac194b2b18bc96d3eb33c04968881c272383f7cc47b10c5de",
+ "mtime_ns": 1788860404345967341
+ }
+]
diff --git a/corpus/paragraph-edit/rust-join/split/native/teardown.json b/corpus/paragraph-edit/rust-join/split/native/teardown.json
new file mode 120000
index 0000000000000000000000000000000000000000..4a260ae81ad209364b9d4ace600eec3f48f27aba
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/split/native/teardown.json
@@ -0,0 +1 @@
+../../../join-edges/teardown.json
\ No newline at end of file
diff --git a/corpus/paragraph-edit/rust-join/tags/candidate/synthetic.one b/corpus/paragraph-edit/rust-join/tags/candidate/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..71430622d67679d3cf2ba34cb5c46ce06b6f0842
Binary files /dev/null and b/corpus/paragraph-edit/rust-join/tags/candidate/synthetic.one differ
diff --git a/corpus/paragraph-edit/rust-join/tags/manifest.json b/corpus/paragraph-edit/rust-join/tags/manifest.json
new file mode 100644
index 0000000000000000000000000000000000000000..fe168eaa1035cd51ca7af2ed8b9956b5618dd042
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/tags/manifest.json
@@ -0,0 +1,29 @@
+[
+ {
+ "case": "Join empty left right tag",
+ "intent": {
+ "author": "Rust join author",
+ "left": "{0D11242E-6A38-0F74-261A-A6FC444FA262},45",
+ "right": "{0D11242E-6A38-0F74-261A-A6FC444FA262},50"
+ },
+ "space": "{FC64EF0A-5CCB-0557-20E0-6C847015B691},1"
+ },
+ {
+ "case": "Join empty left two tags",
+ "intent": {
+ "author": "Rust join author",
+ "left": "{84061027-77E3-03F1-3E0D-A8F744B19C18},45",
+ "right": "{84061027-77E3-03F1-3E0D-A8F744B19C18},51"
+ },
+ "space": "{A05CB25D-C95D-043F-3B9A-CE333AD171AD},1"
+ },
+ {
+ "case": "Join different tags",
+ "intent": {
+ "author": "Rust join author",
+ "left": "{4FBF7D4A-6F4D-048E-0913-5BD9A184C25B},44",
+ "right": "{4FBF7D4A-6F4D-048E-0913-5BD9A184C25B},50"
+ },
+ "space": "{6F0F6556-17D3-03AF-20EF-C465A856A153},1"
+ }
+]
\ No newline at end of file
diff --git a/corpus/paragraph-edit/rust-join/tags/native/notebook/Open Notebook.onetoc2 b/corpus/paragraph-edit/rust-join/tags/native/notebook/Open Notebook.onetoc2
new file mode 100644
index 0000000000000000000000000000000000000000..28ca4e63834c7b4a24dae185a1adf3a2a6fddec1
Binary files /dev/null and b/corpus/paragraph-edit/rust-join/tags/native/notebook/Open Notebook.onetoc2 differ
diff --git a/corpus/paragraph-edit/rust-join/tags/native/notebook/synthetic.one b/corpus/paragraph-edit/rust-join/tags/native/notebook/synthetic.one
new file mode 100644
index 0000000000000000000000000000000000000000..f805fa4ceb33ef378162f71bf3f4d7a12592b423
Binary files /dev/null and b/corpus/paragraph-edit/rust-join/tags/native/notebook/synthetic.one differ
diff --git a/corpus/paragraph-edit/rust-join/tags/native/read/environment.json b/corpus/paragraph-edit/rust-join/tags/native/read/environment.json
new file mode 100644
index 0000000000000000000000000000000000000000..cf321e2d9f6da6877ffb684320da7595a3e2470b
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/tags/native/read/environment.json
@@ -0,0 +1,7 @@
+{
+ "powershell": "5.1.14409.1005",
+ "schema": "xs2010",
+ "hostname": "ONE-M6-5FECAC90",
+ "cold": true,
+ "onenote": "14.0.4763.1000"
+}
diff --git a/corpus/paragraph-edit/rust-join/tags/native/read/hierarchy.xml b/corpus/paragraph-edit/rust-join/tags/native/read/hierarchy.xml
new file mode 100644
index 0000000000000000000000000000000000000000..0bb2568bd3f2ec5818ecb748cfd4ca7aec9c0bf2
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/tags/native/read/hierarchy.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/corpus/paragraph-edit/rust-join/tags/native/read/page-000.xml b/corpus/paragraph-edit/rust-join/tags/native/read/page-000.xml
new file mode 100644
index 0000000000000000000000000000000000000000..5cdd251abfb245b33b76c801b4011c91dcb61958
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/tags/native/read/page-000.xml
@@ -0,0 +1,20 @@
+
+Bold 🦀 italic é color 東京
+End]]>Fictitious: café, 東京, مرحبا]]>
diff --git a/corpus/paragraph-edit/rust-join/tags/native/read/page-001.xml b/corpus/paragraph-edit/rust-join/tags/native/read/page-001.xml
new file mode 100644
index 0000000000000000000000000000000000000000..6a6d722dd3bd9b3e18c7ee18ace84160023ce944
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/tags/native/read/page-001.xml
@@ -0,0 +1,5 @@
+
+LeftBold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-join/tags/native/read/page-002.xml b/corpus/paragraph-edit/rust-join/tags/native/read/page-002.xml
new file mode 100644
index 0000000000000000000000000000000000000000..6979a01a5cbe465260ce7c593d333a93a325509b
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/tags/native/read/page-002.xml
@@ -0,0 +1,5 @@
+
+Bold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-join/tags/native/read/page-003.xml b/corpus/paragraph-edit/rust-join/tags/native/read/page-003.xml
new file mode 100644
index 0000000000000000000000000000000000000000..7013b491ac10291da4085a978aa404d6f0476818
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/tags/native/read/page-003.xml
@@ -0,0 +1,5 @@
+
+Bold italic 🦀 é tail]]>
diff --git a/corpus/paragraph-edit/rust-join/tags/native/read/payloads.json b/corpus/paragraph-edit/rust-join/tags/native/read/payloads.json
new file mode 120000
index 0000000000000000000000000000000000000000..787b4bae85038cda922f8befac6c8ed361fa8035
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/tags/native/read/payloads.json
@@ -0,0 +1 @@
+../../../../before/read/payloads.json
\ No newline at end of file
diff --git a/corpus/paragraph-edit/rust-join/tags/native/run.json b/corpus/paragraph-edit/rust-join/tags/native/run.json
new file mode 100644
index 0000000000000000000000000000000000000000..74bec79c775e24e11e08a5162d26b1176db2860b
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/tags/native/run.json
@@ -0,0 +1,18 @@
+{
+ "notebook": "/Users/clo/dev/one/evidence/m10/paragraph-rust-join-01/tags/candidate",
+ "expected_pages": 4,
+ "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-join/tags/native/source.json b/corpus/paragraph-edit/rust-join/tags/native/source.json
new file mode 100644
index 0000000000000000000000000000000000000000..e9dd9e3f2cadc7bbf354bb48d1389f7ef70b4dce
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/tags/native/source.json
@@ -0,0 +1,8 @@
+[
+ {
+ "path": "synthetic.one",
+ "bytes": 41808,
+ "sha256": "04f5dd05df411fdb228ff7cb80e02ca24744d40b6f0bd485b688aa8c0d6e8ac3",
+ "mtime_ns": 1788860404422342545
+ }
+]
diff --git a/corpus/paragraph-edit/rust-join/tags/native/teardown.json b/corpus/paragraph-edit/rust-join/tags/native/teardown.json
new file mode 120000
index 0000000000000000000000000000000000000000..4a260ae81ad209364b9d4ace600eec3f48f27aba
--- /dev/null
+++ b/corpus/paragraph-edit/rust-join/tags/native/teardown.json
@@ -0,0 +1 @@
+../../../join-edges/teardown.json
\ No newline at end of file
diff --git a/crates/onestore/README.md b/crates/onestore/README.md
index e029c43cadc0257e54ea518ef6d1e30f6632f27c..3bc5ce49fa5859bd51d2f536a316032009bcddf4 100644
--- a/crates/onestore/README.md
+++ b/crates/onestore/README.md
@@ -50,6 +50,7 @@ harness also accepts `--client-profile release`.
| `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 |
+| `ParagraphJoin`, `PreparedEdit::join` | Join adjacent ordinary text while preserving inherited character styles and native text-identity rules |
| `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 |
@@ -85,6 +86,14 @@ the complete child graph and title metadata; repeating an existing identity requ
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).
+Joins retain the left paragraph. Nonempty left text keeps its identity; empty left
+text adopts the right text identity. **The left tags win: right-side tags are removed
+from active text even when the left text is empty.** History retains the original
+objects. Select the preceding leaf text; where that leaf is deeper than the right
+paragraph, right children move to its ancestor at the right paragraph's level.
+Ambiguous ancestry, unsupported indentation transitions and unknown implicit
+font/language inheritance reject before I/O. This is a logical join, so keyboard
+actions that only change list or indentation state remain separate operations.
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)
diff --git a/crates/onestore/src/commit.rs b/crates/onestore/src/commit.rs
index dbc621e4e78b394a7cf6df0fd6cf7a56099b682c..6bd5ed03294cfd1ec4434876863b98cd958cfce9 100644
--- a/crates/onestore/src/commit.rs
+++ b/crates/onestore/src/commit.rs
@@ -183,6 +183,18 @@ pub struct PreparedEdit<'a> {
}
impl<'a> PreparedEdit<'a> {
+ /// Joins adjacent ordinary paragraphs with native left-tag and text-identity semantics.
+ pub fn join(
+ source: &'a [u8],
+ space: ExGuid,
+ join: &crate::ParagraphJoin,
+ ) -> Result {
+ Ok(Self {
+ source,
+ written: join.apply(source, space)?,
+ })
+ }
+
/// 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(
diff --git a/crates/onestore/src/lib.rs b/crates/onestore/src/lib.rs
index 9eac9aa570e7f35e36f497f9f2d791d773d38af8..379b01a43e63d64f37c057beb4eaf16c02cb894c 100644
--- a/crates/onestore/src/lib.rs
+++ b/crates/onestore/src/lib.rs
@@ -32,7 +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 paragraph::{ParagraphJoin, 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
index 0d96074ac3ecbddf7b3f14376fb6d2c37914d625..d122deeebd33fee4774f7559c2d732aa6293a0f7 100644
--- a/crates/onestore/src/paragraph.rs
+++ b/crates/onestore/src/paragraph.rs
@@ -76,7 +76,7 @@ impl ParagraphSplit {
.into_iter()
.filter_map(|(sid, page)| (sid == space).then_some(page))
.collect();
- let [page] = pages.as_slice() else {
+ let [_] = pages.as_slice() else {
return Err(invalid("Splitting requires a single active page"));
};
let semantic = document
@@ -84,7 +84,7 @@ impl ParagraphSplit {
.remove(&space)
.ok_or_else(|| invalid("The active page is unavailable"))?;
let rid = semantic.contexts[&ExGuid::default()];
- let mut view = semantic
+ let view = semantic
.revisions
.into_iter()
.find_map(|(id, view)| (id == rid).then_some(view))
@@ -134,48 +134,9 @@ impl ParagraphSplit {
}
pending.extend(parents.get(&id).into_iter().flatten().copied());
}
+ let raw = index.resolve(space, rid)?;
+ let (text, runs) = ordinary_text(&view, &raw, self.text)?;
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 {
@@ -286,39 +247,7 @@ impl ParagraphSplit {
};
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] })])?;
- }
+ update_title(&store, &raw, view, &pages, &mut changed)?;
write_revision(source, space, |_| Ok(changed))
}
}
@@ -381,3 +310,473 @@ fn fragment(
])?;
Ok(object)
}
+
+fn ordinary_text<'a>(
+ view: &'a crate::document::Revision<'_>,
+ raw: &crate::ResolvedRevision<'_>,
+ id: ExGuid,
+) -> Result<(&'a str, &'a [crate::document::TextRun]), Error> {
+ let node = &view.nodes[&id];
+ 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 or joined",
+ ));
+ }
+ for run in view.text_runs(id)? {
+ 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 or joined",
+ ));
+ }
+ }
+ let ObjectData::Properties(blob) = raw.objects[&id].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 or joined",
+ ));
+ }
+ Ok((text, runs))
+}
+
+fn update_title(
+ store: &Store<'_>,
+ raw: &crate::ResolvedRevision<'_>,
+ view: crate::document::Revision<'_>,
+ pages: &[ExGuid],
+ changed: &mut BTreeMap,
+) -> Result<(), Error> {
+ // Shorten the moved view's lifetime to the changed property buffers.
+ let mut view = view;
+ for (id, object) in changed.iter() {
+ 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(&pages[0])
+ .unwrap()
+ .set(&[(0x1c001d3c, if automatic { &title } else { &[0, 0] })])?;
+ }
+ Ok(())
+}
+
+/// Joins adjacent ordinary text in one outline or table cell.
+/// The left paragraph survives. Empty left text adopts the right text identity;
+/// otherwise the left text survives. The left paragraph's tags are retained.
+/// Right-side tags are removed from active text, including when the left text is empty.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct ParagraphJoin {
+ left: ExGuid,
+ right: ExGuid,
+ author: String,
+}
+
+impl ParagraphJoin {
+ /// Select the preceding leaf paragraph's text and the following paragraph's text.
+ pub fn new(left: ExGuid, right: ExGuid, author: &str) -> Result {
+ if left == right || left.guid == [0; 16] || right.guid == [0; 16] || author.contains('\0') {
+ return Err(invalid(
+ "Choose two distinct text objects and an author name without NUL",
+ ));
+ }
+ Ok(Self {
+ left,
+ right,
+ author: author.to_owned(),
+ })
+ }
+
+ pub(crate) fn apply(&self, source: &[u8], space: ExGuid) -> Result, Error> {
+ if self.left == self.right
+ || self.left.guid == [0; 16]
+ || self.right.guid == [0; 16]
+ || self.author.contains('\0')
+ {
+ return Err(invalid(
+ "Choose two distinct text objects 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();
+ if pages.len() != 1 {
+ return Err(invalid("Joining 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 view = semantic
+ .revisions
+ .into_iter()
+ .find_map(|(id, view)| (id == rid).then_some(view))
+ .unwrap();
+ let parents = editable_parents(&view, &pages, self.left)?;
+ editable_parents(&view, &pages, self.right)?;
+ let parent = |id| -> Result {
+ let [parent] = parents.get(&id).map(Vec::as_slice).unwrap_or_default() else {
+ return Err(invalid("Select text with a unique editable parent"));
+ };
+ Ok(*parent)
+ };
+ let left = parent(self.left)?;
+ let right = parent(self.right)?;
+ for (paragraph, text) in [(left, self.left), (right, self.right)] {
+ if !matches!(view.nodes[¶graph].kind, Kind::Paragraph { .. })
+ || view.nodes[¶graph].content != [text]
+ {
+ return Err(invalid("Select ordinary paragraph text"));
+ }
+ let mut at = paragraph;
+ while at != pages[0] {
+ if matches!(view.nodes[&at].kind, Kind::Title) {
+ return Err(invalid(
+ "Title containers cannot be joined with ordinary paragraphs",
+ ));
+ }
+ at = parent(at)?;
+ }
+ }
+ if !view.nodes[&left].children.is_empty() {
+ return Err(invalid("Select the preceding paragraph's last descendant"));
+ }
+ let right_parent = parent(right)?;
+ if !matches!(
+ view.nodes[&right_parent].kind,
+ Kind::Outline { .. } | Kind::OutlineGroup | Kind::Paragraph { .. } | Kind::Cell { .. }
+ ) {
+ return Err(invalid("Select paragraphs in one outline or table cell"));
+ }
+ let mut stem = left;
+ while parent(stem)? != right_parent {
+ let previous = stem;
+ stem = parent(stem)?;
+ if !matches!(
+ view.nodes[&stem].kind,
+ Kind::Paragraph { .. } | Kind::OutlineGroup
+ ) || view.nodes[&stem].children.last() != Some(&previous)
+ {
+ return Err(invalid(
+ "Select adjacent text within one outline or table cell",
+ ));
+ }
+ }
+ let siblings = &view.nodes[&right_parent].children;
+ if !siblings.windows(2).any(|pair| pair == [stem, right]) {
+ return Err(invalid("Select adjacent paragraph text in document order"));
+ }
+ let children = &view.nodes[&right].children;
+ if !children.is_empty()
+ && !view.nodes[&stem].children.is_empty()
+ && view.nodes[&stem].child_level != view.nodes[&right].child_level
+ {
+ return Err(invalid(
+ "Joining these child indentation levels requires a hierarchy edit",
+ ));
+ }
+ let raw = index.resolve(space, rid)?;
+ let (a, a_runs) = ordinary_text(&view, &raw, self.left)?;
+ let (b, b_runs) = ordinary_text(&view, &raw, self.right)?;
+ let length = u32::try_from(a.encode_utf16().count())
+ .map_err(|_| invalid("Paragraph exceeds the UTF-16 offset range"))?;
+ let right_length = u32::try_from(b.encode_utf16().count())
+ .map_err(|_| invalid("Paragraph exceeds the UTF-16 offset range"))?;
+ length
+ .checked_add(right_length)
+ .ok_or_else(|| invalid("Joined text exceeds the UTF-16 offset range"))?;
+ let modified = current_timestamps()?.0.to_le_bytes();
+ let mut changed = BTreeMap::new();
+ let (survivor, mut text) = if a.is_empty() {
+ let mut target = PropertyObject::from_object(&raw.objects[&self.right])?;
+ target.copy_property(
+ &PropertyObject::from_object(&raw.objects[&self.left])?,
+ 0x40003489,
+ )?;
+ (self.right, target)
+ } else {
+ let mut target = PropertyObject::from_object(&raw.objects[&self.left])?;
+ let mut styles = BTreeMap::new();
+ let mut ends = Vec::new();
+ let mut references = Vec::new();
+ let mut empty_style = None;
+ for run in a_runs.iter().filter(|run| run.start < run.end) {
+ let id = if let Some(id) = run.format {
+ id
+ } else if let Some(id) = empty_style {
+ id
+ } else {
+ let id = ExGuid {
+ guid: fresh_guid()?,
+ n: 1,
+ };
+ changed.insert(
+ id,
+ PropertyObject {
+ jcid: 0x12004d,
+ bytes: properties(&[])?,
+ global_ids: Arc::new(BTreeMap::from([(0, id.guid)])),
+ },
+ );
+ empty_style = Some(id);
+ id
+ };
+ references.extend_from_slice(&target.reference(id)?);
+ ends.extend_from_slice(&run.end.to_le_bytes());
+ }
+ let left_base = character_properties(&view, &raw, self.left)?;
+ let right_base = character_properties(&view, &raw, self.right)?;
+ for (i, run) in b_runs.iter().enumerate() {
+ if run.start == run.end && i != b_runs.len() - 1 {
+ continue;
+ }
+ let id = if let Some(id) = styles.get(&run.format) {
+ *id
+ } else {
+ let mut style = if let Some(id) = run.format {
+ PropertyObject::from_object(&raw.objects[&id])?
+ } else {
+ PropertyObject {
+ jcid: 0x12004d,
+ bytes: properties(&[])?,
+ global_ids: Arc::new(BTreeMap::new()),
+ }
+ };
+ if left_base != right_base {
+ let mut values = right_base.clone();
+ for property in &PropertySets::parse(&style.bytes)?.sets[0] {
+ let key = property.id & 0x7fffffff;
+ if is_character_property(key) {
+ let value = match property.value {
+ crate::Value::NoData => &[][..],
+ crate::Value::Bytes(bytes) => bytes,
+ _ => unreachable!(),
+ };
+ values.insert(key, (property.id, value.to_vec()));
+ }
+ }
+ for key in left_base.keys() {
+ if values.contains_key(key) {
+ continue;
+ }
+ let value = match key >> 26 & 31 {
+ 2 => Vec::new(),
+ _ if matches!(*key, 0x14001c0c | 0x14001c0d) => {
+ 0xff000000_u32.to_le_bytes().to_vec()
+ }
+ _ => {
+ return Err(invalid(
+ "The right paragraph's implicit font or language cannot be preserved under the left style",
+ ));
+ }
+ };
+ values.insert(*key, (*key, value));
+ }
+ style.set(
+ &values
+ .values()
+ .map(|(id, value)| (*id, value.as_slice()))
+ .collect::>(),
+ )?;
+ }
+ let id = if let Some(id) = run
+ .format
+ .filter(|id| raw.objects[id].data == ObjectData::Properties(&style.bytes))
+ {
+ id
+ } else {
+ let id = ExGuid {
+ guid: fresh_guid()?,
+ n: 1,
+ };
+ style.reference(id)?;
+ changed.insert(id, style);
+ id
+ };
+ styles.insert(run.format, id);
+ id
+ };
+ references.extend_from_slice(&target.reference(id)?);
+ ends.extend_from_slice(&(length + run.end).to_le_bytes());
+ }
+ ends.truncate(ends.len() - 4);
+ if ends
+ .chunks_exact(4)
+ .map(|bytes| u32::from_le_bytes(bytes.try_into().unwrap()))
+ .collect::>()
+ .windows(2)
+ .any(|pair| pair[0] >= pair[1])
+ {
+ return Err(invalid("Text-run boundaries must be strictly increasing"));
+ }
+ target.set(&[
+ (0x1c001c22, &string(&format!("{a}{b}"))),
+ (0x1c001e12, &ends),
+ (0x24001e13, &references),
+ ])?;
+ (self.left, target)
+ };
+ text.set(&[(0x14001d7a, &modified)])?;
+ changed.insert(survivor, text);
+ let author = ExGuid {
+ guid: fresh_guid()?,
+ n: 1,
+ };
+ changed.insert(
+ author,
+ PropertyObject {
+ jcid: 0x120001,
+ bytes: properties(&[(0x1c001d75, string(&self.author))])?,
+ global_ids: Arc::new(BTreeMap::from([(0, author.guid)])),
+ },
+ );
+ let mut left_object = PropertyObject::from_object(&raw.objects[&left])?;
+ let content = left_object.reference(survivor)?;
+ let author = left_object.reference(author)?;
+ left_object.set(&[(0x24001c1f, &content), (0x20001d79, &author)])?;
+ changed.insert(left, left_object);
+ if !children.is_empty() {
+ let target = match changed.entry(stem) {
+ std::collections::btree_map::Entry::Occupied(entry) => entry.into_mut(),
+ std::collections::btree_map::Entry::Vacant(entry) => {
+ entry.insert(PropertyObject::from_object(&raw.objects[&stem])?)
+ }
+ };
+ let mut references = Vec::new();
+ for id in view.nodes[&stem].children.iter().chain(children) {
+ references.extend_from_slice(&target.reference(*id)?);
+ }
+ target.set(&[(0x24001c20, &references)])?;
+ target.copy_property(
+ &PropertyObject::from_object(&raw.objects[&right])?,
+ 0x0c001c03,
+ )?;
+ }
+ let mut container = PropertyObject::from_object(&raw.objects[&right_parent])?;
+ let mut references = Vec::new();
+ for id in siblings.iter().filter(|id| **id != right) {
+ references.extend_from_slice(&container.reference(*id)?);
+ }
+ container.set(&[(0x24001c20, &references)])?;
+ changed.insert(right_parent, container);
+ let mut pending = vec![left, right_parent];
+ let mut ancestors = BTreeSet::new();
+ while let Some(id) = pending.pop() {
+ if !ancestors.insert(id) {
+ continue;
+ }
+ 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)])?;
+ pending.extend(parents.get(&id).into_iter().flatten().copied());
+ }
+ update_title(&store, &raw, view, &pages, &mut changed)?;
+ write_revision(source, space, |_| Ok(changed))
+ }
+}
+
+fn is_character_property(id: u32) -> bool {
+ matches!(
+ id,
+ 0x08001c04
+ ..=0x08001c09
+ | 0x08001e16
+ | 0x08001e14
+ | 0x08001e19
+ | 0x08003401
+ | 0x08001e22
+ | 0x08003476
+ | 0x1c001c0a
+ | 0x10001c0b
+ | 0x14001c0c
+ | 0x14001c0d
+ | 0x14001c3b
+ )
+}
+
+fn character_properties(
+ view: &crate::document::Revision<'_>,
+ raw: &crate::ResolvedRevision<'_>,
+ text: ExGuid,
+) -> Result)>, Error> {
+ let Kind::RichText {
+ paragraph_style, ..
+ } = view.nodes[&text].kind
+ else {
+ unreachable!()
+ };
+ let mut values = BTreeMap::new();
+ for id in paragraph_style.into_iter().chain([text]) {
+ let ObjectData::Properties(bytes) = raw.objects[&id].data else {
+ unreachable!()
+ };
+ for property in &PropertySets::parse(bytes)?.sets[0] {
+ let key = property.id & 0x7fffffff;
+ if is_character_property(key) {
+ let value = match property.value {
+ crate::Value::NoData => &[][..],
+ crate::Value::Bytes(bytes) => bytes,
+ _ => unreachable!(),
+ };
+ values.insert(key, (property.id, value.to_vec()));
+ }
+ }
+ }
+ Ok(values)
+}
diff --git a/crates/onestore/src/write.rs b/crates/onestore/src/write.rs
index 581a72460722c325de61b9e9bbef0b5e00bcf1a3..fb7dcd9f2162f4860f7ff3884b9989901eb9ff63 100644
--- a/crates/onestore/src/write.rs
+++ b/crates/onestore/src/write.rs
@@ -472,6 +472,114 @@ impl PropertyObject {
}
compact(id, &self.global_ids)
}
+
+ pub fn copy_property(&mut self, source: &Self, id: u32) -> Result<()> {
+ let properties = PropertySets::parse(&source.bytes)?;
+ let mut target = Self {
+ jcid: self.jcid,
+ bytes: self.bytes.clone(),
+ global_ids: Arc::clone(&self.global_ids),
+ };
+ target.remove(&[id])?;
+ let lengths = property_set_lengths(&properties);
+ let mut offset = properties.root_ids.as_ptr().addr() - source.bytes.as_ptr().addr()
+ + properties.root_ids.len();
+ let mut selected = None;
+ for property in &properties.sets[0] {
+ let end = offset + field_length(property, &lengths);
+ if property.id & 0x7fffffff == id & 0x7fffffff {
+ selected = Some((property, &source.bytes[offset..end]));
+ break;
+ }
+ offset = end;
+ }
+ if let Some((property, field)) = selected {
+ let mut added: [Vec; 3] = std::array::from_fn(|_| Vec::new());
+ let mut pending = vec![property];
+ while let Some(property) = pending.pop() {
+ match &property.value {
+ Value::References {
+ stream,
+ compact_ids,
+ } => {
+ let index = match stream {
+ crate::IdStream::Objects => 0,
+ crate::IdStream::ObjectSpaces => 1,
+ crate::IdStream::Contexts => 2,
+ };
+ let mut cursor = crate::bytes::Cursor {
+ bytes: compact_ids,
+ offset: 0,
+ };
+ while cursor.offset < cursor.bytes.len() {
+ let id = cursor.compact(&source.global_ids)?;
+ added[index].extend_from_slice(&target.reference(id)?);
+ }
+ }
+ Value::Sets(children) => {
+ for child in children.clone().rev() {
+ pending.extend(properties.sets[child].iter().rev());
+ }
+ }
+ _ => {}
+ }
+ }
+ let old = PropertySets::parse(&target.bytes)?;
+ let count = u16::try_from(old.sets[0].len() + 1).map_err(|_| Error {
+ offset: 0,
+ message: "Root property count exceeds the format limit",
+ })?;
+ let streams = crate::properties::reference_streams(&mut crate::bytes::Cursor {
+ bytes: &target.bytes,
+ offset: 0,
+ })?;
+ let stream_count = (0..3)
+ .rev()
+ .find(|i| streams[*i].offset != 0 || !added[*i].is_empty())
+ .unwrap()
+ + 1;
+ let mut bytes = Vec::new();
+ for i in 0..stream_count {
+ let stream = &streams[i];
+ let count = u32::try_from((stream.bytes.len() + added[i].len()) / 4)
+ .ok()
+ .filter(|n| *n <= 0xffffff)
+ .ok_or(Error {
+ offset: 0,
+ message: "Reference stream exceeds the format limit",
+ })?;
+ let reserved = if stream.offset == 0 {
+ 0
+ } else {
+ u32::from_le_bytes(
+ target.bytes[stream.offset - 4..stream.offset]
+ .try_into()
+ .unwrap(),
+ ) & 0x3f000000
+ };
+ let flags = match (i, stream_count) {
+ (0, 1) => 0x80000000,
+ (0 | 1, 3) => 0x40000000,
+ _ => 0,
+ };
+ bytes.extend_from_slice(&(count | reserved | flags).to_le_bytes());
+ bytes.extend_from_slice(stream.bytes);
+ bytes.extend_from_slice(&added[i]);
+ }
+ bytes.extend_from_slice(&count.to_le_bytes());
+ bytes.extend_from_slice(old.root_ids);
+ bytes.extend_from_slice(&property.id.to_le_bytes());
+ let start =
+ old.root_ids.as_ptr().addr() - target.bytes.as_ptr().addr() + old.root_ids.len();
+ bytes.extend_from_slice(&target.bytes[start..target.bytes.len() - old.padding.len()]);
+ bytes.extend_from_slice(field);
+ bytes.resize(bytes.len().next_multiple_of(8), 0);
+ PropertySets::parse(&bytes)?;
+ target.bytes = bytes;
+ }
+ *self = target;
+ Ok(())
+ }
}
pub(crate) fn write_revision(
diff --git a/crates/onestore/src/write/tests.rs b/crates/onestore/src/write/tests.rs
index 5e996875596247be4c62f01f55f8f8c6c619c30a..ae67fe251d1d4d1eaac1938f39cdb402e32fe6b6 100644
--- a/crates/onestore/src/write/tests.rs
+++ b/crates/onestore/src/write/tests.rs
@@ -29,8 +29,11 @@ fn document_insertions_and_formatting_respect_readonly_ancestors() {
))
})
.unwrap();
+ let right = Insertion::paragraph(outline, None, "Right", "Author").unwrap();
+ let expanded = PreparedEdit::insert(&source, sid, &right).unwrap();
+ let source = expanded.as_bytes();
for blocked in [page, outline, paragraph] {
- let protected = write_revision(&source, sid, |raw| {
+ let protected = write_revision(source, sid, |raw| {
let mut object = PropertyObject::from_object(&raw.objects[&blocked])?;
object.set(&[(0x88001cde, &[])])?;
Ok(BTreeMap::from([(blocked, object)]))
@@ -48,6 +51,8 @@ fn document_insertions_and_formatting_respect_readonly_ancestors() {
.unwrap();
let split = crate::ParagraphSplit::new(text, 1, "Author").unwrap();
assert!(PreparedEdit::split(&protected, sid, &split).is_err());
+ let join = crate::ParagraphJoin::new(text, right.text_object(), "Author").unwrap();
+ assert!(PreparedEdit::join(&protected, sid, &join).is_err());
assert!(
PreparedEdit::format(
&protected,
@@ -63,6 +68,18 @@ fn document_insertions_and_formatting_respect_readonly_ancestors() {
assert!(PreparedEdit::insert(&protected, sid, &outline).is_err());
}
}
+ let protected = write_revision(source, sid, |raw| {
+ let mut object = PropertyObject::from_object(&raw.objects[&right.object()])?;
+ object.set(&[(0x88001cde, &[])])?;
+ Ok(BTreeMap::from([(right.object(), object)]))
+ })
+ .unwrap();
+ let document = crate::document::Document::parse(&index).unwrap();
+ let space = &document.spaces[&sid];
+ let view = &space.revisions[&space.contexts[&ExGuid::default()]];
+ let text = view.nodes[¶graph].content[0];
+ let join = crate::ParagraphJoin::new(text, right.text_object(), "Author").unwrap();
+ assert!(PreparedEdit::join(&protected, sid, &join).is_err());
}
fn add_paragraph(source: &[u8], number: u32) -> Vec {
@@ -572,6 +589,56 @@ fn nested_fields_and_other_reference_streams_remain_byte_exact() {
object.remove(&ids).unwrap();
assert_eq!(object.bytes, before);
}
+ let source = super::PropertyObject {
+ jcid: 0x6000e,
+ bytes: original.clone(),
+ global_ids: std::sync::Arc::new(std::collections::BTreeMap::from([(0, [1; 16])])),
+ };
+ let mut target = super::PropertyObject {
+ jcid: 0x6000e,
+ bytes: properties(&[(0x20000001, vec![6, 0, 0, 0])]).unwrap(),
+ global_ids: std::sync::Arc::new(std::collections::BTreeMap::from([(0, [9; 16])])),
+ };
+ target.copy_property(&source, 0x40000002).unwrap();
+ let copied = PropertySets::parse(&target.bytes).unwrap();
+ assert_eq!(target.global_ids[&0], [9; 16]);
+ assert_eq!(target.global_ids[&1], [1; 16]);
+ assert_eq!(
+ copied.sets[0][0].value,
+ Value::References {
+ stream: crate::IdStream::Objects,
+ compact_ids: &[6, 0, 0, 0],
+ }
+ );
+ for (i, n) in [2, 4, 5].into_iter().enumerate() {
+ let Value::References { compact_ids, .. } = copied.sets[1][i].value else {
+ panic!()
+ };
+ assert_eq!(compact_ids, &[n, 1, 0, 0]);
+ }
+ assert_eq!(copied.sets[1][3], previous.sets[1][3]);
+ assert_eq!(
+ target
+ .bytes
+ .windows(nested.len())
+ .filter(|b| *b == nested)
+ .count(),
+ 1
+ );
+ let before = target.bytes.clone();
+ target.copy_property(&source, 0x40000002).unwrap();
+ assert_eq!(target.bytes, before);
+ target.copy_property(&source, 0x20000001).unwrap();
+ assert_eq!(PropertySets::parse(&target.bytes).unwrap().sets[0].len(), 1);
+ let before = target.bytes.clone();
+ let ids = std::sync::Arc::clone(&target.global_ids);
+ let invalid = super::PropertyObject {
+ global_ids: Default::default(),
+ ..source
+ };
+ assert!(target.copy_property(&invalid, 0x40000002).is_err());
+ assert_eq!(target.bytes, before);
+ assert_eq!(target.global_ids, ids);
}
#[test]
@@ -599,6 +666,13 @@ fn deep_property_splices_do_not_use_the_call_stack() {
let parsed = PropertySets::parse(&object.bytes).unwrap();
assert_eq!(parsed.sets.len(), 100_001);
assert_eq!(&object.bytes[..bytes.len()], &bytes);
+ let mut copied = super::PropertyObject {
+ jcid: object.jcid,
+ bytes: properties(&[]).unwrap(),
+ global_ids: Default::default(),
+ };
+ copied.copy_property(&object, 0x44000001).unwrap();
+ assert_eq!(copied.bytes, object.bytes);
object.remove(&[0x44000001]).unwrap();
let parsed = PropertySets::parse(&object.bytes).unwrap();
assert_eq!(parsed.sets.len(), 1);
diff --git a/crates/onestore/tests/paragraph.rs b/crates/onestore/tests/paragraph.rs
index 4767022d053c4b677a6cd49151b564757c906bd9..e2f754a9d7ea6eab32f97f177ea7c930be7a673c 100644
--- a/crates/onestore/tests/paragraph.rs
+++ b/crates/onestore/tests/paragraph.rs
@@ -1,5 +1,5 @@
use onestore::{
- ExGuid, ParagraphSplit, PreparedEdit, RevisionIndex, Store,
+ ExGuid, ParagraphJoin, ParagraphSplit, PreparedEdit, RevisionIndex, Store,
document::{Document, Kind, Revision},
};
use serde_json::Value;
@@ -25,6 +25,270 @@ fn characters(view: &Revision<'_>, id: ExGuid) -> Vec<(char, Value)> {
.collect()
}
+const JOIN_FIXTURES: [(&[u8], &[u8], bool); 3] = [
+ (
+ include_bytes!("../../../corpus/paragraph-edit/split/notebook/synthetic.one").as_slice(),
+ include_bytes!("../../../corpus/paragraph-edit/joined/notebook/synthetic.one").as_slice(),
+ true,
+ ),
+ (
+ include_bytes!("../../../corpus/paragraph-edit/join-edges/before/notebook/synthetic.one")
+ .as_slice(),
+ include_bytes!("../../../corpus/paragraph-edit/join-edges/joined/notebook/synthetic.one")
+ .as_slice(),
+ false,
+ ),
+ (
+ include_bytes!("../../../corpus/paragraph-edit/join-tags/before/notebook/synthetic.one")
+ .as_slice(),
+ include_bytes!("../../../corpus/paragraph-edit/join-tags/joined/notebook/synthetic.one")
+ .as_slice(),
+ false,
+ ),
+];
+
+fn join_targets(document: &Document<'_>, split_cases: bool) -> Vec<(String, ExGuid, ExGuid)> {
+ let mut cases = Vec::new();
+ if split_cases {
+ let manifest: Value =
+ serde_json::from_str(include_str!("../../../corpus/paragraph-edit/manifest.json"))
+ .unwrap();
+ for case in manifest["cases"].as_array().unwrap() {
+ cases.push((
+ case["case"].as_str().unwrap().to_owned(),
+ serde_json::from_value::(case["original_text"].clone()).unwrap(),
+ serde_json::from_value::(case["new_text"].clone()).unwrap(),
+ ));
+ }
+ } else {
+ for (sid, page) in document.pages().unwrap() {
+ let s = &document.spaces[&sid];
+ let view = &s.revisions[&s.contexts[&ExGuid::default()]];
+ let Kind::Metadata {
+ title: Some(name), ..
+ } = &view.nodes[&view.roots[&2]].kind
+ else {
+ panic!()
+ };
+ if !name.starts_with("Join ") {
+ continue;
+ }
+ let outline = view.nodes[&page]
+ .children
+ .iter()
+ .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. }))
+ .unwrap();
+ let mut left = view.nodes[outline].children[0];
+ while let Some(child) = view.nodes[&left].children.last() {
+ left = *child;
+ }
+ let right = view.nodes[outline].children[1];
+ cases.push((
+ name.clone(),
+ view.nodes[&left].content[0],
+ view.nodes[&right].content[0],
+ ));
+ }
+ }
+ assert_eq!(cases.len(), document.pages().unwrap().len() - 1);
+ cases
+}
+
+#[test]
+fn joins_match_native_graphs_tags_and_inherited_character_styles() {
+ for (source, native, split_cases) in JOIN_FIXTURES {
+ let store = Store::parse(source).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ let document = Document::parse(&index).unwrap();
+ let native_store = Store::parse(native).unwrap();
+ let native_index = RevisionIndex::parse(&native_store).unwrap();
+ let native_document = Document::parse(&native_index).unwrap();
+ let cases = join_targets(&document, split_cases);
+ for (name, left, right) in cases {
+ let (sid, space) = document
+ .spaces
+ .iter()
+ .find(|(_, space)| {
+ space.revisions[&space.contexts[&ExGuid::default()]]
+ .nodes
+ .contains_key(&left)
+ })
+ .unwrap();
+ let intent = ParagraphJoin::new(left, right, "Join author").unwrap();
+ let restored = serde_json::from_value(serde_json::to_value(&intent).unwrap()).unwrap();
+ assert_eq!(intent, restored);
+ let edited = PreparedEdit::join(source, *sid, &restored);
+ if name == "Split before hyperlink" {
+ assert!(edited.is_err());
+ continue;
+ }
+ let edited = edited.unwrap_or_else(|error| panic!("{name}: {error}"));
+ 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 = ¤t_document.spaces[sid];
+ let current = ¤t.revisions[¤t.contexts[&ExGuid::default()]];
+ let expected = &native_document.spaces[sid];
+ let expected = &expected.revisions[&expected.contexts[&ExGuid::default()]];
+ let (_, page) = document
+ .pages()
+ .unwrap()
+ .into_iter()
+ .find(|(id, _)| id == sid)
+ .unwrap();
+ let before = &space.revisions[&space.contexts[&ExGuid::default()]];
+ let left_paragraph = *before
+ .nodes
+ .iter()
+ .find(|(_, node)| node.content == [left])
+ .unwrap()
+ .0;
+ let right_paragraph = *before
+ .nodes
+ .iter()
+ .find(|(_, node)| node.content == [right])
+ .unwrap()
+ .0;
+ let right_parent = *before
+ .nodes
+ .iter()
+ .find(|(_, node)| node.children.contains(&right_paragraph))
+ .unwrap()
+ .0;
+ let survivor = if characters(before, left).is_empty() {
+ right
+ } else {
+ left
+ };
+ let mut changed = std::collections::BTreeSet::from([survivor, before.roots[&2]]);
+ let mut ancestors = vec![left_paragraph, right_parent];
+ while let Some(id) = ancestors.pop() {
+ if !changed.insert(id) || id == page {
+ continue;
+ }
+ ancestors.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 current_rid = current_document.spaces[sid].contexts[&ExGuid::default()];
+ let new_raw = current_index.resolve(*sid, current_rid).unwrap();
+ for (id, object) in old_raw.objects {
+ if !changed.contains(&id) {
+ assert_eq!(
+ object.data, new_raw.objects[&id].data,
+ "{name} untouched {id}"
+ );
+ }
+ }
+ let mut pending: Vec<_> = expected.nodes[&page]
+ .children
+ .iter()
+ .filter(|id| matches!(expected.nodes[id].kind, Kind::Outline { .. }))
+ .copied()
+ .collect();
+ while let Some(id) = pending.pop() {
+ let node = &expected.nodes[&id];
+ assert_eq!(
+ current.nodes[&id].children, node.children,
+ "{name} children {id}"
+ );
+ assert_eq!(
+ current.nodes[&id].content, node.content,
+ "{name} content {id}"
+ );
+ assert_eq!(
+ current.nodes[&id].child_level, node.child_level,
+ "{name} indentation {id}"
+ );
+ pending.extend(
+ node.children
+ .iter()
+ .chain(&node.content)
+ .chain(&node.structure)
+ .copied(),
+ );
+ if matches!(node.kind, Kind::RichText { .. }) {
+ assert_eq!(
+ serde_json::to_value(¤t.nodes[&id].tags).unwrap(),
+ serde_json::to_value(&node.tags).unwrap(),
+ "{name} tags {id}"
+ );
+ let normalize = |view, id| {
+ characters(view, id)
+ .into_iter()
+ .map(|(c, mut style)| {
+ let fields = style.as_object_mut().unwrap();
+ for key in
+ ["alignment", "space_before", "space_after", "line_spacing"]
+ {
+ fields.remove(key);
+ }
+ for key in [
+ "bold",
+ "italic",
+ "underline",
+ "strike",
+ "superscript",
+ "subscript",
+ "hidden",
+ "hyperlink",
+ "hyperlink_label",
+ "math",
+ "embedded_object",
+ "rtl",
+ ] {
+ if fields[key].is_null() {
+ fields.insert(key.into(), false.into());
+ }
+ }
+ for key in ["color", "highlight"] {
+ if fields[key].is_null() {
+ fields.insert(key.into(), 0xff000000_u32.into());
+ }
+ }
+ (c, style)
+ })
+ .collect::>()
+ };
+ assert_eq!(
+ normalize(current, id),
+ normalize(expected, id),
+ "{name} styles {id}"
+ );
+ }
+ }
+ for (sid, old) in &index.spaces {
+ for revision in old.revisions.keys() {
+ let old = index.resolve(*sid, *revision).unwrap();
+ let retained = current_index.resolve(*sid, *revision).unwrap();
+ assert_eq!(old.roots, retained.roots);
+ for (id, object) in old.objects {
+ assert_eq!(object.data, retained.objects[&id].data);
+ }
+ }
+ }
+ assert!(PreparedEdit::join(edited.as_bytes(), *sid, &intent).is_err());
+ assert_eq!(
+ space.contexts.len(),
+ current_document.spaces[sid].contexts.len()
+ );
+ }
+ }
+}
+
#[test]
fn splits_partition_native_paragraphs_at_every_scalar_boundary() {
let manifest: Value =
@@ -341,3 +605,179 @@ fn interrupted_splits_publish_a_complete_graph_or_retain_the_original() {
}
}
}
+
+#[test]
+#[ignore = "exports paragraph joins for independent native validation"]
+fn export_native_paragraph_joins() {
+ use std::{fs, path::PathBuf};
+ let output = PathBuf::from(std::env::var_os("ONESTORE_PARAGRAPH_JOIN_OUTPUT").unwrap());
+ assert!(output.is_absolute());
+ fs::create_dir(&output).unwrap();
+ for (number, (source, _, split_cases)) in JOIN_FIXTURES.into_iter().enumerate() {
+ let store = Store::parse(source).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ let document = Document::parse(&index).unwrap();
+ let cases = join_targets(&document, split_cases);
+ let mut source = source.to_vec();
+ let mut manifest = Vec::new();
+ for (name, left, right) in cases {
+ if name == "Split before hyperlink" {
+ continue;
+ }
+ let sid = *document
+ .spaces
+ .iter()
+ .find(|(_, space)| {
+ space.revisions[&space.contexts[&ExGuid::default()]]
+ .nodes
+ .contains_key(&left)
+ })
+ .unwrap()
+ .0;
+ let intent = ParagraphJoin::new(left, right, "Rust join author").unwrap();
+ let edit = PreparedEdit::join(&source, sid, &intent).unwrap();
+ source = edit.as_bytes().to_vec();
+ manifest.push(serde_json::json!({"case":name,"space":sid,"intent":intent}));
+ }
+ let folder = output.join(["split", "inheritance", "tags"][number]);
+ fs::create_dir_all(folder.join("candidate")).unwrap();
+ fs::write(folder.join("candidate/synthetic.one"), source).unwrap();
+ fs::write(
+ folder.join("manifest.json"),
+ serde_json::to_vec_pretty(&manifest).unwrap(),
+ )
+ .unwrap();
+ }
+}
+
+#[test]
+fn interrupted_joins_preserve_complete_graphs_and_empty_text_adoption() {
+ let source = onestore::create_section("join.one", "Left", "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 left = view.nodes[&view.nodes[&outline].children[0]].content[0];
+ let right = onestore::Insertion::paragraph(outline, None, "Right 🦀", "Author")
+ .unwrap()
+ .with_formatting(0..5, &[onestore::TextAttribute::Bold(true)])
+ .unwrap();
+ let inserted = PreparedEdit::insert(&source, sid, &right).unwrap();
+ let child =
+ onestore::Insertion::paragraph(right.object(), None, "Retained child", "Author").unwrap();
+ let original = PreparedEdit::insert(inserted.as_bytes(), sid, &child).unwrap();
+ let original = original.as_bytes();
+ let empty = onestore::replace_text(original, sid, left, 0..4, "").unwrap();
+ let checkpoint = checkpoint::pending(original, sid, left, 0x14001d7a);
+ for source in [original, &empty, &checkpoint] {
+ let intent = ParagraphJoin::new(left, right.text_object(), "Join author").unwrap();
+ let edit = PreparedEdit::join(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: 917,
+ };
+ let mut successful = disk(None);
+ edit.commit(&mut successful).unwrap();
+ assert_eq!(successful.durable, edit.as_bytes());
+ for at in std::iter::once(1).chain(source.len().div_ceil(193)..=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 => {}
+ }
+ }
+ }
+ }
+}
+
+#[test]
+fn joins_reject_invalid_identities_wrong_order_and_unrelated_pages() {
+ let (source, _, _) = JOIN_FIXTURES[0];
+ let store = Store::parse(source).unwrap();
+ let index = RevisionIndex::parse(&store).unwrap();
+ let document = Document::parse(&index).unwrap();
+ let cases = join_targets(&document, true);
+ let (_, left, right) = &cases[0];
+ let (sid, space) = document
+ .spaces
+ .iter()
+ .find(|(_, space)| {
+ space.revisions[&space.contexts[&ExGuid::default()]]
+ .nodes
+ .contains_key(left)
+ })
+ .unwrap();
+ assert!(ParagraphJoin::new(*left, *left, "Author").is_err());
+ assert!(ParagraphJoin::new(ExGuid::default(), *right, "Author").is_err());
+ assert!(ParagraphJoin::new(*left, *right, "a\0b").is_err());
+ let intent = ParagraphJoin::new(*left, *right, "Author").unwrap();
+ for (field, value) in [
+ ("left", serde_json::to_value(ExGuid::default()).unwrap()),
+ ("right", serde_json::to_value(left).unwrap()),
+ ("author", serde_json::json!("a\0b")),
+ ] {
+ let mut encoded = serde_json::to_value(&intent).unwrap();
+ encoded[field] = value;
+ let invalid = serde_json::from_value(encoded).unwrap();
+ assert!(PreparedEdit::join(source, *sid, &invalid).is_err());
+ }
+ for intent in [
+ ParagraphJoin::new(*right, *left, "Author").unwrap(),
+ ParagraphJoin::new(*left, cases[1].2, "Author").unwrap(),
+ ] {
+ assert!(PreparedEdit::join(source, *sid, &intent).is_err());
+ }
+ let view = &space.revisions[&space.contexts[&ExGuid::default()]];
+ let paragraph = *view
+ .nodes
+ .iter()
+ .find(|(_, node)| node.content == [*left])
+ .unwrap()
+ .0;
+ let parent = view
+ .nodes
+ .values()
+ .find(|node| node.children.contains(¶graph))
+ .unwrap();
+ let last = view.nodes[parent.children.last().unwrap()].content[0];
+ let nonadjacent = ParagraphJoin::new(*left, last, "Author").unwrap();
+ assert!(PreparedEdit::join(source, *sid, &nonadjacent).is_err());
+ let title = view
+ .nodes
+ .iter()
+ .find_map(|(_, node)| matches!(node.kind, Kind::Title).then_some(node))
+ .unwrap();
+ let mut pending = title.children.clone();
+ let mut rejected = 0;
+ while let Some(id) = pending.pop() {
+ let node = &view.nodes[&id];
+ pending.extend(node.children.iter().chain(&node.content).copied());
+ if matches!(node.kind, Kind::RichText { .. }) {
+ let intent = ParagraphJoin::new(id, *right, "Author").unwrap();
+ assert!(PreparedEdit::join(source, *sid, &intent).is_err());
+ rejected += 1;
+ }
+ }
+ assert!(rejected > 0);
+}
diff --git a/fuzz/fuzz_targets/paragraph.rs b/fuzz/fuzz_targets/paragraph.rs
index fcc5b46163d65f926a61b277abe432623a0410df..a7600901c0d5118d31963e56b19ea365bced0b32 100644
--- a/fuzz/fuzz_targets/paragraph.rs
+++ b/fuzz/fuzz_targets/paragraph.rs
@@ -1,8 +1,8 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
use onestore::{
- CommitState, ExGuid, Insertion, ParagraphSplit, PreparedEdit, RevisionIndex, Store,
- TextAttribute as A,
+ CommitState, ExGuid, Insertion, ParagraphJoin, ParagraphSplit, PreparedEdit, RevisionIndex,
+ Store, TextAttribute as A,
document::{Document, Kind},
};
use std::sync::LazyLock;
@@ -43,6 +43,11 @@ fuzz_target!(|input: &[u8]| {
{
current::current(edit.as_bytes());
}
+ if let Ok(intent) = serde_json::from_slice::(input)
+ && let Ok(edit) = PreparedEdit::join(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) {
@@ -65,57 +70,91 @@ fuzz_target!(|input: &[u8]| {
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()))
+ let characters = |view: &onestore::document::Revision<'_>, id| {
+ 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::>()
+ };
+ let pairs: Vec<_> = view
+ .nodes
+ .iter()
+ .flat_map(|(parent, node)| {
+ node.children.windows(2).filter_map(|pair| {
+ (paragraphs.contains(&pair[0])
+ && paragraphs.contains(&pair[1])
+ && view.nodes[&pair[0]].children.is_empty())
+ .then_some((*parent, pair[0], pair[1]))
+ })
})
.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 mut expected_text = Vec::new();
+ let mut expected_graph = Vec::new();
+ let edit = if step[1] & 8 != 0 && !pairs.is_empty() {
+ let (parent, left, right) = pairs[usize::from(step[2]) % pairs.len()];
+ let a = view.nodes[&left].content[0];
+ let b = view.nodes[&right].content[0];
+ let mut expected = characters(view, a);
+ let survivor = if expected.is_empty() { b } else { a };
+ expected.extend(characters(view, b));
+ expected_text.push((survivor, expected));
+ let children: Vec<_> = view.nodes[&parent]
+ .children
+ .iter()
+ .filter(|id| **id != right)
+ .copied()
+ .collect();
+ expected_graph.push((parent, children, view.nodes[&parent].content.clone()));
+ expected_graph.push((left, view.nodes[&right].children.clone(), vec![survivor]));
+ let intent = ParagraphJoin::new(a, b, "Paragraph fuzz").unwrap();
+ let restored = serde_json::from_value(serde_json::to_value(&intent).unwrap()).unwrap();
+ assert_eq!(intent, restored);
+ PreparedEdit::join(source, *sid, &restored).unwrap()
+ } else {
+ let paragraph = paragraphs[usize::from(step[2]) % paragraphs.len()];
+ let text = view.nodes[¶graph].content[0];
+ let before = characters(view, text);
+ let offsets: Vec = std::iter::once(0)
+ .chain(before.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 restored = serde_json::from_value(serde_json::to_value(&intent).unwrap()).unwrap();
+ assert_eq!(intent, restored);
+ let edit = PreparedEdit::split(source, *sid, &restored);
+ let Some(position) = offsets.iter().position(|n| *n == offset) else {
+ assert!(edit.is_err());
+ continue;
+ };
+ expected_text.push((text, before[..position].to_vec()));
+ expected_text.push((intent.text_object(), before[position..].to_vec()));
+ expected_graph.push((paragraph, vec![], vec![text]));
+ expected_graph.push((
+ intent.object(),
+ view.nodes[¶graph].children.clone(),
+ vec![intent.text_object()],
+ ));
+ edit.unwrap()
};
- 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);
+ for (id, expected) in expected_text {
+ assert_eq!(characters(after_view, id), expected);
+ }
+ for (id, children, content) in expected_graph {
+ assert_eq!(after_view.nodes[&id].children, children);
+ assert_eq!(after_view.nodes[&id].content, content);
}
- 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 {
diff --git a/tools/native/paragraph-tag-joins.ps1 b/tools/native/paragraph-tag-joins.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..023be19ffda21fb41d2fd9655a659df98be2c82b
--- /dev/null
+++ b/tools/native/paragraph-tag-joins.ps1
@@ -0,0 +1,39 @@
+param([Parameter(Mandatory=$true)][string]$Root, [Parameter(Mandatory=$true)][string]$CloneHost)
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+& "$PSScriptRoot\cold-current.ps1" -Root $Root -CloneHost $CloneHost
+$app = New-Object -ComObject OneNote.Application
+$notebook = ''
+try {
+ $app.OpenHierarchy((Join-Path $Root 'notebook'), '', [ref]$notebook, 0)
+ $section = ''
+ $app.OpenHierarchy('synthetic.one', $notebook, [ref]$section, 0)
+ $mixed = 'Bold italic ' + [char]::ConvertFromUtf32(0x1F980) + ' e' + [char]0x0301 + ' tail'
+ $text = ''
+ $child = 'Preserved child'
+ $tag = ''
+ $rightTag = $tag.Replace('index="0"', 'index="1"')
+ $cases = @(
+ @{ name = 'Join empty left right tag'; body = '' + $rightTag + $text + '' },
+ @{ name = 'Join empty left two tags'; body = '' + $tag + '' + $rightTag + $text + '' },
+ @{ name = 'Join different tags'; body = '' + $tag + 'Left' + $rightTag + $text + '' }
+ )
+ $inputs = Join-Path $Root 'inputs'
+ New-Item -ItemType Directory -Path $inputs | Out-Null
+ foreach ($case in $cases) {
+ $page = ''
+ $app.CreateNewPage($section, [ref]$page, 0)
+ $xml = '' + $case.name + '' + $case.body + 'Preserved sibling'
+ [IO.File]::WriteAllText((Join-Path $inputs ($case.name + '.xml')), $xml, [Text.Encoding]::UTF8)
+ $app.UpdatePageContent($xml, [DateTime]::MinValue, 1, $true)
+ $case.page = $page
+ }
+ $cases | ConvertTo-Json -Depth 6 | Set-Content (Join-Path $Root 'cases.json') -Encoding UTF8
+ $app.SyncHierarchy($notebook)
+} finally {
+ if ($notebook) { $app.CloseNotebook($notebook, $false) }
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app)
+ $app = $null
+ [GC]::Collect()
+ [GC]::WaitForPendingFinalizers()
+}
diff --git a/tools/test_paragraph_edit.py b/tools/test_paragraph_edit.py
index 4813b927f421f18a8c24b9859ec77d372412985b..dd58f808b585e7a2b4e84e921ab4decab224f1bd 100644
--- a/tools/test_paragraph_edit.py
+++ b/tools/test_paragraph_edit.py
@@ -17,60 +17,100 @@ compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
class ParagraphEditTest(unittest.TestCase):
+ def test_rust_joins_match_native_controls_and_retain_cold_graph_identities(self):
+ for name, control in [('split', FIXTURE / 'joined/read'),
+ ('inheritance', FIXTURE / 'join-edges/joined/read'),
+ ('tags', FIXTURE / 'join-tags/joined/read')]:
+ fixture = FIXTURE / 'rust-join' / name
+ manifest = json.loads((fixture / 'manifest.json').read_text())
+ with self.subTest(fixture=name), TemporaryDirectory() as temporary:
+ models = []
+ for source in ('candidate', 'native/notebook'):
+ folder = Path(temporary) / source.replace('/', '-')
+ shutil.copytree(fixture / 'native/read', folder / 'read')
+ compare(fixture / source, folder / 'read')
+ subprocess.run([EXPORTER, fixture / source / 'synthetic.one', folder / 'model'], check=True)
+ document = json.loads((folder / 'model/document.json').read_text())
+ models.append({r['nodes'][r['roots']['2']]['kind']['title']: (r, page)
+ for _, _, r, page in ordered_pages(document)})
+ self.assertEqual(models[0].keys(), models[1].keys())
+ for title, (old, page) in models[0].items():
+ saved, saved_page = models[1][title]
+ self.assertEqual(page, saved_page)
+ for outline in old['nodes'][page]['children']:
+ if old['nodes'][outline]['kind']['type'] != 'Outline': continue
+ for oid, node in walk(old, outline):
+ self.assertEqual(saved['nodes'][oid]['children'], node['children'])
+ self.assertEqual(saved['nodes'][oid]['content'], node['content'])
+ captures = []
+ for folder in (control, fixture / 'native/read'):
+ captures.append({page.get('name'): native_characters(page, page.findall('one:Outline', ns))
+ for path in folder.glob('page-*.xml') for page in [ET.parse(path).getroot()]})
+ self.assertEqual(len(manifest), {'split': 12, 'inheritance': 5, 'tags': 3}[name])
+ for case in manifest:
+ for a, b in zip(captures[0][case['case']], captures[1][case['case']], strict=True):
+ for (x, old), (y, new) in zip(a, b, strict=True):
+ self.assertEqual(x, y)
+ for key in old.keys() | new.keys():
+ default = 'automatic' if key in ('color', 'highlight') else False
+ self.assertEqual(old.get(key, default), new.get(key, default))
+
def test_native_joins_preserve_inherited_styles_and_follow_tag_and_child_rules(self):
- fixture = FIXTURE / 'join-edges'
- models, captures = {}, {}
- with TemporaryDirectory() as temporary:
- for phase in ('before', 'joined'):
- folder = Path(temporary) / phase
- shutil.copytree(fixture / phase / 'read', folder / 'read')
- compare(fixture / phase / 'notebook', folder / 'read')
- subprocess.run([EXPORTER, fixture / phase / 'notebook/synthetic.one', folder / 'model'], check=True)
- model = json.loads((folder / 'model/document.json').read_text())
- models[phase] = {r['nodes'][r['roots']['2']]['kind']['title']: (r, page)
- for _, _, r, page in ordered_pages(model)}
- captures[phase] = {}
- for path in (folder / 'read').glob('page-*.xml'):
- page = ET.parse(path).getroot()
- captures[phase][page.get('name')] = native_characters(page, page.findall('one:Outline', ns))
- self.assertEqual(len(models[phase]), 6)
- cases = json.loads((fixture / 'cases.json').read_text(encoding='utf-8-sig'))
- self.assertEqual(len(cases), 5)
- for case in cases:
- name = case['name']
- with self.subTest(case=name):
- old, page = models['before'][name]
- new, new_page = models['joined'][name]
- self.assertEqual(page, new_page)
- outline, = [oid for oid in old['nodes'][page]['children'] if old['nodes'][oid]['kind']['type'] == 'Outline']
- left, right, sibling = old['nodes'][outline]['children']
- self.assertEqual(new['nodes'][outline]['children'], [left, sibling])
- target = old['nodes'][left]['children'][-1] if name == 'Join both children' else left
- a, = old['nodes'][target]['content']
- b, = old['nodes'][right]['content']
- empty = old['nodes'][a]['kind']['text'] == ''
- survivor = b if empty else a
- self.assertEqual(new['nodes'][target]['content'], [survivor])
- self.assertEqual(new['nodes'][survivor]['kind']['text'], old['nodes'][a]['kind']['text'] + old['nodes'][b]['kind']['text'])
- self.assertEqual(new['nodes'][survivor]['tags'], old['nodes'][a]['tags'])
- children = old['nodes'][left]['children'] + old['nodes'][right]['children']
- self.assertEqual(new['nodes'][left]['children'], children)
- self.assertEqual(new['nodes'][sibling], old['nodes'][sibling])
- if name == 'Join both children':
- parent_text, = old['nodes'][left]['content']
- original = old['nodes'][parent_text]
- current = new['nodes'][parent_text]
- self.assertGreater(current['modified'], original['modified'])
- expected = {**original, 'modified': current['modified'],
- 'extra': [original['extra'][0] + [{'id': 0x880034dd, 'value': 'NoData'}]]}
- self.assertEqual(current, expected)
- before = [v for paragraph in captures['before'][name] for v in paragraph]
- after = [v for paragraph in captures['joined'][name] for v in paragraph]
- for (a, old_style), (b, new_style) in zip(before, after, strict=True):
- self.assertEqual(a, b)
- for key in old_style.keys() | new_style.keys():
- default = 'automatic' if key in ('color', 'highlight') else False
- self.assertEqual(old_style.get(key, default), new_style.get(key, default))
+ for fixture_name, expected_cases in [('join-edges', 5), ('join-tags', 3)]:
+ fixture = FIXTURE / fixture_name
+ models, captures = {}, {}
+ with TemporaryDirectory() as temporary:
+ for phase in ('before', 'joined'):
+ folder = Path(temporary) / phase
+ shutil.copytree(fixture / phase / 'read', folder / 'read')
+ compare(fixture / phase / 'notebook', folder / 'read')
+ subprocess.run([EXPORTER, fixture / phase / 'notebook/synthetic.one', folder / 'model'], check=True)
+ model = json.loads((folder / 'model/document.json').read_text())
+ models[phase] = {r['nodes'][r['roots']['2']]['kind']['title']: (r, page)
+ for _, _, r, page in ordered_pages(model)}
+ captures[phase] = {}
+ for path in (folder / 'read').glob('page-*.xml'):
+ page = ET.parse(path).getroot()
+ captures[phase][page.get('name')] = native_characters(page, page.findall('one:Outline', ns))
+ self.assertEqual(len(models[phase]), expected_cases + 1)
+ cases = json.loads((fixture / 'cases.json').read_text(encoding='utf-8-sig'))
+ self.assertEqual(len(cases), expected_cases)
+ for case in cases:
+ name = case['name']
+ with self.subTest(case=name):
+ old, page = models['before'][name]
+ new, new_page = models['joined'][name]
+ self.assertEqual(page, new_page)
+ outline, = [oid for oid in old['nodes'][page]['children'] if old['nodes'][oid]['kind']['type'] == 'Outline']
+ left, right, sibling = old['nodes'][outline]['children']
+ self.assertEqual(new['nodes'][outline]['children'], [left, sibling])
+ target = old['nodes'][left]['children'][-1] if name == 'Join both children' else left
+ a, = old['nodes'][target]['content']
+ b, = old['nodes'][right]['content']
+ empty = old['nodes'][a]['kind']['text'] == ''
+ survivor = b if empty else a
+ self.assertEqual(new['nodes'][target]['content'], [survivor])
+ self.assertEqual(new['nodes'][survivor]['kind']['text'], old['nodes'][a]['kind']['text'] + old['nodes'][b]['kind']['text'])
+ self.assertEqual(new['nodes'][survivor]['tags'], old['nodes'][a]['tags'])
+ children = old['nodes'][left]['children'] + old['nodes'][right]['children']
+ self.assertEqual(new['nodes'][left]['children'], children)
+ self.assertEqual(new['nodes'][sibling], old['nodes'][sibling])
+ if name == 'Join both children':
+ parent_text, = old['nodes'][left]['content']
+ original = old['nodes'][parent_text]
+ current = new['nodes'][parent_text]
+ self.assertGreater(current['modified'], original['modified'])
+ expected = {**original, 'modified': current['modified'],
+ 'extra': [original['extra'][0] + [{'id': 0x880034dd, 'value': 'NoData'}]]}
+ self.assertEqual(current, expected)
+ before = [v for paragraph in captures['before'][name] for v in paragraph]
+ after = [v for paragraph in captures['joined'][name] for v in paragraph]
+ for (a, old_style), (b, new_style) in zip(before, after, strict=True):
+ self.assertEqual(a, b)
+ for key in old_style.keys() | new_style.keys():
+ default = 'automatic' if key in ('color', 'highlight') else False
+ self.assertEqual(old_style.get(key, default), new_style.get(key, default))
+
def test_rust_splits_match_native_controls_and_preserve_empty_typing_styles(self):
fixture = FIXTURE / 'rust-split'